Merge lp:~julian-edwards/maas/1.2-backport-bug-1068843 into lp:maas/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
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).

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

Subscribers

People subscribed via source and target branches

to status/vote changes: