Merge lp:~julian-edwards/maas/1.2-backport-bug-1068843 into lp:maas/1.2
- 1.2-backport-bug-1068843
- Merge into 1.2
Proposed by
Julian Edwards
Status: | Merged |
---|---|
Approved by: | Julian Edwards |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1278 |
Proposed branch: | lp:~julian-edwards/maas/1.2-backport-bug-1068843 |
Merge into: | lp:maas/1.2 |
Diff against target: |
1273 lines (+600/-95) 23 files modified
contrib/python-tx-tftp/tftp/protocol.py (+14/-2) contrib/python-tx-tftp/tftp/test/test_protocol.py (+73/-2) src/maasserver/api.py (+24/-8) src/maasserver/components.py (+3/-1) src/maasserver/forms.py (+6/-0) src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py (+217/-0) src/maasserver/models/bootimage.py (+22/-11) src/maasserver/models/config.py (+1/-0) src/maasserver/preseed.py (+1/-1) src/maasserver/testing/factory.py (+4/-1) src/maasserver/tests/test_api.py (+102/-36) src/maasserver/tests/test_bootimage.py (+20/-9) src/maasserver/tests/test_config.py (+14/-6) src/maasserver/tests/test_preseed.py (+2/-2) src/maasserver/tests/test_start_up.py (+1/-1) src/maasserver/tests/test_views_settings.py (+9/-4) src/provisioningserver/boot_images.py (+2/-1) src/provisioningserver/tasks.py (+19/-0) src/provisioningserver/testing/boot_images.py (+2/-4) src/provisioningserver/tests/test_boot_images.py (+7/-3) src/provisioningserver/tests/test_tasks.py (+31/-1) src/provisioningserver/tests/test_tftp.py (+20/-2) src/provisioningserver/tftp.py (+6/-0) |
To merge this branch: | bzr merge lp:~julian-edwards/maas/1.2-backport-bug-1068843 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella (community) | Approve | ||
Review via email: mp+132043@code.launchpad.net |
Commit message
Backport changes from trunk that fix bug 1068843 (cluster controller has no provisioning images).
Description of the change
To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'contrib/python-tx-tftp/tftp/protocol.py' |
2 | --- contrib/python-tx-tftp/tftp/protocol.py 2012-07-05 14:36:27 +0000 |
3 | +++ contrib/python-tx-tftp/tftp/protocol.py 2012-10-30 11:04:21 +0000 |
4 | @@ -12,6 +12,7 @@ |
5 | from twisted.internet.defer import inlineCallbacks, returnValue |
6 | from twisted.internet.protocol import DatagramProtocol |
7 | from twisted.python import log |
8 | +from twisted.python.context import call |
9 | |
10 | |
11 | class TFTP(DatagramProtocol): |
12 | @@ -48,11 +49,22 @@ |
13 | |
14 | @inlineCallbacks |
15 | def _startSession(self, datagram, addr, mode): |
16 | + # Set up a call context so that we can pass extra arbitrary |
17 | + # information to interested backends without adding extra call |
18 | + # arguments, or switching to using a request object, for example. |
19 | + context = {} |
20 | + if self.transport is not None: |
21 | + # Add the local and remote addresses to the call context. |
22 | + local = self.transport.getHost() |
23 | + context["local"] = local.host, local.port |
24 | + context["remote"] = addr |
25 | try: |
26 | if datagram.opcode == OP_WRQ: |
27 | - fs_interface = yield self.backend.get_writer(datagram.filename) |
28 | + fs_interface = yield call( |
29 | + context, self.backend.get_writer, datagram.filename) |
30 | elif datagram.opcode == OP_RRQ: |
31 | - fs_interface = yield self.backend.get_reader(datagram.filename) |
32 | + fs_interface = yield call( |
33 | + context, self.backend.get_reader, datagram.filename) |
34 | except Unsupported, e: |
35 | self.transport.write(ERRORDatagram.from_code(ERR_ILLEGAL_OP, |
36 | str(e)).to_wire(), addr) |
37 | |
38 | === modified file 'contrib/python-tx-tftp/tftp/test/test_protocol.py' |
39 | --- contrib/python-tx-tftp/tftp/test/test_protocol.py 2012-07-25 19:59:37 +0000 |
40 | +++ contrib/python-tx-tftp/tftp/test/test_protocol.py 2012-10-30 11:04:21 +0000 |
41 | @@ -11,9 +11,11 @@ |
42 | from tftp.netascii import NetasciiReceiverProxy, NetasciiSenderProxy |
43 | from tftp.protocol import TFTP |
44 | from twisted.internet import reactor |
45 | -from twisted.internet.defer import Deferred |
46 | +from twisted.internet.address import IPv4Address |
47 | +from twisted.internet.defer import Deferred, inlineCallbacks |
48 | from twisted.internet.protocol import DatagramProtocol |
49 | from twisted.internet.task import Clock |
50 | +from twisted.python import context |
51 | from twisted.python.filepath import FilePath |
52 | from twisted.test.proto_helpers import StringTransport |
53 | from twisted.trial import unittest |
54 | @@ -50,7 +52,8 @@ |
55 | |
56 | def setUp(self): |
57 | self.clock = Clock() |
58 | - self.transport = FakeTransport(hostAddress=('127.0.0.1', self.port)) |
59 | + self.transport = FakeTransport( |
60 | + hostAddress=IPv4Address('UDP', '127.0.0.1', self.port)) |
61 | |
62 | def test_malformed_datagram(self): |
63 | tftp = TFTP(BackendFactory(), _clock=self.clock) |
64 | @@ -247,3 +250,71 @@ |
65 | self.clock.advance(1) |
66 | self.assertTrue(d.called) |
67 | self.assertTrue(IWriter.providedBy(d.result.backend)) |
68 | + |
69 | + |
70 | +class CapturedContext(Exception): |
71 | + """A donkey, to carry the call context back up the stack.""" |
72 | + |
73 | + def __init__(self, args, names): |
74 | + super(CapturedContext, self).__init__(*args) |
75 | + self.context = {name: context.get(name) for name in names} |
76 | + |
77 | + |
78 | +class ContextCapturingBackend(object): |
79 | + """A fake `IBackend` that raises `CapturedContext`. |
80 | + |
81 | + Calling `get_reader` or `get_writer` raises a `CapturedContext` exception, |
82 | + which captures the values of the call context for the given `names`. |
83 | + """ |
84 | + |
85 | + def __init__(self, *names): |
86 | + self.names = names |
87 | + |
88 | + def get_reader(self, file_name): |
89 | + raise CapturedContext(("get_reader", file_name), self.names) |
90 | + |
91 | + def get_writer(self, file_name): |
92 | + raise CapturedContext(("get_writer", file_name), self.names) |
93 | + |
94 | + |
95 | +class HostTransport(object): |
96 | + """A fake `ITransport` that only responds to `getHost`.""" |
97 | + |
98 | + def __init__(self, host): |
99 | + self.host = host |
100 | + |
101 | + def getHost(self): |
102 | + return IPv4Address("UDP", *self.host) |
103 | + |
104 | + |
105 | +class BackendCallingContext(unittest.TestCase): |
106 | + |
107 | + def setUp(self): |
108 | + super(BackendCallingContext, self).setUp() |
109 | + self.backend = ContextCapturingBackend("local", "remote") |
110 | + self.tftp = TFTP(self.backend) |
111 | + self.tftp.transport = HostTransport(("12.34.56.78", 1234)) |
112 | + |
113 | + @inlineCallbacks |
114 | + def test_context_rrq(self): |
115 | + rrq_datagram = RRQDatagram('nonempty', 'NetASCiI', {}) |
116 | + rrq_addr = ('127.0.0.1', 1069) |
117 | + error = yield self.assertFailure( |
118 | + self.tftp._startSession(rrq_datagram, rrq_addr, "octet"), |
119 | + CapturedContext) |
120 | + self.assertEqual(("get_reader", rrq_datagram.filename), error.args) |
121 | + self.assertEqual( |
122 | + {"local": self.tftp.transport.host, "remote": rrq_addr}, |
123 | + error.context) |
124 | + |
125 | + @inlineCallbacks |
126 | + def test_context_wrq(self): |
127 | + wrq_datagram = WRQDatagram('nonempty', 'NetASCiI', {}) |
128 | + wrq_addr = ('127.0.0.1', 1069) |
129 | + error = yield self.assertFailure( |
130 | + self.tftp._startSession(wrq_datagram, wrq_addr, "octet"), |
131 | + CapturedContext) |
132 | + self.assertEqual(("get_writer", wrq_datagram.filename), error.args) |
133 | + self.assertEqual( |
134 | + {"local": self.tftp.transport.host, "remote": wrq_addr}, |
135 | + error.context) |
136 | |
137 | === modified file 'src/maasserver/api.py' |
138 | --- src/maasserver/api.py 2012-10-29 11:04:39 +0000 |
139 | +++ src/maasserver/api.py 2012-10-30 11:04:21 +0000 |
140 | @@ -162,7 +162,10 @@ |
141 | compose_preseed_url, |
142 | ) |
143 | from maasserver.server_address import get_maas_facing_server_address |
144 | -from maasserver.utils import map_enum |
145 | +from maasserver.utils import ( |
146 | + absolute_reverse, |
147 | + map_enum, |
148 | + ) |
149 | from maasserver.utils.orm import get_one |
150 | from piston.handler import ( |
151 | AnonymousBaseHandler, |
152 | @@ -1695,6 +1698,8 @@ |
153 | :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not |
154 | 'armhf'). |
155 | :param subarch: Subarchitecture name (in the pxelinux namespace). |
156 | + :param local: The IP address of the cluster controller. |
157 | + :param remote: The IP address of the booting node. |
158 | """ |
159 | node = get_node_from_mac_string(request.GET.get('mac', None)) |
160 | |
161 | @@ -1749,11 +1754,12 @@ |
162 | |
163 | purpose = get_boot_purpose(node) |
164 | server_address = get_maas_facing_server_address() |
165 | + cluster_address = get_mandatory_param(request.GET, "local") |
166 | |
167 | params = KernelParameters( |
168 | arch=arch, subarch=subarch, release=series, purpose=purpose, |
169 | hostname=hostname, domain=domain, preseed_url=preseed_url, |
170 | - log_host=server_address, fs_host=server_address) |
171 | + log_host=server_address, fs_host=cluster_address) |
172 | |
173 | return HttpResponse( |
174 | json.dumps(params._asdict()), |
175 | @@ -1777,24 +1783,34 @@ |
176 | `purpose`, all as in the code that determines TFTP paths for |
177 | these images. |
178 | """ |
179 | - check_nodegroup_access(request, NodeGroup.objects.ensure_master()) |
180 | + nodegroup_uuid = get_mandatory_param(request.data, "nodegroup") |
181 | + nodegroup = get_object_or_404(NodeGroup, uuid=nodegroup_uuid) |
182 | + check_nodegroup_access(request, nodegroup) |
183 | images = json.loads(get_mandatory_param(request.data, 'images')) |
184 | |
185 | for image in images: |
186 | BootImage.objects.register_image( |
187 | + nodegroup=nodegroup, |
188 | architecture=image['architecture'], |
189 | subarchitecture=image.get('subarchitecture', 'generic'), |
190 | release=image['release'], |
191 | purpose=image['purpose']) |
192 | |
193 | - if len(images) == 0: |
194 | + # Work out if any nodegroups are missing images. |
195 | + nodegroup_ids_with_images = BootImage.objects.values_list( |
196 | + "nodegroup_id", flat=True) |
197 | + nodegroups_missing_images = NodeGroup.objects.exclude( |
198 | + id__in=nodegroup_ids_with_images).filter( |
199 | + status=NODEGROUP_STATUS.ACCEPTED) |
200 | + if nodegroups_missing_images.exists(): |
201 | warning = dedent("""\ |
202 | - No boot images have been imported yet. Either the |
203 | + Some cluster controllers are missing boot images. Either the |
204 | maas-import-pxe-files script has not run yet, or it failed. |
205 | |
206 | - Try running it manually. If it succeeds, this message will |
207 | - go away within 5 minutes. |
208 | - """) |
209 | + Try running it manually on the affected |
210 | + <a href="%s#accepted-clusters">cluster controllers.</a> |
211 | + If it succeeds, this message will go away within 5 minutes. |
212 | + """ % absolute_reverse("settings")) |
213 | register_persistent_error(COMPONENT.IMPORT_PXE_FILES, warning) |
214 | else: |
215 | discard_persistent_error(COMPONENT.IMPORT_PXE_FILES) |
216 | |
217 | === modified file 'src/maasserver/components.py' |
218 | --- src/maasserver/components.py 2012-09-28 18:42:16 +0000 |
219 | +++ src/maasserver/components.py 2012-10-30 11:04:21 +0000 |
220 | @@ -17,6 +17,7 @@ |
221 | "register_persistent_error", |
222 | ] |
223 | |
224 | +from django.utils.safestring import mark_safe |
225 | from maasserver.models import ComponentError |
226 | from maasserver.utils.orm import get_one |
227 | |
228 | @@ -50,4 +51,5 @@ |
229 | |
230 | def get_persistent_errors(): |
231 | """Return list of current persistent error messages.""" |
232 | - return sorted(err.error for err in ComponentError.objects.all()) |
233 | + return sorted( |
234 | + mark_safe(err.error) for err in ComponentError.objects.all()) |
235 | |
236 | === modified file 'src/maasserver/forms.py' |
237 | --- src/maasserver/forms.py 2012-10-29 11:04:39 +0000 |
238 | +++ src/maasserver/forms.py 2012-10-30 11:04:21 +0000 |
239 | @@ -583,6 +583,12 @@ |
240 | label="Default domain for new nodes", required=False, help_text=( |
241 | "If 'local' is chosen, nodes must be using mDNS. Leave empty to " |
242 | "use hostnames without a domain for newly enlisted nodes.")) |
243 | + http_proxy = forms.URLField( |
244 | + label="Proxy for HTTP and HTTPS traffic", required=False, |
245 | + help_text=( |
246 | + "This is used by the cluster and region controllers for " |
247 | + "downloading PXE boot images and other provisioning-related " |
248 | + "resources. It is not passed into provisioned nodes.")) |
249 | |
250 | |
251 | class CommissioningForm(ConfigForm): |
252 | |
253 | === added file 'src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py' |
254 | --- src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py 1970-01-01 00:00:00 +0000 |
255 | +++ src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py 2012-10-30 11:04:21 +0000 |
256 | @@ -0,0 +1,217 @@ |
257 | +# -*- coding: utf-8 -*- |
258 | +import datetime |
259 | +from south.db import db |
260 | +from south.v2 import SchemaMigration |
261 | +from django.db import models |
262 | + |
263 | + |
264 | +class Migration(SchemaMigration): |
265 | + |
266 | + def forwards(self, orm): |
267 | + # Removing unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'architecture', 'purpose'] |
268 | + db.delete_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'architecture', 'purpose']) |
269 | + |
270 | + # Adding field 'BootImage.nodegroup' |
271 | + db.add_column(u'maasserver_bootimage', 'nodegroup', |
272 | + self.gf('django.db.models.fields.related.ForeignKey')(null=True, to=orm['maasserver.NodeGroup']), |
273 | + keep_default=False) |
274 | + # Set existing bootimage rows to have a nodegroup of the master |
275 | + # nodegroup (which is the first row in that table). |
276 | + db.execute("UPDATE maasserver_bootimage SET nodegroup_id = (SELECT id from maasserver_nodegroup ORDER BY id LIMIT 1)") |
277 | + db.execute("ALTER TABLE maasserver_bootimage ALTER nodegroup_id SET NOT NULL") |
278 | + # Adding unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'nodegroup', 'architecture', 'purpose'] |
279 | + db.create_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'nodegroup_id', 'architecture', 'purpose']) |
280 | + |
281 | + |
282 | + def backwards(self, orm): |
283 | + # Removing unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'nodegroup', 'architecture', 'purpose'] |
284 | + db.delete_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'nodegroup_id', 'architecture', 'purpose']) |
285 | + |
286 | + # Deleting field 'BootImage.nodegroup' |
287 | + db.delete_column(u'maasserver_bootimage', 'nodegroup_id') |
288 | + |
289 | + # Adding unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'architecture', 'purpose'] |
290 | + db.create_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'architecture', 'purpose']) |
291 | + |
292 | + |
293 | + models = { |
294 | + 'auth.group': { |
295 | + 'Meta': {'object_name': 'Group'}, |
296 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
297 | + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), |
298 | + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) |
299 | + }, |
300 | + 'auth.permission': { |
301 | + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, |
302 | + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), |
303 | + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), |
304 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
305 | + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) |
306 | + }, |
307 | + 'auth.user': { |
308 | + 'Meta': {'object_name': 'User'}, |
309 | + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), |
310 | + 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}), |
311 | + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), |
312 | + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), |
313 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
314 | + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), |
315 | + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), |
316 | + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), |
317 | + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), |
318 | + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), |
319 | + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), |
320 | + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), |
321 | + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) |
322 | + }, |
323 | + 'contenttypes.contenttype': { |
324 | + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, |
325 | + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), |
326 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
327 | + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), |
328 | + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) |
329 | + }, |
330 | + u'maasserver.bootimage': { |
331 | + 'Meta': {'unique_together': "((u'nodegroup', u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'}, |
332 | + 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}), |
333 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
334 | + 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}), |
335 | + 'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}), |
336 | + 'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}), |
337 | + 'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}) |
338 | + }, |
339 | + u'maasserver.componenterror': { |
340 | + 'Meta': {'object_name': 'ComponentError'}, |
341 | + 'component': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}), |
342 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
343 | + 'error': ('django.db.models.fields.CharField', [], {'max_length': '1000'}), |
344 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
345 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}) |
346 | + }, |
347 | + u'maasserver.config': { |
348 | + 'Meta': {'object_name': 'Config'}, |
349 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
350 | + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), |
351 | + 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'}) |
352 | + }, |
353 | + u'maasserver.dhcplease': { |
354 | + 'Meta': {'object_name': 'DHCPLease'}, |
355 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
356 | + 'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}), |
357 | + 'mac': ('maasserver.fields.MACAddressField', [], {}), |
358 | + 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}) |
359 | + }, |
360 | + u'maasserver.filestorage': { |
361 | + 'Meta': {'object_name': 'FileStorage'}, |
362 | + 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}), |
363 | + 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}), |
364 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) |
365 | + }, |
366 | + u'maasserver.macaddress': { |
367 | + 'Meta': {'object_name': 'MACAddress'}, |
368 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
369 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
370 | + 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}), |
371 | + 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}), |
372 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}) |
373 | + }, |
374 | + u'maasserver.node': { |
375 | + 'Meta': {'object_name': 'Node'}, |
376 | + 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}), |
377 | + 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386/generic'", 'max_length': '31'}), |
378 | + 'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), |
379 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
380 | + 'distro_series': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '10', 'null': 'True', 'blank': 'True'}), |
381 | + 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), |
382 | + 'hardware_details': ('maasserver.fields.XMLField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), |
383 | + 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), |
384 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
385 | + 'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}), |
386 | + 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), |
387 | + 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}), |
388 | + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), |
389 | + 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}), |
390 | + 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}), |
391 | + 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}), |
392 | + 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-f9e8804e-1d11-11e2-be72-0026c71eea0e'", 'unique': 'True', 'max_length': '41'}), |
393 | + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}), |
394 | + 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}), |
395 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}) |
396 | + }, |
397 | + u'maasserver.nodegroup': { |
398 | + 'Meta': {'object_name': 'NodeGroup'}, |
399 | + 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}), |
400 | + 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}), |
401 | + 'cluster_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100', 'blank': 'True'}), |
402 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
403 | + 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), |
404 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
405 | + 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'blank': 'True'}), |
406 | + 'status': ('django.db.models.fields.IntegerField', [], {'default': '0'}), |
407 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}), |
408 | + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'}) |
409 | + }, |
410 | + u'maasserver.nodegroupinterface': { |
411 | + 'Meta': {'unique_together': "((u'nodegroup', u'interface'),)", 'object_name': 'NodeGroupInterface'}, |
412 | + 'broadcast_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}), |
413 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
414 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
415 | + 'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), |
416 | + 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}), |
417 | + 'ip_range_high': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}), |
418 | + 'ip_range_low': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}), |
419 | + 'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}), |
420 | + 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}), |
421 | + 'router_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}), |
422 | + 'subnet_mask': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}), |
423 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}) |
424 | + }, |
425 | + u'maasserver.sshkey': { |
426 | + 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'}, |
427 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
428 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
429 | + 'key': ('django.db.models.fields.TextField', [], {}), |
430 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}), |
431 | + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) |
432 | + }, |
433 | + u'maasserver.tag': { |
434 | + 'Meta': {'object_name': 'Tag'}, |
435 | + 'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}), |
436 | + 'created': ('django.db.models.fields.DateTimeField', [], {}), |
437 | + 'definition': ('django.db.models.fields.TextField', [], {}), |
438 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
439 | + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}), |
440 | + 'updated': ('django.db.models.fields.DateTimeField', [], {}) |
441 | + }, |
442 | + u'maasserver.userprofile': { |
443 | + 'Meta': {'object_name': 'UserProfile'}, |
444 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
445 | + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) |
446 | + }, |
447 | + 'piston.consumer': { |
448 | + 'Meta': {'object_name': 'Consumer'}, |
449 | + 'description': ('django.db.models.fields.TextField', [], {}), |
450 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
451 | + 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}), |
452 | + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), |
453 | + 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}), |
454 | + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}), |
455 | + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"}) |
456 | + }, |
457 | + 'piston.token': { |
458 | + 'Meta': {'object_name': 'Token'}, |
459 | + 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), |
460 | + 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), |
461 | + 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}), |
462 | + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), |
463 | + 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), |
464 | + 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}), |
465 | + 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}), |
466 | + 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1350997269L'}), |
467 | + 'token_type': ('django.db.models.fields.IntegerField', [], {}), |
468 | + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}), |
469 | + 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'}) |
470 | + } |
471 | + } |
472 | + |
473 | + complete_apps = ['maasserver'] |
474 | |
475 | === modified file 'src/maasserver/models/bootimage.py' |
476 | --- src/maasserver/models/bootimage.py 2012-09-13 06:53:55 +0000 |
477 | +++ src/maasserver/models/bootimage.py 2012-10-30 11:04:21 +0000 |
478 | @@ -17,10 +17,12 @@ |
479 | |
480 | from django.db.models import ( |
481 | CharField, |
482 | + ForeignKey, |
483 | Manager, |
484 | Model, |
485 | ) |
486 | from maasserver import DefaultMeta |
487 | +from maasserver.models.nodegroup import NodeGroup |
488 | |
489 | |
490 | class BootImageManager(Manager): |
491 | @@ -29,25 +31,30 @@ |
492 | Don't import or instantiate this directly; access as `BootImage.objects`. |
493 | """ |
494 | |
495 | - def get_by_natural_key(self, architecture, subarchitecture, release, |
496 | - purpose): |
497 | + def get_by_natural_key(self, nodegroup, architecture, subarchitecture, |
498 | + release, purpose): |
499 | """Look up a specific image.""" |
500 | return self.get( |
501 | - architecture=architecture, subarchitecture=subarchitecture, |
502 | - release=release, purpose=purpose) |
503 | + nodegroup=nodegroup, architecture=architecture, |
504 | + subarchitecture=subarchitecture, release=release, |
505 | + purpose=purpose) |
506 | |
507 | - def register_image(self, architecture, subarchitecture, release, purpose): |
508 | + def register_image(self, nodegroup, architecture, subarchitecture, |
509 | + release, purpose): |
510 | """Register an image if it wasn't already registered.""" |
511 | self.get_or_create( |
512 | - architecture=architecture, subarchitecture=subarchitecture, |
513 | - release=release, purpose=purpose) |
514 | + nodegroup=nodegroup, architecture=architecture, |
515 | + subarchitecture=subarchitecture, release=release, |
516 | + purpose=purpose) |
517 | |
518 | - def have_image(self, architecture, subarchitecture, release, purpose): |
519 | + def have_image(self, nodegroup, architecture, subarchitecture, release, |
520 | + purpose): |
521 | """Is an image for the given kind of boot available?""" |
522 | try: |
523 | self.get_by_natural_key( |
524 | - architecture=architecture, subarchitecture=subarchitecture, |
525 | - release=release, purpose=purpose) |
526 | + nodegroup=nodegroup, architecture=architecture, |
527 | + subarchitecture=subarchitecture, release=release, |
528 | + purpose=purpose) |
529 | return True |
530 | except BootImage.DoesNotExist: |
531 | return False |
532 | @@ -69,11 +76,15 @@ |
533 | |
534 | class Meta(DefaultMeta): |
535 | unique_together = ( |
536 | - ('architecture', 'subarchitecture', 'release', 'purpose'), |
537 | + ('nodegroup', 'architecture', 'subarchitecture', 'release', |
538 | + 'purpose'), |
539 | ) |
540 | |
541 | objects = BootImageManager() |
542 | |
543 | + # Nodegroup (cluster controller) that has the images. |
544 | + nodegroup = ForeignKey(NodeGroup, null=False, editable=False, unique=False) |
545 | + |
546 | # System architecture (e.g. "i386") that the image is for. |
547 | architecture = CharField(max_length=255, blank=False, editable=False) |
548 | |
549 | |
550 | === modified file 'src/maasserver/models/config.py' |
551 | --- src/maasserver/models/config.py 2012-09-27 13:50:34 +0000 |
552 | +++ src/maasserver/models/config.py 2012-10-30 11:04:21 +0000 |
553 | @@ -53,6 +53,7 @@ |
554 | 'enlistment_domain': b'local', |
555 | 'default_distro_series': DISTRO_SERIES.precise, |
556 | 'commissioning_distro_series': DISTRO_SERIES.precise, |
557 | + 'http_proxy': None, |
558 | ## /settings |
559 | } |
560 | |
561 | |
562 | === modified file 'src/maasserver/preseed.py' |
563 | --- src/maasserver/preseed.py 2012-10-02 21:07:00 +0000 |
564 | +++ src/maasserver/preseed.py 2012-10-30 11:04:21 +0000 |
565 | @@ -242,7 +242,7 @@ |
566 | """Whether or not the SquashFS image can be used during installation.""" |
567 | arch, subarch = node.architecture.split("/") |
568 | return BootImage.objects.have_image( |
569 | - arch, subarch, node.get_distro_series(), "filesystem") |
570 | + node.nodegroup, arch, subarch, node.get_distro_series(), "filesystem") |
571 | |
572 | |
573 | def render_preseed(node, prefix, release=''): |
574 | |
575 | === modified file 'src/maasserver/testing/factory.py' |
576 | --- src/maasserver/testing/factory.py 2012-10-25 13:40:55 +0000 |
577 | +++ src/maasserver/testing/factory.py 2012-10-30 11:04:21 +0000 |
578 | @@ -325,7 +325,7 @@ |
579 | '%s="%s"' % (key, value) for key, value in items.items()]) |
580 | |
581 | def make_boot_image(self, architecture=None, subarchitecture=None, |
582 | - release=None, purpose=None): |
583 | + release=None, purpose=None, nodegroup=None): |
584 | if architecture is None: |
585 | architecture = self.make_name('architecture') |
586 | if subarchitecture is None: |
587 | @@ -334,7 +334,10 @@ |
588 | release = self.make_name('release') |
589 | if purpose is None: |
590 | purpose = self.make_name('purpose') |
591 | + if nodegroup is None: |
592 | + nodegroup = self.make_node_group() |
593 | return BootImage.objects.create( |
594 | + nodegroup=nodegroup, |
595 | architecture=architecture, |
596 | subarchitecture=subarchitecture, |
597 | release=release, |
598 | |
599 | === modified file 'src/maasserver/tests/test_api.py' |
600 | --- src/maasserver/tests/test_api.py 2012-10-29 11:04:39 +0000 |
601 | +++ src/maasserver/tests/test_api.py 2012-10-30 11:04:21 +0000 |
602 | @@ -110,8 +110,12 @@ |
603 | NodeUserData, |
604 | ) |
605 | from metadataserver.nodeinituser import get_node_init_user |
606 | -from mock import Mock |
607 | +from mock import ( |
608 | + ANY, |
609 | + Mock, |
610 | + ) |
611 | from provisioningserver import ( |
612 | + boot_images, |
613 | kernel_opts, |
614 | tasks, |
615 | ) |
616 | @@ -3043,11 +3047,16 @@ |
617 | |
618 | class TestPXEConfigAPI(AnonAPITestCase): |
619 | |
620 | + def get_default_params(self): |
621 | + return { |
622 | + "local": factory.getRandomIPAddress(), |
623 | + "remote": factory.getRandomIPAddress(), |
624 | + } |
625 | + |
626 | def get_mac_params(self): |
627 | - return {'mac': factory.make_mac_address().mac_address} |
628 | - |
629 | - def get_default_params(self): |
630 | - return dict() |
631 | + params = self.get_default_params() |
632 | + params['mac'] = factory.make_mac_address().mac_address |
633 | + return params |
634 | |
635 | def get_pxeconfig(self, params=None): |
636 | """Make a request to `pxeconfig`, and return its response dict.""" |
637 | @@ -3081,7 +3090,8 @@ |
638 | ContainsAll(KernelParameters._fields)) |
639 | |
640 | def test_pxeconfig_returns_data_for_known_node(self): |
641 | - response = self.client.get(reverse('pxeconfig'), self.get_mac_params()) |
642 | + params = self.get_mac_params() |
643 | + response = self.client.get(reverse('pxeconfig'), params) |
644 | self.assertEqual(httplib.OK, response.status_code) |
645 | |
646 | def test_pxeconfig_returns_no_content_for_unknown_node(self): |
647 | @@ -3093,6 +3103,7 @@ |
648 | architecture = factory.getRandomEnum(ARCHITECTURE) |
649 | arch, subarch = architecture.split('/') |
650 | params = dict( |
651 | + self.get_default_params(), |
652 | mac=factory.getRandomMACAddress(delimiter=b'-'), |
653 | arch=arch, |
654 | subarch=subarch) |
655 | @@ -3121,15 +3132,19 @@ |
656 | full_hostname = '.'.join([host, domain]) |
657 | node = factory.make_node(hostname=full_hostname) |
658 | mac = factory.make_mac_address(node=node) |
659 | - pxe_config = self.get_pxeconfig(params={'mac': mac.mac_address}) |
660 | + params = self.get_default_params() |
661 | + params['mac'] = mac.mac_address |
662 | + pxe_config = self.get_pxeconfig(params) |
663 | self.assertEqual(host, pxe_config.get('hostname')) |
664 | self.assertNotIn(domain, pxe_config.values()) |
665 | |
666 | def test_pxeconfig_uses_nodegroup_domain_for_node(self): |
667 | mac = factory.make_mac_address() |
668 | + params = self.get_default_params() |
669 | + params['mac'] = mac |
670 | self.assertEqual( |
671 | mac.node.nodegroup.name, |
672 | - self.get_pxeconfig({'mac': mac.mac_address}).get('domain')) |
673 | + self.get_pxeconfig(params).get('domain')) |
674 | |
675 | def get_without_param(self, param): |
676 | """Request a `pxeconfig()` response, but omit `param` from request.""" |
677 | @@ -3194,6 +3209,13 @@ |
678 | fake_boot_purpose, |
679 | json.loads(response.content)["purpose"]) |
680 | |
681 | + def test_pxeconfig_returns_fs_host_as_cluster_controller(self): |
682 | + # The kernel parameter `fs_host` points to the cluster controller |
683 | + # address, which is passed over within the `local` parameter. |
684 | + params = self.get_default_params() |
685 | + kernel_params = KernelParameters(**self.get_pxeconfig(params)) |
686 | + self.assertEqual(params["local"], kernel_params.fs_host) |
687 | + |
688 | |
689 | class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase): |
690 | scenarios = [ |
691 | @@ -3917,63 +3939,102 @@ |
692 | ('celery', FixtureResource(CeleryFixture())), |
693 | ) |
694 | |
695 | - def report_images(self, images, client=None): |
696 | + def report_images(self, nodegroup, images, client=None): |
697 | if client is None: |
698 | client = self.client |
699 | return client.post( |
700 | - reverse('boot_images_handler'), |
701 | - {'op': 'report_boot_images', 'images': json.dumps(images)}) |
702 | + reverse('boot_images_handler'), { |
703 | + 'images': json.dumps(images), |
704 | + 'nodegroup': nodegroup.uuid, |
705 | + 'op': 'report_boot_images', |
706 | + }) |
707 | |
708 | def test_report_boot_images_does_not_work_for_normal_user(self): |
709 | - NodeGroup.objects.ensure_master() |
710 | + nodegroup = NodeGroup.objects.ensure_master() |
711 | log_in_as_normal_user(self.client) |
712 | - response = self.report_images([]) |
713 | - self.assertEqual(httplib.FORBIDDEN, response.status_code) |
714 | + response = self.report_images(nodegroup, []) |
715 | + self.assertEqual( |
716 | + httplib.FORBIDDEN, response.status_code, response.content) |
717 | |
718 | def test_report_boot_images_works_for_master_worker(self): |
719 | - client = make_worker_client(NodeGroup.objects.ensure_master()) |
720 | - response = self.report_images([], client=client) |
721 | + nodegroup = NodeGroup.objects.ensure_master() |
722 | + client = make_worker_client(nodegroup) |
723 | + response = self.report_images(nodegroup, [], client=client) |
724 | self.assertEqual(httplib.OK, response.status_code) |
725 | |
726 | def test_report_boot_images_stores_images(self): |
727 | + nodegroup = NodeGroup.objects.ensure_master() |
728 | image = make_boot_image_params() |
729 | - client = make_worker_client(NodeGroup.objects.ensure_master()) |
730 | - response = self.report_images([image], client=client) |
731 | + client = make_worker_client(nodegroup) |
732 | + response = self.report_images(nodegroup, [image], client=client) |
733 | self.assertEqual( |
734 | (httplib.OK, "OK"), |
735 | (response.status_code, response.content)) |
736 | self.assertTrue( |
737 | - BootImage.objects.have_image(**image)) |
738 | + BootImage.objects.have_image(nodegroup=nodegroup, **image)) |
739 | |
740 | def test_report_boot_images_ignores_unknown_image_properties(self): |
741 | + nodegroup = NodeGroup.objects.ensure_master() |
742 | image = make_boot_image_params() |
743 | image['nonesuch'] = factory.make_name('nonesuch'), |
744 | - client = make_worker_client(NodeGroup.objects.ensure_master()) |
745 | - response = self.report_images([image], client=client) |
746 | + client = make_worker_client(nodegroup) |
747 | + response = self.report_images(nodegroup, [image], client=client) |
748 | self.assertEqual( |
749 | (httplib.OK, "OK"), |
750 | (response.status_code, response.content)) |
751 | |
752 | def test_report_boot_images_warns_if_no_images_found(self): |
753 | - recorder = self.patch(api, 'register_persistent_error') |
754 | - client = make_worker_client(NodeGroup.objects.ensure_master()) |
755 | - |
756 | - response = self.report_images([], client=client) |
757 | - self.assertEqual( |
758 | - (httplib.OK, "OK"), |
759 | - (response.status_code, response.content)) |
760 | - |
761 | - self.assertIn( |
762 | - COMPONENT.IMPORT_PXE_FILES, |
763 | - [args[0][0] for args in recorder.call_args_list]) |
764 | + nodegroup = NodeGroup.objects.ensure_master() |
765 | + factory.make_node_group() # Second nodegroup with no images. |
766 | + recorder = self.patch(api, 'register_persistent_error') |
767 | + client = make_worker_client(nodegroup) |
768 | + response = self.report_images(nodegroup, [], client=client) |
769 | + self.assertEqual( |
770 | + (httplib.OK, "OK"), |
771 | + (response.status_code, response.content)) |
772 | + |
773 | + self.assertIn( |
774 | + COMPONENT.IMPORT_PXE_FILES, |
775 | + [args[0][0] for args in recorder.call_args_list]) |
776 | + # Check that the persistent error message contains a link to the |
777 | + # clusters listing. |
778 | + self.assertIn( |
779 | + "/settings/#accepted-clusters", recorder.call_args_list[0][0][1]) |
780 | + |
781 | + def test_report_boot_images_warns_if_any_nodegroup_has_no_images(self): |
782 | + nodegroup = NodeGroup.objects.ensure_master() |
783 | + # Second nodegroup with no images. |
784 | + factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED) |
785 | + recorder = self.patch(api, 'register_persistent_error') |
786 | + client = make_worker_client(nodegroup) |
787 | + image = make_boot_image_params() |
788 | + response = self.report_images(nodegroup, [image], client=client) |
789 | + self.assertEqual( |
790 | + (httplib.OK, "OK"), |
791 | + (response.status_code, response.content)) |
792 | + |
793 | + self.assertIn( |
794 | + COMPONENT.IMPORT_PXE_FILES, |
795 | + [args[0][0] for args in recorder.call_args_list]) |
796 | + |
797 | + def test_report_boot_images_ignores_non_accepted_groups(self): |
798 | + nodegroup = factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED) |
799 | + factory.make_node_group(status=NODEGROUP_STATUS.PENDING) |
800 | + factory.make_node_group(status=NODEGROUP_STATUS.REJECTED) |
801 | + recorder = self.patch(api, 'register_persistent_error') |
802 | + client = make_worker_client(nodegroup) |
803 | + image = make_boot_image_params() |
804 | + response = self.report_images(nodegroup, [image], client=client) |
805 | + self.assertEqual(0, recorder.call_count) |
806 | |
807 | def test_report_boot_images_removes_warning_if_images_found(self): |
808 | self.patch(api, 'register_persistent_error') |
809 | self.patch(api, 'discard_persistent_error') |
810 | - client = make_worker_client(NodeGroup.objects.ensure_master()) |
811 | + nodegroup = factory.make_node_group() |
812 | + image = make_boot_image_params() |
813 | + client = make_worker_client(nodegroup) |
814 | |
815 | - response = self.report_images( |
816 | - [make_boot_image_params()], client=client) |
817 | + response = self.report_images(nodegroup, [image], client=client) |
818 | self.assertEqual( |
819 | (httplib.OK, "OK"), |
820 | (response.status_code, response.content)) |
821 | @@ -3985,15 +4046,20 @@ |
822 | COMPONENT.IMPORT_PXE_FILES) |
823 | |
824 | def test_worker_calls_report_boot_images(self): |
825 | + # report_boot_images() uses the report_boot_images op on the nodes |
826 | + # handlers to send image information. |
827 | refresh_worker(NodeGroup.objects.ensure_master()) |
828 | self.patch(MAASClient, 'post') |
829 | self.patch(tftppath, 'list_boot_images', Mock(return_value=[])) |
830 | + self.patch(boot_images, "get_cluster_uuid") |
831 | |
832 | tasks.report_boot_images.delay() |
833 | |
834 | + # We're not concerned about the payloads (images and nodegroup) here; |
835 | + # those are tested in provisioningserver.tests.test_boot_images. |
836 | MAASClient.post.assert_called_once_with( |
837 | reverse('boot_images_handler').lstrip('/'), 'report_boot_images', |
838 | - images=json.dumps([])) |
839 | + images=ANY, nodegroup=ANY) |
840 | |
841 | |
842 | class TestDescribe(AnonAPITestCase): |
843 | |
844 | === modified file 'src/maasserver/tests/test_bootimage.py' |
845 | --- src/maasserver/tests/test_bootimage.py 2012-09-14 14:16:01 +0000 |
846 | +++ src/maasserver/tests/test_bootimage.py 2012-10-30 11:04:21 +0000 |
847 | @@ -12,7 +12,10 @@ |
848 | __metaclass__ = type |
849 | __all__ = [] |
850 | |
851 | -from maasserver.models import BootImage |
852 | +from maasserver.models import ( |
853 | + BootImage, |
854 | + NodeGroup, |
855 | + ) |
856 | from maasserver.testing.factory import factory |
857 | from maasserver.testing.testcase import TestCase |
858 | from provisioningserver.testing.boot_images import make_boot_image_params |
859 | @@ -20,22 +23,30 @@ |
860 | |
861 | class TestBootImageManager(TestCase): |
862 | |
863 | + def setUp(self): |
864 | + super(TestBootImageManager, self).setUp() |
865 | + self.nodegroup = NodeGroup.objects.ensure_master() |
866 | + |
867 | def test_have_image_returns_False_if_image_not_available(self): |
868 | self.assertFalse( |
869 | - BootImage.objects.have_image(**make_boot_image_params())) |
870 | + BootImage.objects.have_image( |
871 | + self.nodegroup, **make_boot_image_params())) |
872 | |
873 | def test_have_image_returns_True_if_image_available(self): |
874 | params = make_boot_image_params() |
875 | - factory.make_boot_image(**params) |
876 | - self.assertTrue(BootImage.objects.have_image(**params)) |
877 | + factory.make_boot_image(nodegroup=self.nodegroup, **params) |
878 | + self.assertTrue( |
879 | + BootImage.objects.have_image(self.nodegroup, **params)) |
880 | |
881 | def test_register_image_registers_new_image(self): |
882 | params = make_boot_image_params() |
883 | - BootImage.objects.register_image(**params) |
884 | - self.assertTrue(BootImage.objects.have_image(**params)) |
885 | + BootImage.objects.register_image(self.nodegroup, **params) |
886 | + self.assertTrue( |
887 | + BootImage.objects.have_image(self.nodegroup, **params)) |
888 | |
889 | def test_register_image_leaves_existing_image_intact(self): |
890 | params = make_boot_image_params() |
891 | - factory.make_boot_image(**params) |
892 | - BootImage.objects.register_image(**params) |
893 | - self.assertTrue(BootImage.objects.have_image(**params)) |
894 | + factory.make_boot_image(nodegroup=self.nodegroup, **params) |
895 | + BootImage.objects.register_image(self.nodegroup, **params) |
896 | + self.assertTrue( |
897 | + BootImage.objects.have_image(self.nodegroup, **params)) |
898 | |
899 | === modified file 'src/maasserver/tests/test_config.py' |
900 | --- src/maasserver/tests/test_config.py 2012-05-11 07:51:59 +0000 |
901 | +++ src/maasserver/tests/test_config.py 2012-10-30 11:04:21 +0000 |
902 | @@ -16,10 +16,8 @@ |
903 | |
904 | from fixtures import TestWithFixtures |
905 | from maasserver.models import Config |
906 | -from maasserver.models.config import ( |
907 | - DEFAULT_CONFIG, |
908 | - get_default_config, |
909 | - ) |
910 | +import maasserver.models.config |
911 | +from maasserver.models.config import get_default_config |
912 | from maasserver.testing.factory import factory |
913 | from maasserver.testing.testcase import TestCase |
914 | |
915 | @@ -31,6 +29,14 @@ |
916 | default_config = get_default_config() |
917 | self.assertEqual(gethostname(), default_config['maas_name']) |
918 | |
919 | + def test_defaults(self): |
920 | + expected = get_default_config() |
921 | + observed = { |
922 | + name: Config.objects.get_config(name) |
923 | + for name in expected |
924 | + } |
925 | + self.assertEqual(expected, observed) |
926 | + |
927 | |
928 | class CallRecorder: |
929 | """A utility class which tracks the calls to its 'call' method and |
930 | @@ -63,13 +69,15 @@ |
931 | def test_manager_get_config_not_found_in_default_config(self): |
932 | name = factory.getRandomString() |
933 | value = factory.getRandomString() |
934 | - DEFAULT_CONFIG[name] = value |
935 | + self.patch(maasserver.models.config, "DEFAULT_CONFIG", {name: value}) |
936 | config = Config.objects.get_config(name, None) |
937 | self.assertEqual(value, config) |
938 | |
939 | def test_default_config_cannot_be_changed(self): |
940 | name = factory.getRandomString() |
941 | - DEFAULT_CONFIG[name] = {'key': 'value'} |
942 | + self.patch( |
943 | + maasserver.models.config, "DEFAULT_CONFIG", |
944 | + {name: {'key': 'value'}}) |
945 | config = Config.objects.get_config(name) |
946 | config.update({'key2': 'value2'}) |
947 | |
948 | |
949 | === modified file 'src/maasserver/tests/test_preseed.py' |
950 | --- src/maasserver/tests/test_preseed.py 2012-10-05 04:21:12 +0000 |
951 | +++ src/maasserver/tests/test_preseed.py 2012-10-30 11:04:21 +0000 |
952 | @@ -330,10 +330,10 @@ |
953 | ) |
954 | |
955 | def test_squashfs_available(self): |
956 | - BootImage.objects.register_image( |
957 | - self.arch, self.subarch, self.series, self.purpose) |
958 | node = factory.make_node( |
959 | architecture="i386/generic", distro_series="quantal") |
960 | + BootImage.objects.register_image( |
961 | + node.nodegroup, self.arch, self.subarch, self.series, self.purpose) |
962 | self.assertEqual(self.present, is_squashfs_image_present(node)) |
963 | |
964 | |
965 | |
966 | === modified file 'src/maasserver/tests/test_start_up.py' |
967 | --- src/maasserver/tests/test_start_up.py 2012-09-28 18:42:16 +0000 |
968 | +++ src/maasserver/tests/test_start_up.py 2012-10-30 11:04:21 +0000 |
969 | @@ -32,9 +32,9 @@ |
970 | NodeGroup, |
971 | ) |
972 | from maasserver.testing.factory import factory |
973 | +from maasserver.testing.testcase import TestCase |
974 | from maastesting.celery import CeleryFixture |
975 | from maastesting.fakemethod import FakeMethod |
976 | -from maastesting.testcase import TestCase |
977 | from mock import Mock |
978 | from provisioningserver import tasks |
979 | from testresources import FixtureResource |
980 | |
981 | === modified file 'src/maasserver/tests/test_views_settings.py' |
982 | --- src/maasserver/tests/test_views_settings.py 2012-10-03 15:48:11 +0000 |
983 | +++ src/maasserver/tests/test_views_settings.py 2012-10-30 11:04:21 +0000 |
984 | @@ -82,6 +82,7 @@ |
985 | self.patch(settings, "DNS_CONNECT", False) |
986 | new_name = factory.getRandomString() |
987 | new_domain = factory.getRandomString() |
988 | + new_proxy = "http://%s.example.com:1234/" % factory.getRandomString() |
989 | response = self.client.post( |
990 | reverse('settings'), |
991 | get_prefixed_form_data( |
992 | @@ -89,12 +90,16 @@ |
993 | data={ |
994 | 'maas_name': new_name, |
995 | 'enlistment_domain': new_domain, |
996 | + 'http_proxy': new_proxy, |
997 | })) |
998 | - |
999 | - self.assertEqual(httplib.FOUND, response.status_code) |
1000 | - self.assertEqual(new_name, Config.objects.get_config('maas_name')) |
1001 | + self.assertEqual(httplib.FOUND, response.status_code, response.content) |
1002 | self.assertEqual( |
1003 | - new_domain, Config.objects.get_config('enlistment_domain')) |
1004 | + (new_name, |
1005 | + new_domain, |
1006 | + new_proxy), |
1007 | + (Config.objects.get_config('maas_name'), |
1008 | + Config.objects.get_config('enlistment_domain'), |
1009 | + Config.objects.get_config('http_proxy'))) |
1010 | |
1011 | def test_settings_commissioning_POST(self): |
1012 | new_after_commissioning = factory.getRandomEnum( |
1013 | |
1014 | === modified file 'src/provisioningserver/boot_images.py' |
1015 | --- src/provisioningserver/boot_images.py 2012-09-14 11:04:33 +0000 |
1016 | +++ src/provisioningserver/boot_images.py 2012-10-30 11:04:21 +0000 |
1017 | @@ -32,6 +32,7 @@ |
1018 | from provisioningserver.config import Config |
1019 | from provisioningserver.logging import task_logger |
1020 | from provisioningserver.pxe import tftppath |
1021 | +from provisioningserver.start_cluster_controller import get_cluster_uuid |
1022 | |
1023 | |
1024 | def get_cached_knowledge(): |
1025 | @@ -53,7 +54,7 @@ |
1026 | """Submit images to server.""" |
1027 | MAASClient(MAASOAuth(*api_credentials), MAASDispatcher(), maas_url).post( |
1028 | 'api/1.0/boot-images/', 'report_boot_images', |
1029 | - images=json.dumps(images)) |
1030 | + nodegroup=get_cluster_uuid(), images=json.dumps(images)) |
1031 | |
1032 | |
1033 | def report_to_server(): |
1034 | |
1035 | === modified file 'src/provisioningserver/tasks.py' |
1036 | --- src/provisioningserver/tasks.py 2012-10-11 13:25:43 +0000 |
1037 | +++ src/provisioningserver/tasks.py 2012-10-30 11:04:21 +0000 |
1038 | @@ -23,6 +23,7 @@ |
1039 | 'write_full_dns_config', |
1040 | ] |
1041 | |
1042 | +import os |
1043 | from subprocess import ( |
1044 | CalledProcessError, |
1045 | check_call, |
1046 | @@ -347,6 +348,11 @@ |
1047 | UPDATE_NODE_TAGS_RETRY_DELAY = 2 |
1048 | |
1049 | |
1050 | +# ===================================================================== |
1051 | +# Tags-related tasks |
1052 | +# ===================================================================== |
1053 | + |
1054 | + |
1055 | @task(max_retries=UPDATE_NODE_TAGS_MAX_RETRY) |
1056 | def update_node_tags(tag_name, tag_definition, retry=True): |
1057 | """Update the nodes for a new/changed tag definition. |
1058 | @@ -363,3 +369,16 @@ |
1059 | exc=exc, countdown=UPDATE_NODE_TAGS_RETRY_DELAY) |
1060 | else: |
1061 | raise |
1062 | + |
1063 | + |
1064 | +# ===================================================================== |
1065 | +# Image importing-related tasks |
1066 | +# ===================================================================== |
1067 | + |
1068 | +@task |
1069 | +def import_pxe_files(http_proxy=None): |
1070 | + env = dict(os.environ) |
1071 | + if http_proxy is not None: |
1072 | + env['http_proxy'] = http_proxy |
1073 | + env['https_proxy'] = http_proxy |
1074 | + check_call(['maas-import-pxe-files'], env=env) |
1075 | |
1076 | === modified file 'src/provisioningserver/testing/boot_images.py' |
1077 | --- src/provisioningserver/testing/boot_images.py 2012-09-14 14:16:01 +0000 |
1078 | +++ src/provisioningserver/testing/boot_images.py 2012-10-30 11:04:21 +0000 |
1079 | @@ -17,7 +17,7 @@ |
1080 | from maastesting.factory import factory |
1081 | |
1082 | |
1083 | -def make_boot_image_params(**kwargs): |
1084 | +def make_boot_image_params(): |
1085 | """Create an arbitrary dict of boot-image parameters. |
1086 | |
1087 | These are the parameters that together describe a kind of boot that we |
1088 | @@ -25,10 +25,8 @@ |
1089 | Ubuntu release, and boot purpose. See the `tftppath` module for how |
1090 | these fit together. |
1091 | """ |
1092 | - fields = dict( |
1093 | + return dict( |
1094 | architecture=factory.make_name('architecture'), |
1095 | subarchitecture=factory.make_name('subarchitecture'), |
1096 | release=factory.make_name('release'), |
1097 | purpose=factory.make_name('purpose')) |
1098 | - fields.update(kwargs) |
1099 | - return fields |
1100 | |
1101 | === modified file 'src/provisioningserver/tests/test_boot_images.py' |
1102 | --- src/provisioningserver/tests/test_boot_images.py 2012-10-04 05:49:09 +0000 |
1103 | +++ src/provisioningserver/tests/test_boot_images.py 2012-10-30 11:04:21 +0000 |
1104 | @@ -15,7 +15,10 @@ |
1105 | import json |
1106 | |
1107 | from apiclient.maas_client import MAASClient |
1108 | -from mock import Mock |
1109 | +from mock import ( |
1110 | + Mock, |
1111 | + sentinel, |
1112 | + ) |
1113 | from provisioningserver import boot_images |
1114 | from provisioningserver.pxe import tftppath |
1115 | from provisioningserver.testing.boot_images import make_boot_image_params |
1116 | @@ -34,11 +37,12 @@ |
1117 | self.set_api_credentials() |
1118 | image = make_boot_image_params() |
1119 | self.patch(tftppath, 'list_boot_images', Mock(return_value=[image])) |
1120 | + get_cluster_uuid = self.patch(boot_images, "get_cluster_uuid") |
1121 | + get_cluster_uuid.return_value = sentinel.uuid |
1122 | self.patch(MAASClient, 'post') |
1123 | - |
1124 | boot_images.report_to_server() |
1125 | - |
1126 | args, kwargs = MAASClient.post.call_args |
1127 | + self.assertIs(sentinel.uuid, kwargs["nodegroup"]) |
1128 | self.assertItemsEqual([image], json.loads(kwargs['images'])) |
1129 | |
1130 | def test_does_nothing_without_maas_url(self): |
1131 | |
1132 | === modified file 'src/provisioningserver/tests/test_tasks.py' |
1133 | --- src/provisioningserver/tests/test_tasks.py 2012-10-08 08:24:11 +0000 |
1134 | +++ src/provisioningserver/tests/test_tasks.py 2012-10-30 11:04:21 +0000 |
1135 | @@ -25,6 +25,7 @@ |
1136 | from apiclient.maas_client import MAASClient |
1137 | from apiclient.testing.credentials import make_api_credentials |
1138 | from celery.app import app_or_default |
1139 | +from celery.task import Task |
1140 | from maastesting.celery import CeleryFixture |
1141 | from maastesting.factory import factory |
1142 | from maastesting.fakemethod import ( |
1143 | @@ -32,10 +33,14 @@ |
1144 | MultiFakeMethod, |
1145 | ) |
1146 | from maastesting.matchers import ContainsAll |
1147 | -from mock import Mock |
1148 | +from mock import ( |
1149 | + ANY, |
1150 | + Mock, |
1151 | + ) |
1152 | from netaddr import IPNetwork |
1153 | from provisioningserver import ( |
1154 | auth, |
1155 | + boot_images, |
1156 | cache, |
1157 | tags, |
1158 | tasks, |
1159 | @@ -59,6 +64,7 @@ |
1160 | from provisioningserver.tags import MissingCredentials |
1161 | from provisioningserver.tasks import ( |
1162 | add_new_dhcp_host_map, |
1163 | + import_pxe_files, |
1164 | Omshell, |
1165 | power_off, |
1166 | power_on, |
1167 | @@ -485,6 +491,7 @@ |
1168 | auth.record_api_credentials(':'.join(make_api_credentials())) |
1169 | image = make_boot_image_params() |
1170 | self.patch(tftppath, 'list_boot_images', Mock(return_value=[image])) |
1171 | + self.patch(boot_images, "get_cluster_uuid") |
1172 | self.patch(MAASClient, 'post') |
1173 | |
1174 | report_boot_images.delay() |
1175 | @@ -534,3 +541,26 @@ |
1176 | self.assertRaises( |
1177 | MissingCredentials, update_node_tags.delay, tag, |
1178 | '//node', retry=True) |
1179 | + |
1180 | + |
1181 | +class TestImportPxeFiles(PservTestCase): |
1182 | + |
1183 | + def test_import_pxe_files(self): |
1184 | + recorder = self.patch(tasks, 'check_call', Mock()) |
1185 | + import_pxe_files() |
1186 | + recorder.assert_called_once_with(['maas-import-pxe-files'], env=ANY) |
1187 | + self.assertIsInstance(import_pxe_files, Task) |
1188 | + |
1189 | + def test_import_pxe_files_preserves_environment(self): |
1190 | + recorder = self.patch(tasks, 'check_call', Mock()) |
1191 | + import_pxe_files() |
1192 | + recorder.assert_called_once_with( |
1193 | + ['maas-import-pxe-files'], env=os.environ) |
1194 | + |
1195 | + def test_import_pxe_files_sets_proxy(self): |
1196 | + recorder = self.patch(tasks, 'check_call', Mock()) |
1197 | + proxy = factory.getRandomString() |
1198 | + import_pxe_files(http_proxy=proxy) |
1199 | + expected_env = dict(os.environ, http_proxy=proxy, https_proxy=proxy) |
1200 | + recorder.assert_called_once_with( |
1201 | + ['maas-import-pxe-files'], env=expected_env) |
1202 | |
1203 | === modified file 'src/provisioningserver/tests/test_tftp.py' |
1204 | --- src/provisioningserver/tests/test_tftp.py 2012-10-02 10:53:22 +0000 |
1205 | +++ src/provisioningserver/tests/test_tftp.py 2012-10-30 11:04:21 +0000 |
1206 | @@ -35,6 +35,7 @@ |
1207 | inlineCallbacks, |
1208 | succeed, |
1209 | ) |
1210 | +from twisted.python import context |
1211 | from zope.interface.verify import verifyObject |
1212 | |
1213 | |
1214 | @@ -213,6 +214,16 @@ |
1215 | mac = factory.getRandomMACAddress(b"-") |
1216 | config_path = compose_config_path(mac) |
1217 | backend = TFTPBackend(self.make_dir(), b"http://example.com/") |
1218 | + # python-tx-tftp sets up call context so that backends can discover |
1219 | + # more about the environment in which they're running. |
1220 | + call_context = { |
1221 | + "local": ( |
1222 | + factory.getRandomIPAddress(), |
1223 | + factory.getRandomPort()), |
1224 | + "remote": ( |
1225 | + factory.getRandomIPAddress(), |
1226 | + factory.getRandomPort()), |
1227 | + } |
1228 | |
1229 | @partial(self.patch, backend, "get_config_reader") |
1230 | def get_config_reader(params): |
1231 | @@ -220,9 +231,16 @@ |
1232 | params_json_reader = BytesReader(params_json) |
1233 | return succeed(params_json_reader) |
1234 | |
1235 | - reader = yield backend.get_reader(config_path) |
1236 | + reader = yield context.call( |
1237 | + call_context, backend.get_reader, config_path) |
1238 | output = reader.read(10000) |
1239 | - expected_params = dict(mac=mac) |
1240 | + # The addresses provided by python-tx-tftp in the call context are |
1241 | + # passed over the wire as address:port strings. |
1242 | + expected_params = { |
1243 | + "mac": mac, |
1244 | + "local": call_context["local"][0], # address only. |
1245 | + "remote": call_context["remote"][0], # address only. |
1246 | + } |
1247 | observed_params = json.loads(output) |
1248 | self.assertEqual(expected_params, observed_params) |
1249 | |
1250 | |
1251 | === modified file 'src/provisioningserver/tftp.py' |
1252 | --- src/provisioningserver/tftp.py 2012-10-03 08:50:17 +0000 |
1253 | +++ src/provisioningserver/tftp.py 2012-10-30 11:04:21 +0000 |
1254 | @@ -34,6 +34,7 @@ |
1255 | IReader, |
1256 | ) |
1257 | from tftp.errors import FileNotFound |
1258 | +from twisted.python.context import get |
1259 | from twisted.web.client import getPage |
1260 | import twisted.web.error |
1261 | from zope.interface import implementer |
1262 | @@ -211,6 +212,11 @@ |
1263 | for key, value in config_file_match.groupdict().items() |
1264 | if value is not None |
1265 | } |
1266 | + # Send the local and remote endpoint addresses. |
1267 | + local_host, local_port = get("local", (None, None)) |
1268 | + params["local"] = local_host |
1269 | + remote_host, remote_port = get("remote", (None, None)) |
1270 | + params["remote"] = remote_host |
1271 | d = self.get_config_reader(params) |
1272 | d.addErrback(self.get_page_errback, file_name) |
1273 | return d |