Merge lp:~jtv/maas/bug-1025582-model into lp:maas/trunk

Proposed by Jeroen T. Vermeulen on 2012-09-11
Status: Merged
Approved by: Jeroen T. Vermeulen on 2012-09-13
Approved revision: 988
Merged at revision: 990
Proposed branch: lp:~jtv/maas/bug-1025582-model
Merge into: lp:maas/trunk
Diff against target: 398 lines (+339/-1)
5 files modified
src/maasserver/migrations/0025_add_bootimage_model.py (+182/-0)
src/maasserver/models/__init__.py (+4/-1)
src/maasserver/models/bootimage.py (+89/-0)
src/maasserver/testing/factory.py (+17/-0)
src/maasserver/tests/test_bootimage.py (+47/-0)
To merge this branch: bzr merge lp:~jtv/maas/bug-1025582-model
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve on 2012-09-13
Raphaël Badin (community) 2012-09-11 Needs Information on 2012-09-12
Review via email: mp+123679@code.launchpad.net

Commit Message

New model: BootImage. Represents an available boot image, so that the server can detect use of missing images.

This new model represents what types of node we can boot which releases on, for which purpose. Since the inventory only changes when we have a new image, complete and ready to install, there is no provision for disappearing images.

Description of the Change

The master worker (which runs on the same machine as the TFTP server) will report available images regularly through the MAAS API. The download script can't do this directly because it has no API credentials.

Nobody was available to pre-imp this part of the work with, so read critically.

Jeroen

To post a comment you must log in.
lp:~jtv/maas/bug-1025582-model updated on 2012-09-11
985. By Jeroen T. Vermeulen on 2012-09-11

Merge trunk workaround for Django fix that broke our tests.

Raphaël Badin (rvb) wrote :

Looks generally good, but I really wonder about [1], so "needs information".

[0]

src/maasserver/migrations/0021_add_bootimage_model.py

Would you mind renaming this 0025_add_bootimage_model.py? I've recently landed migrations 21 and 22 and I've got branches in review for migrations 23 and 24.

[1]

297 + architecture = CharField(max_length=255, blank=False)
298 + subarchitecture = CharField(max_length=255, blank=False)
299 + release = CharField(max_length=255, blank=False)
300 + purpose = CharField(max_length=255, blank=False)

Why didn't you use CharField with choices here (same as, for instance, node.architecture)?

review: Needs Information
lp:~jtv/maas/bug-1025582-model updated on 2012-09-13
986. By Jeroen T. Vermeulen on 2012-09-13

Review change: renumber DB migration as newer branches overtake this one in review.

Jeroen T. Vermeulen (jtv) wrote :

> Looks generally good, but I really wonder about [1], so "needs information".
>
> [0]
>
> src/maasserver/migrations/0021_add_bootimage_model.py
>
> Would you mind renaming this 0025_add_bootimage_model.py? I've recently
> landed migrations 21 and 22 and I've got branches in review for migrations 23
> and 24.

Done.

> [1]
>
> 297 + architecture = CharField(max_length=255, blank=False)
> 298 + subarchitecture = CharField(max_length=255, blank=False)
> 299 + release = CharField(max_length=255, blank=False)
> 300 + purpose = CharField(max_length=255, blank=False)
>
> Why didn't you use CharField with choices here (same as, for instance,
> node.architecture)?

Because I thought that Django might possibly enforce it, which would not be what I wanted: registering available images shouldn't break just because you've got an architecture that the server isn't aware of yet.

But as it turns out the choices option isn't model-related. It's purely for the UI, which this model doesn't have — so probably not worth holding up these reviews for! I added it and made the fields non-editable.

lp:~jtv/maas/bug-1025582-model updated on 2012-09-13
987. By Jeroen T. Vermeulen on 2012-09-13

Review change: set choices for architecture field.

Julian Edwards (julian-edwards) wrote :

On Thursday 13 September 2012 03:25:22 you wrote:
> > [1]
> >
> > 297 + architecture = CharField(max_length=255, blank=False)
> > 298 + subarchitecture = CharField(max_length=255, blank=False)
> > 299 + release = CharField(max_length=255, blank=False)
> > 300 + purpose = CharField(max_length=255, blank=False)
> >
> > Why didn't you use CharField with choices here (same as, for instance,
> > node.architecture)?
>
> Because I thought that Django might possibly enforce it, which would not be
> what I wanted: registering available images shouldn't break just because
> you've got an architecture that the server isn't aware of yet.
>
> But as it turns out the choices option isn't model-related. It's purely for
> the UI, which this model doesn't have — so probably not worth holding up
> these reviews for! I added it and made the fields non-editable.

We want to rid the code of all enums for architecture because having one makes
it impossible to install arbitrary new architectures. That is something we
need to support in the near-ish future.

Julian Edwards (julian-edwards) wrote :

Approved as it was, let's remove the choices.

review: Approve
lp:~jtv/maas/bug-1025582-model updated on 2012-09-13
988. By Jeroen T. Vermeulen on 2012-09-13

Julian's suggestions.

Jeroen T. Vermeulen (jtv) wrote :

Thanks. Choices removed, fields documented, propagating changes to successor branches.

Raphaël Badin (rvb) wrote :

>> [0]
>>
>> src/maasserver/migrations/0021_add_bootimage_model.py
>>
>> Would you mind renaming this 0025_add_bootimage_model.py? I've recently
>> landed migrations 21 and 22 and I've got branches in review for migrations 23
>> and 24.

>Done.

rarg, that was a bad suggestion from me :/. I forgot that each migration not only has information about the delta (what field needs to be added/removed etc.) but also about all of the models. I also spotted a weird (South-related?) problem with the recently uuid field so I'll see about fixing that…

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/migrations/0025_add_bootimage_model.py'
2--- src/maasserver/migrations/0025_add_bootimage_model.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/migrations/0025_add_bootimage_model.py 2012-09-13 03:54:19 +0000
4@@ -0,0 +1,182 @@
5+# encoding: utf-8
6+import datetime
7+
8+from django.db import models
9+from south.db import db
10+from south.v2 import SchemaMigration
11+
12+
13+class Migration(SchemaMigration):
14+
15+ def forwards(self, orm):
16+
17+ # Adding model 'BootImage'
18+ db.create_table(u'maasserver_bootimage', (
19+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
20+ ('architecture', self.gf('django.db.models.fields.CharField')(max_length=255)),
21+ ('subarchitecture', self.gf('django.db.models.fields.CharField')(max_length=255)),
22+ ('release', self.gf('django.db.models.fields.CharField')(max_length=255)),
23+ ('purpose', self.gf('django.db.models.fields.CharField')(max_length=255)),
24+ ))
25+ db.send_create_signal(u'maasserver', ['BootImage'])
26+
27+ # Adding unique constraint on 'BootImage', fields ['architecture', 'subarchitecture', 'release', 'purpose']
28+ db.create_unique(u'maasserver_bootimage', ['architecture', 'subarchitecture', 'release', 'purpose'])
29+
30+
31+ def backwards(self, orm):
32+
33+ # Removing unique constraint on 'BootImage', fields ['architecture', 'subarchitecture', 'release', 'purpose']
34+ db.delete_unique(u'maasserver_bootimage', ['architecture', 'subarchitecture', 'release', 'purpose'])
35+
36+ # Deleting model 'BootImage'
37+ db.delete_table(u'maasserver_bootimage')
38+
39+
40+ models = {
41+ 'auth.group': {
42+ 'Meta': {'object_name': 'Group'},
43+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
44+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
45+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
46+ },
47+ 'auth.permission': {
48+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
49+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
50+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
51+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
52+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
53+ },
54+ 'auth.user': {
55+ 'Meta': {'object_name': 'User'},
56+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
57+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
58+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
59+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
60+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
61+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
62+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
63+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
64+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
65+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
66+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
67+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
68+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
69+ },
70+ 'contenttypes.contenttype': {
71+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
72+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
73+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
74+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
75+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
76+ },
77+ u'maasserver.bootimage': {
78+ 'Meta': {'unique_together': "((u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'},
79+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
80+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
81+ 'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
82+ 'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
83+ 'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'})
84+ },
85+ u'maasserver.config': {
86+ 'Meta': {'object_name': 'Config'},
87+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
88+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
89+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
90+ },
91+ u'maasserver.dhcplease': {
92+ 'Meta': {'object_name': 'DHCPLease'},
93+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
94+ 'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
95+ 'mac': ('maasserver.fields.MACAddressField', [], {}),
96+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
97+ },
98+ u'maasserver.filestorage': {
99+ 'Meta': {'object_name': 'FileStorage'},
100+ 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
101+ 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}),
102+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
103+ },
104+ u'maasserver.macaddress': {
105+ 'Meta': {'object_name': 'MACAddress'},
106+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
107+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
108+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
109+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
110+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
111+ },
112+ u'maasserver.node': {
113+ 'Meta': {'object_name': 'Node'},
114+ 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
115+ 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}),
116+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
117+ 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
118+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
119+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
120+ 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
121+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
122+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
123+ 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
124+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
125+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
126+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-ee3df1b4-fbcb-11e1-8b8f-002608dc6120'", 'unique': 'True', 'max_length': '41'}),
127+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
128+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
129+ },
130+ u'maasserver.nodegroup': {
131+ 'Meta': {'object_name': 'NodeGroup'},
132+ 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
133+ 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
134+ 'broadcast_ip': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
135+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
136+ 'dhcp_interfaces': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
137+ 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
138+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
139+ 'ip_range_high': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
140+ 'ip_range_low': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
141+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
142+ 'router_ip': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
143+ 'subnet_mask': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
144+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
145+ 'worker_ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'})
146+ },
147+ u'maasserver.sshkey': {
148+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
149+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
150+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
151+ 'key': ('django.db.models.fields.TextField', [], {}),
152+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
153+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
154+ },
155+ u'maasserver.userprofile': {
156+ 'Meta': {'object_name': 'UserProfile'},
157+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
158+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
159+ },
160+ 'piston.consumer': {
161+ 'Meta': {'object_name': 'Consumer'},
162+ 'description': ('django.db.models.fields.TextField', [], {}),
163+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
164+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
165+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
166+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
167+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
168+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
169+ },
170+ 'piston.token': {
171+ 'Meta': {'object_name': 'Token'},
172+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
173+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
174+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
175+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
176+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
177+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
178+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
179+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1347338908L'}),
180+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
181+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
182+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
183+ }
184+ }
185+
186+ complete_apps = ['maasserver']
187
188=== modified file 'src/maasserver/models/__init__.py'
189--- src/maasserver/models/__init__.py 2012-08-16 13:38:51 +0000
190+++ src/maasserver/models/__init__.py 2012-09-13 03:54:19 +0000
191@@ -11,6 +11,7 @@
192
193 __metaclass__ = type
194 __all__ = [
195+ 'BootImage',
196 'Config',
197 'DHCPLease',
198 'FileStorage',
199@@ -29,6 +30,7 @@
200 from django.contrib.auth.models import User
201 from django.db.models.signals import post_save
202 from maasserver.enum import NODE_PERMISSION
203+from maasserver.models.bootimage import BootImage
204 from maasserver.models.config import Config
205 from maasserver.models.dhcplease import DHCPLease
206 from maasserver.models.filestorage import FileStorage
207@@ -61,8 +63,9 @@
208
209
210 # Register the models in the admin site.
211+admin.site.register(BootImage)
212+admin.site.register(Config)
213 admin.site.register(Consumer)
214-admin.site.register(Config)
215 admin.site.register(FileStorage)
216 admin.site.register(MACAddress)
217 admin.site.register(Node)
218
219=== added file 'src/maasserver/models/bootimage.py'
220--- src/maasserver/models/bootimage.py 1970-01-01 00:00:00 +0000
221+++ src/maasserver/models/bootimage.py 2012-09-13 03:54:19 +0000
222@@ -0,0 +1,89 @@
223+# Copyright 2012 Canonical Ltd. This software is licensed under the
224+# GNU Affero General Public License version 3 (see the file LICENSE).
225+
226+"""Registration of available boot images."""
227+
228+from __future__ import (
229+ absolute_import,
230+ print_function,
231+ unicode_literals,
232+ )
233+
234+__metaclass__ = type
235+__all__ = [
236+ 'BootImage',
237+ ]
238+
239+
240+from django.db.models import (
241+ CharField,
242+ Manager,
243+ Model,
244+ )
245+from maasserver import DefaultMeta
246+
247+
248+class BootImageManager(Manager):
249+ """Manager for model class.
250+
251+ Don't import or instantiate this directly; access as `BootImage.objects`.
252+ """
253+
254+ def get_by_natural_key(self, architecture, subarchitecture, release,
255+ purpose):
256+ """Look up a specific image."""
257+ return self.get(
258+ architecture=architecture, subarchitecture=subarchitecture,
259+ release=release, purpose=purpose)
260+
261+ def register_image(self, architecture, subarchitecture, release, purpose):
262+ """Register an image if it wasn't already registered."""
263+ self.get_or_create(
264+ architecture=architecture, subarchitecture=subarchitecture,
265+ release=release, purpose=purpose)
266+
267+ def have_image(self, architecture, subarchitecture, release, purpose):
268+ """Is an image for the given kind of boot available?"""
269+ try:
270+ self.get_by_natural_key(
271+ architecture=architecture, subarchitecture=subarchitecture,
272+ release=release, purpose=purpose)
273+ return True
274+ except BootImage.DoesNotExist:
275+ return False
276+
277+
278+class BootImage(Model):
279+ """Available boot image (i.e. kernel and initrd).
280+
281+ Each `BootImage` represents a type of boot for which a boot image is
282+ available. The `maas-import-pxe-files` script imports these, and the
283+ TFTP server provides them to booting nodes.
284+
285+ If a boot image is missing, that may mean that the import script has not
286+ been run yet, or has failed; or that it was not configured to provide
287+ that particular image.
288+
289+ Fields correspond directly to values used in the `tftppath` module.
290+ """
291+
292+ class Meta(DefaultMeta):
293+ unique_together = (
294+ ('architecture', 'subarchitecture', 'release', 'purpose'),
295+ )
296+
297+ objects = BootImageManager()
298+
299+ # System architecture (e.g. "i386") that the image is for.
300+ architecture = CharField(max_length=255, blank=False, editable=False)
301+
302+ # Sub-architecture, e.g. a particular type of ARM machine that needs
303+ # different treatment. (For architectures that don't need these
304+ # such as i386 and amd64, we use "generic").
305+ subarchitecture = CharField(max_length=255, blank=False, editable=False)
306+
307+ # Ubuntu release (e.g. "precise") that the image boots.
308+ release = CharField(max_length=255, blank=False, editable=False)
309+
310+ # Boot purpose (e.g. "commissioning" or "install") that the image is for.
311+ purpose = CharField(max_length=255, blank=False, editable=False)
312
313=== modified file 'src/maasserver/testing/factory.py'
314--- src/maasserver/testing/factory.py 2012-09-10 14:50:56 +0000
315+++ src/maasserver/testing/factory.py 2012-09-13 03:54:19 +0000
316@@ -24,6 +24,7 @@
317 NODE_STATUS,
318 )
319 from maasserver.models import (
320+ BootImage,
321 DHCPLease,
322 FileStorage,
323 MACAddress,
324@@ -271,6 +272,22 @@
325 return "OAuth " + ", ".join([
326 '%s="%s"' % (key, value) for key, value in items.items()])
327
328+ def make_boot_image(self, architecture=None, subarchitecture=None,
329+ release=None, purpose=None):
330+ if architecture is None:
331+ architecture = self.make_name('architecture')
332+ if subarchitecture is None:
333+ subarchitecture = self.make_name('subarchitecture')
334+ if release is None:
335+ release = self.make_name('release')
336+ if purpose is None:
337+ purpose = self.make_name('purpose')
338+ return BootImage.objects.create(
339+ architecture=architecture,
340+ subarchitecture=subarchitecture,
341+ release=release,
342+ purpose=purpose)
343+
344
345 # Create factory singleton.
346 factory = Factory()
347
348=== added file 'src/maasserver/tests/test_bootimage.py'
349--- src/maasserver/tests/test_bootimage.py 1970-01-01 00:00:00 +0000
350+++ src/maasserver/tests/test_bootimage.py 2012-09-13 03:54:19 +0000
351@@ -0,0 +1,47 @@
352+# Copyright 2012 Canonical Ltd. This software is licensed under the
353+# GNU Affero General Public License version 3 (see the file LICENSE).
354+
355+"""Tests for :class:`BootImage`."""
356+
357+from __future__ import (
358+ absolute_import,
359+ print_function,
360+ unicode_literals,
361+ )
362+
363+__metaclass__ = type
364+__all__ = []
365+
366+from maasserver.models import BootImage
367+from maasserver.testing.factory import factory
368+from maasserver.testing.testcase import TestCase
369+
370+
371+class TestBootImageManager(TestCase):
372+
373+ def make_image_params(self):
374+ return dict(
375+ architecture=factory.make_name('architecture'),
376+ subarchitecture=factory.make_name('subarchitecture'),
377+ release=factory.make_name('release'),
378+ purpose=factory.make_name('purpose'))
379+
380+ def test_have_image_returns_False_if_image_not_available(self):
381+ self.assertFalse(
382+ BootImage.objects.have_image(**self.make_image_params()))
383+
384+ def test_have_image_returns_True_if_image_available(self):
385+ params = self.make_image_params()
386+ factory.make_boot_image(**params)
387+ self.assertTrue(BootImage.objects.have_image(**params))
388+
389+ def test_register_image_registers_new_image(self):
390+ params = self.make_image_params()
391+ BootImage.objects.register_image(**params)
392+ self.assertTrue(BootImage.objects.have_image(**params))
393+
394+ def test_register_image_leaves_existing_image_intact(self):
395+ params = self.make_image_params()
396+ factory.make_boot_image(**params)
397+ BootImage.objects.register_image(**params)
398+ self.assertTrue(BootImage.objects.have_image(**params))