← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~julian-edwards/maas/1.2-backport-bug-1068843 into lp:maas/1.2

 

Julian Edwards has proposed merging lp:~julian-edwards/maas/1.2-backport-bug-1068843 into lp:maas/1.2.

Commit message:
Backport changes from trunk that fix bug 1068843 (cluster controller has no provisioning images).

Requested reviews:
  MAAS Maintainers (maas-maintainers)
Related bugs:
  Bug #1068843 in MAAS: "maas-cluster-controller doesn't have images for provisioning"
  https://bugs.launchpad.net/maas/+bug/1068843

For more details, see:
https://code.launchpad.net/~julian-edwards/maas/1.2-backport-bug-1068843/+merge/132043
-- 
https://code.launchpad.net/~julian-edwards/maas/1.2-backport-bug-1068843/+merge/132043
Your team MAAS Maintainers is requested to review the proposed merge of lp:~julian-edwards/maas/1.2-backport-bug-1068843 into lp:maas/1.2.
=== modified file 'contrib/python-tx-tftp/tftp/protocol.py'
--- contrib/python-tx-tftp/tftp/protocol.py	2012-07-05 14:36:27 +0000
+++ contrib/python-tx-tftp/tftp/protocol.py	2012-10-30 10:29:27 +0000
@@ -12,6 +12,7 @@
 from twisted.internet.defer import inlineCallbacks, returnValue
 from twisted.internet.protocol import DatagramProtocol
 from twisted.python import log
+from twisted.python.context import call
 
 
 class TFTP(DatagramProtocol):
@@ -48,11 +49,22 @@
 
     @inlineCallbacks
     def _startSession(self, datagram, addr, mode):
+        # Set up a call context so that we can pass extra arbitrary
+        # information to interested backends without adding extra call
+        # arguments, or switching to using a request object, for example.
+        context = {}
+        if self.transport is not None:
+            # Add the local and remote addresses to the call context.
+            local = self.transport.getHost()
+            context["local"] = local.host, local.port
+            context["remote"] = addr
         try:
             if datagram.opcode == OP_WRQ:
-                fs_interface = yield self.backend.get_writer(datagram.filename)
+                fs_interface = yield call(
+                    context, self.backend.get_writer, datagram.filename)
             elif datagram.opcode == OP_RRQ:
-                fs_interface = yield self.backend.get_reader(datagram.filename)
+                fs_interface = yield call(
+                    context, self.backend.get_reader, datagram.filename)
         except Unsupported, e:
             self.transport.write(ERRORDatagram.from_code(ERR_ILLEGAL_OP,
                                     str(e)).to_wire(), addr)

=== modified file 'contrib/python-tx-tftp/tftp/test/test_protocol.py'
--- contrib/python-tx-tftp/tftp/test/test_protocol.py	2012-07-25 19:59:37 +0000
+++ contrib/python-tx-tftp/tftp/test/test_protocol.py	2012-10-30 10:29:27 +0000
@@ -11,9 +11,11 @@
 from tftp.netascii import NetasciiReceiverProxy, NetasciiSenderProxy
 from tftp.protocol import TFTP
 from twisted.internet import reactor
-from twisted.internet.defer import Deferred
+from twisted.internet.address import IPv4Address
+from twisted.internet.defer import Deferred, inlineCallbacks
 from twisted.internet.protocol import DatagramProtocol
 from twisted.internet.task import Clock
+from twisted.python import context
 from twisted.python.filepath import FilePath
 from twisted.test.proto_helpers import StringTransport
 from twisted.trial import unittest
@@ -50,7 +52,8 @@
 
     def setUp(self):
         self.clock = Clock()
-        self.transport = FakeTransport(hostAddress=('127.0.0.1', self.port))
+        self.transport = FakeTransport(
+            hostAddress=IPv4Address('UDP', '127.0.0.1', self.port))
 
     def test_malformed_datagram(self):
         tftp = TFTP(BackendFactory(), _clock=self.clock)
@@ -247,3 +250,71 @@
         self.clock.advance(1)
         self.assertTrue(d.called)
         self.assertTrue(IWriter.providedBy(d.result.backend))
+
+
+class CapturedContext(Exception):
+    """A donkey, to carry the call context back up the stack."""
+
+    def __init__(self, args, names):
+        super(CapturedContext, self).__init__(*args)
+        self.context = {name: context.get(name) for name in names}
+
+
+class ContextCapturingBackend(object):
+    """A fake `IBackend` that raises `CapturedContext`.
+
+    Calling `get_reader` or `get_writer` raises a `CapturedContext` exception,
+    which captures the values of the call context for the given `names`.
+    """
+
+    def __init__(self, *names):
+        self.names = names
+
+    def get_reader(self, file_name):
+        raise CapturedContext(("get_reader", file_name), self.names)
+
+    def get_writer(self, file_name):
+        raise CapturedContext(("get_writer", file_name), self.names)
+
+
+class HostTransport(object):
+    """A fake `ITransport` that only responds to `getHost`."""
+
+    def __init__(self, host):
+        self.host = host
+
+    def getHost(self):
+        return IPv4Address("UDP", *self.host)
+
+
+class BackendCallingContext(unittest.TestCase):
+
+    def setUp(self):
+        super(BackendCallingContext, self).setUp()
+        self.backend = ContextCapturingBackend("local", "remote")
+        self.tftp = TFTP(self.backend)
+        self.tftp.transport = HostTransport(("12.34.56.78", 1234))
+
+    @inlineCallbacks
+    def test_context_rrq(self):
+        rrq_datagram = RRQDatagram('nonempty', 'NetASCiI', {})
+        rrq_addr = ('127.0.0.1', 1069)
+        error = yield self.assertFailure(
+            self.tftp._startSession(rrq_datagram, rrq_addr, "octet"),
+            CapturedContext)
+        self.assertEqual(("get_reader", rrq_datagram.filename), error.args)
+        self.assertEqual(
+            {"local": self.tftp.transport.host, "remote": rrq_addr},
+            error.context)
+
+    @inlineCallbacks
+    def test_context_wrq(self):
+        wrq_datagram = WRQDatagram('nonempty', 'NetASCiI', {})
+        wrq_addr = ('127.0.0.1', 1069)
+        error = yield self.assertFailure(
+            self.tftp._startSession(wrq_datagram, wrq_addr, "octet"),
+            CapturedContext)
+        self.assertEqual(("get_writer", wrq_datagram.filename), error.args)
+        self.assertEqual(
+            {"local": self.tftp.transport.host, "remote": wrq_addr},
+            error.context)

=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-10-29 11:04:39 +0000
+++ src/maasserver/api.py	2012-10-30 10:29:27 +0000
@@ -162,7 +162,10 @@
     compose_preseed_url,
     )
 from maasserver.server_address import get_maas_facing_server_address
-from maasserver.utils import map_enum
+from maasserver.utils import (
+    absolute_reverse,
+    map_enum,
+    )
 from maasserver.utils.orm import get_one
 from piston.handler import (
     AnonymousBaseHandler,
@@ -1695,6 +1698,8 @@
     :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not
         'armhf').
     :param subarch: Subarchitecture name (in the pxelinux namespace).
+    :param local: The IP address of the cluster controller.
+    :param remote: The IP address of the booting node.
     """
     node = get_node_from_mac_string(request.GET.get('mac', None))
 
@@ -1749,11 +1754,12 @@
 
     purpose = get_boot_purpose(node)
     server_address = get_maas_facing_server_address()
+    cluster_address = get_mandatory_param(request.GET, "local")
 
     params = KernelParameters(
         arch=arch, subarch=subarch, release=series, purpose=purpose,
         hostname=hostname, domain=domain, preseed_url=preseed_url,
-        log_host=server_address, fs_host=server_address)
+        log_host=server_address, fs_host=cluster_address)
 
     return HttpResponse(
         json.dumps(params._asdict()),
@@ -1777,24 +1783,34 @@
             `purpose`, all as in the code that determines TFTP paths for
             these images.
         """
-        check_nodegroup_access(request, NodeGroup.objects.ensure_master())
+        nodegroup_uuid = get_mandatory_param(request.data, "nodegroup")
+        nodegroup = get_object_or_404(NodeGroup, uuid=nodegroup_uuid)
+        check_nodegroup_access(request, nodegroup)
         images = json.loads(get_mandatory_param(request.data, 'images'))
 
         for image in images:
             BootImage.objects.register_image(
+                nodegroup=nodegroup,
                 architecture=image['architecture'],
                 subarchitecture=image.get('subarchitecture', 'generic'),
                 release=image['release'],
                 purpose=image['purpose'])
 
-        if len(images) == 0:
+        # Work out if any nodegroups are missing images.
+        nodegroup_ids_with_images = BootImage.objects.values_list(
+            "nodegroup_id", flat=True)
+        nodegroups_missing_images = NodeGroup.objects.exclude(
+            id__in=nodegroup_ids_with_images).filter(
+                status=NODEGROUP_STATUS.ACCEPTED)
+        if nodegroups_missing_images.exists():
             warning = dedent("""\
-                No boot images have been imported yet.  Either the
+                Some cluster controllers are missing boot images.  Either the
                 maas-import-pxe-files script has not run yet, or it failed.
 
-                Try running it manually.  If it succeeds, this message will
-                go away within 5 minutes.
-                """)
+                Try running it manually on the affected
+                <a href="%s#accepted-clusters">cluster controllers.</a>
+                If it succeeds, this message will go away within 5 minutes.
+                """ % absolute_reverse("settings"))
             register_persistent_error(COMPONENT.IMPORT_PXE_FILES, warning)
         else:
             discard_persistent_error(COMPONENT.IMPORT_PXE_FILES)

=== modified file 'src/maasserver/components.py'
--- src/maasserver/components.py	2012-09-28 18:42:16 +0000
+++ src/maasserver/components.py	2012-10-30 10:29:27 +0000
@@ -17,6 +17,7 @@
     "register_persistent_error",
     ]
 
+from django.utils.safestring import mark_safe
 from maasserver.models import ComponentError
 from maasserver.utils.orm import get_one
 
@@ -50,4 +51,5 @@
 
 def get_persistent_errors():
     """Return list of current persistent error messages."""
-    return sorted(err.error for err in ComponentError.objects.all())
+    return sorted(
+        mark_safe(err.error) for err in ComponentError.objects.all())

=== modified file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	2012-10-29 11:04:39 +0000
+++ src/maasserver/forms.py	2012-10-30 10:29:27 +0000
@@ -583,6 +583,12 @@
         label="Default domain for new nodes", required=False, help_text=(
             "If 'local' is chosen, nodes must be using mDNS. Leave empty to "
             "use hostnames without a domain for newly enlisted nodes."))
+    http_proxy = forms.URLField(
+        label="Proxy for HTTP and HTTPS traffic", required=False,
+        help_text=(
+            "This is used by the cluster and region controllers for "
+            "downloading PXE boot images and other provisioning-related "
+            "resources. It is not passed into provisioned nodes."))
 
 
 class CommissioningForm(ConfigForm):

=== added file 'src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py'
--- src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/0039_add_nodegroup_to_bootimage.py	2012-10-30 10:29:27 +0000
@@ -0,0 +1,217 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Removing unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'architecture', 'purpose']
+        db.delete_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'architecture', 'purpose'])
+
+        # Adding field 'BootImage.nodegroup'
+        db.add_column(u'maasserver_bootimage', 'nodegroup',
+                      self.gf('django.db.models.fields.related.ForeignKey')(null=True, to=orm['maasserver.NodeGroup']),
+                      keep_default=False)
+        # Set existing bootimage rows to have a nodegroup of the master
+        # nodegroup (which is the first row in that table).
+        db.execute("UPDATE maasserver_bootimage SET nodegroup_id = (SELECT id from maasserver_nodegroup ORDER BY id LIMIT 1)")
+        db.execute("ALTER TABLE maasserver_bootimage ALTER nodegroup_id SET NOT NULL")
+        # Adding unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'nodegroup', 'architecture', 'purpose']
+        db.create_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'nodegroup_id', 'architecture', 'purpose'])
+
+
+    def backwards(self, orm):
+        # Removing unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'nodegroup', 'architecture', 'purpose']
+        db.delete_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'nodegroup_id', 'architecture', 'purpose'])
+
+        # Deleting field 'BootImage.nodegroup'
+        db.delete_column(u'maasserver_bootimage', 'nodegroup_id')
+
+        # Adding unique constraint on 'BootImage', fields ['subarchitecture', 'release', 'architecture', 'purpose']
+        db.create_unique(u'maasserver_bootimage', ['subarchitecture', 'release', 'architecture', 'purpose'])
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        u'maasserver.bootimage': {
+            'Meta': {'unique_together': "((u'nodegroup', u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'},
+            'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
+            'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        u'maasserver.componenterror': {
+            'Meta': {'object_name': 'ComponentError'},
+            'component': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'error': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.config': {
+            'Meta': {'object_name': 'Config'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
+        },
+        u'maasserver.dhcplease': {
+            'Meta': {'object_name': 'DHCPLease'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
+            'mac': ('maasserver.fields.MACAddressField', [], {}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
+        },
+        u'maasserver.filestorage': {
+            'Meta': {'object_name': 'FileStorage'},
+            'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
+            'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        u'maasserver.macaddress': {
+            'Meta': {'object_name': 'MACAddress'},
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
+            'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.node': {
+            'Meta': {'object_name': 'Node'},
+            'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386/generic'", 'max_length': '31'}),
+            'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'distro_series': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'hardware_details': ('maasserver.fields.XMLField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
+            'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
+            'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
+            'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
+            'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
+            'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-f9e8804e-1d11-11e2-be72-0026c71eea0e'", 'unique': 'True', 'max_length': '41'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
+            'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.nodegroup': {
+            'Meta': {'object_name': 'NodeGroup'},
+            'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
+            'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
+            'cluster_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'blank': 'True'}),
+            'status': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
+        },
+        u'maasserver.nodegroupinterface': {
+            'Meta': {'unique_together': "((u'nodegroup', u'interface'),)", 'object_name': 'NodeGroupInterface'},
+            'broadcast_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
+            'ip_range_high': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'ip_range_low': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
+            'router_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'subnet_mask': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.sshkey': {
+            'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.TextField', [], {}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        u'maasserver.tag': {
+            'Meta': {'object_name': 'Tag'},
+            'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'definition': ('django.db.models.fields.TextField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {})
+        },
+        u'maasserver.userprofile': {
+            'Meta': {'object_name': 'UserProfile'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+        },
+        'piston.consumer': {
+            'Meta': {'object_name': 'Consumer'},
+            'description': ('django.db.models.fields.TextField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
+        },
+        'piston.token': {
+            'Meta': {'object_name': 'Token'},
+            'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+            'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1350997269L'}),
+            'token_type': ('django.db.models.fields.IntegerField', [], {}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
+            'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
+        }
+    }
+
+    complete_apps = ['maasserver']

=== modified file 'src/maasserver/models/bootimage.py'
--- src/maasserver/models/bootimage.py	2012-09-13 06:53:55 +0000
+++ src/maasserver/models/bootimage.py	2012-10-30 10:29:27 +0000
@@ -17,10 +17,12 @@
 
 from django.db.models import (
     CharField,
+    ForeignKey,
     Manager,
     Model,
     )
 from maasserver import DefaultMeta
+from maasserver.models.nodegroup import NodeGroup
 
 
 class BootImageManager(Manager):
@@ -29,25 +31,30 @@
     Don't import or instantiate this directly; access as `BootImage.objects`.
     """
 
-    def get_by_natural_key(self, architecture, subarchitecture, release,
-                           purpose):
+    def get_by_natural_key(self, nodegroup, architecture, subarchitecture,
+                           release, purpose):
         """Look up a specific image."""
         return self.get(
-            architecture=architecture, subarchitecture=subarchitecture,
-            release=release, purpose=purpose)
+            nodegroup=nodegroup, architecture=architecture,
+            subarchitecture=subarchitecture, release=release,
+            purpose=purpose)
 
-    def register_image(self, architecture, subarchitecture, release, purpose):
+    def register_image(self, nodegroup, architecture, subarchitecture,
+                       release, purpose):
         """Register an image if it wasn't already registered."""
         self.get_or_create(
-            architecture=architecture, subarchitecture=subarchitecture,
-            release=release, purpose=purpose)
+            nodegroup=nodegroup, architecture=architecture,
+            subarchitecture=subarchitecture, release=release,
+            purpose=purpose)
 
-    def have_image(self, architecture, subarchitecture, release, purpose):
+    def have_image(self, nodegroup, architecture, subarchitecture, release,
+                   purpose):
         """Is an image for the given kind of boot available?"""
         try:
             self.get_by_natural_key(
-                architecture=architecture, subarchitecture=subarchitecture,
-                release=release, purpose=purpose)
+                nodegroup=nodegroup, architecture=architecture,
+                subarchitecture=subarchitecture, release=release,
+                purpose=purpose)
             return True
         except BootImage.DoesNotExist:
             return False
@@ -69,11 +76,15 @@
 
     class Meta(DefaultMeta):
         unique_together = (
-            ('architecture', 'subarchitecture', 'release', 'purpose'),
+            ('nodegroup', 'architecture', 'subarchitecture', 'release',
+             'purpose'),
             )
 
     objects = BootImageManager()
 
+    # Nodegroup (cluster controller) that has the images.
+    nodegroup = ForeignKey(NodeGroup, null=False, editable=False, unique=False)
+
     # System architecture (e.g. "i386") that the image is for.
     architecture = CharField(max_length=255, blank=False, editable=False)
 

=== modified file 'src/maasserver/models/config.py'
--- src/maasserver/models/config.py	2012-09-27 13:50:34 +0000
+++ src/maasserver/models/config.py	2012-10-30 10:29:27 +0000
@@ -53,6 +53,7 @@
         'enlistment_domain': b'local',
         'default_distro_series': DISTRO_SERIES.precise,
         'commissioning_distro_series': DISTRO_SERIES.precise,
+        'http_proxy': None,
         ## /settings
         }
 

=== modified file 'src/maasserver/preseed.py'
--- src/maasserver/preseed.py	2012-10-02 21:07:00 +0000
+++ src/maasserver/preseed.py	2012-10-30 10:29:27 +0000
@@ -242,7 +242,7 @@
     """Whether or not the SquashFS image can be used during installation."""
     arch, subarch = node.architecture.split("/")
     return BootImage.objects.have_image(
-        arch, subarch, node.get_distro_series(), "filesystem")
+        node.nodegroup, arch, subarch, node.get_distro_series(), "filesystem")
 
 
 def render_preseed(node, prefix, release=''):

=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py	2012-10-25 13:40:55 +0000
+++ src/maasserver/testing/factory.py	2012-10-30 10:29:27 +0000
@@ -325,7 +325,7 @@
             '%s="%s"' % (key, value) for key, value in items.items()])
 
     def make_boot_image(self, architecture=None, subarchitecture=None,
-                        release=None, purpose=None):
+                        release=None, purpose=None, nodegroup=None):
         if architecture is None:
             architecture = self.make_name('architecture')
         if subarchitecture is None:
@@ -334,7 +334,10 @@
             release = self.make_name('release')
         if purpose is None:
             purpose = self.make_name('purpose')
+        if nodegroup is None:
+            nodegroup = self.make_node_group()
         return BootImage.objects.create(
+            nodegroup=nodegroup,
             architecture=architecture,
             subarchitecture=subarchitecture,
             release=release,

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-10-29 11:04:39 +0000
+++ src/maasserver/tests/test_api.py	2012-10-30 10:29:27 +0000
@@ -3043,11 +3043,16 @@
 
 class TestPXEConfigAPI(AnonAPITestCase):
 
+    def get_default_params(self):
+        return {
+            "local": factory.getRandomIPAddress(),
+            "remote": factory.getRandomIPAddress(),
+            }
+
     def get_mac_params(self):
-        return {'mac': factory.make_mac_address().mac_address}
-
-    def get_default_params(self):
-        return dict()
+        params = self.get_default_params()
+        params['mac'] = factory.make_mac_address().mac_address
+        return params
 
     def get_pxeconfig(self, params=None):
         """Make a request to `pxeconfig`, and return its response dict."""
@@ -3081,7 +3086,8 @@
             ContainsAll(KernelParameters._fields))
 
     def test_pxeconfig_returns_data_for_known_node(self):
-        response = self.client.get(reverse('pxeconfig'), self.get_mac_params())
+        params = self.get_mac_params()
+        response = self.client.get(reverse('pxeconfig'), params)
         self.assertEqual(httplib.OK, response.status_code)
 
     def test_pxeconfig_returns_no_content_for_unknown_node(self):
@@ -3093,6 +3099,7 @@
         architecture = factory.getRandomEnum(ARCHITECTURE)
         arch, subarch = architecture.split('/')
         params = dict(
+            self.get_default_params(),
             mac=factory.getRandomMACAddress(delimiter=b'-'),
             arch=arch,
             subarch=subarch)
@@ -3121,15 +3128,19 @@
         full_hostname = '.'.join([host, domain])
         node = factory.make_node(hostname=full_hostname)
         mac = factory.make_mac_address(node=node)
-        pxe_config = self.get_pxeconfig(params={'mac': mac.mac_address})
+        params = self.get_default_params()
+        params['mac'] = mac.mac_address
+        pxe_config = self.get_pxeconfig(params)
         self.assertEqual(host, pxe_config.get('hostname'))
         self.assertNotIn(domain, pxe_config.values())
 
     def test_pxeconfig_uses_nodegroup_domain_for_node(self):
         mac = factory.make_mac_address()
+        params = self.get_default_params()
+        params['mac'] = mac
         self.assertEqual(
             mac.node.nodegroup.name,
-            self.get_pxeconfig({'mac': mac.mac_address}).get('domain'))
+            self.get_pxeconfig(params).get('domain'))
 
     def get_without_param(self, param):
         """Request a `pxeconfig()` response, but omit `param` from request."""
@@ -3194,6 +3205,13 @@
             fake_boot_purpose,
             json.loads(response.content)["purpose"])
 
+    def test_pxeconfig_returns_fs_host_as_cluster_controller(self):
+        # The kernel parameter `fs_host` points to the cluster controller
+        # address, which is passed over within the `local` parameter.
+        params = self.get_default_params()
+        kernel_params = KernelParameters(**self.get_pxeconfig(params))
+        self.assertEqual(params["local"], kernel_params.fs_host)
+
 
 class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase):
     scenarios = [
@@ -3917,63 +3935,102 @@
         ('celery', FixtureResource(CeleryFixture())),
         )
 
-    def report_images(self, images, client=None):
+    def report_images(self, nodegroup, images, client=None):
         if client is None:
             client = self.client
         return client.post(
-            reverse('boot_images_handler'),
-            {'op': 'report_boot_images', 'images': json.dumps(images)})
+            reverse('boot_images_handler'), {
+                'images': json.dumps(images),
+                'nodegroup': nodegroup.uuid,
+                'op': 'report_boot_images',
+                })
 
     def test_report_boot_images_does_not_work_for_normal_user(self):
-        NodeGroup.objects.ensure_master()
+        nodegroup = NodeGroup.objects.ensure_master()
         log_in_as_normal_user(self.client)
-        response = self.report_images([])
-        self.assertEqual(httplib.FORBIDDEN, response.status_code)
+        response = self.report_images(nodegroup, [])
+        self.assertEqual(
+            httplib.FORBIDDEN, response.status_code, response.content)
 
     def test_report_boot_images_works_for_master_worker(self):
-        client = make_worker_client(NodeGroup.objects.ensure_master())
-        response = self.report_images([], client=client)
+        nodegroup = NodeGroup.objects.ensure_master()
+        client = make_worker_client(nodegroup)
+        response = self.report_images(nodegroup, [], client=client)
         self.assertEqual(httplib.OK, response.status_code)
 
     def test_report_boot_images_stores_images(self):
+        nodegroup = NodeGroup.objects.ensure_master()
         image = make_boot_image_params()
-        client = make_worker_client(NodeGroup.objects.ensure_master())
-        response = self.report_images([image], client=client)
+        client = make_worker_client(nodegroup)
+        response = self.report_images(nodegroup, [image], client=client)
         self.assertEqual(
             (httplib.OK, "OK"),
             (response.status_code, response.content))
         self.assertTrue(
-            BootImage.objects.have_image(**image))
+            BootImage.objects.have_image(nodegroup=nodegroup, **image))
 
     def test_report_boot_images_ignores_unknown_image_properties(self):
+        nodegroup = NodeGroup.objects.ensure_master()
         image = make_boot_image_params()
         image['nonesuch'] = factory.make_name('nonesuch'),
-        client = make_worker_client(NodeGroup.objects.ensure_master())
-        response = self.report_images([image], client=client)
+        client = make_worker_client(nodegroup)
+        response = self.report_images(nodegroup, [image], client=client)
         self.assertEqual(
             (httplib.OK, "OK"),
             (response.status_code, response.content))
 
     def test_report_boot_images_warns_if_no_images_found(self):
-        recorder = self.patch(api, 'register_persistent_error')
-        client = make_worker_client(NodeGroup.objects.ensure_master())
-
-        response = self.report_images([], client=client)
-        self.assertEqual(
-            (httplib.OK, "OK"),
-            (response.status_code, response.content))
-
-        self.assertIn(
-            COMPONENT.IMPORT_PXE_FILES,
-            [args[0][0] for args in recorder.call_args_list])
+        nodegroup = NodeGroup.objects.ensure_master()
+        factory.make_node_group()  # Second nodegroup with no images.
+        recorder = self.patch(api, 'register_persistent_error')
+        client = make_worker_client(nodegroup)
+        response = self.report_images(nodegroup, [], client=client)
+        self.assertEqual(
+            (httplib.OK, "OK"),
+            (response.status_code, response.content))
+
+        self.assertIn(
+            COMPONENT.IMPORT_PXE_FILES,
+            [args[0][0] for args in recorder.call_args_list])
+        # Check that the persistent error message contains a link to the
+        # clusters listing.
+        self.assertIn(
+            "/settings/#accepted-clusters", recorder.call_args_list[0][0][1])
+
+    def test_report_boot_images_warns_if_any_nodegroup_has_no_images(self):
+        nodegroup = NodeGroup.objects.ensure_master()
+        # Second nodegroup with no images.
+        factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED)
+        recorder = self.patch(api, 'register_persistent_error')
+        client = make_worker_client(nodegroup)
+        image = make_boot_image_params()
+        response = self.report_images(nodegroup, [image], client=client)
+        self.assertEqual(
+            (httplib.OK, "OK"),
+            (response.status_code, response.content))
+
+        self.assertIn(
+            COMPONENT.IMPORT_PXE_FILES,
+            [args[0][0] for args in recorder.call_args_list])
+
+    def test_report_boot_images_ignores_non_accepted_groups(self):
+        nodegroup = factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED)
+        factory.make_node_group(status=NODEGROUP_STATUS.PENDING)
+        factory.make_node_group(status=NODEGROUP_STATUS.REJECTED)
+        recorder = self.patch(api, 'register_persistent_error')
+        client = make_worker_client(nodegroup)
+        image = make_boot_image_params()
+        response = self.report_images(nodegroup, [image], client=client)
+        self.assertEqual(0, recorder.call_count)
 
     def test_report_boot_images_removes_warning_if_images_found(self):
         self.patch(api, 'register_persistent_error')
         self.patch(api, 'discard_persistent_error')
-        client = make_worker_client(NodeGroup.objects.ensure_master())
+        nodegroup = factory.make_node_group()
+        image = make_boot_image_params()
+        client = make_worker_client(nodegroup)
 
-        response = self.report_images(
-            [make_boot_image_params()], client=client)
+        response = self.report_images(nodegroup, [image], client=client)
         self.assertEqual(
             (httplib.OK, "OK"),
             (response.status_code, response.content))

=== modified file 'src/maasserver/tests/test_bootimage.py'
--- src/maasserver/tests/test_bootimage.py	2012-09-14 14:16:01 +0000
+++ src/maasserver/tests/test_bootimage.py	2012-10-30 10:29:27 +0000
@@ -12,7 +12,10 @@
 __metaclass__ = type
 __all__ = []
 
-from maasserver.models import BootImage
+from maasserver.models import (
+    BootImage,
+    NodeGroup,
+    )
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
 from provisioningserver.testing.boot_images import make_boot_image_params
@@ -20,22 +23,30 @@
 
 class TestBootImageManager(TestCase):
 
+    def setUp(self):
+        super(TestBootImageManager, self).setUp()
+        self.nodegroup = NodeGroup.objects.ensure_master()
+
     def test_have_image_returns_False_if_image_not_available(self):
         self.assertFalse(
-            BootImage.objects.have_image(**make_boot_image_params()))
+            BootImage.objects.have_image(
+                self.nodegroup, **make_boot_image_params()))
 
     def test_have_image_returns_True_if_image_available(self):
         params = make_boot_image_params()
-        factory.make_boot_image(**params)
-        self.assertTrue(BootImage.objects.have_image(**params))
+        factory.make_boot_image(nodegroup=self.nodegroup, **params)
+        self.assertTrue(
+            BootImage.objects.have_image(self.nodegroup, **params))
 
     def test_register_image_registers_new_image(self):
         params = make_boot_image_params()
-        BootImage.objects.register_image(**params)
-        self.assertTrue(BootImage.objects.have_image(**params))
+        BootImage.objects.register_image(self.nodegroup, **params)
+        self.assertTrue(
+            BootImage.objects.have_image(self.nodegroup, **params))
 
     def test_register_image_leaves_existing_image_intact(self):
         params = make_boot_image_params()
-        factory.make_boot_image(**params)
-        BootImage.objects.register_image(**params)
-        self.assertTrue(BootImage.objects.have_image(**params))
+        factory.make_boot_image(nodegroup=self.nodegroup, **params)
+        BootImage.objects.register_image(self.nodegroup, **params)
+        self.assertTrue(
+            BootImage.objects.have_image(self.nodegroup, **params))

=== modified file 'src/maasserver/tests/test_config.py'
--- src/maasserver/tests/test_config.py	2012-05-11 07:51:59 +0000
+++ src/maasserver/tests/test_config.py	2012-10-30 10:29:27 +0000
@@ -16,10 +16,8 @@
 
 from fixtures import TestWithFixtures
 from maasserver.models import Config
-from maasserver.models.config import (
-    DEFAULT_CONFIG,
-    get_default_config,
-    )
+import maasserver.models.config
+from maasserver.models.config import get_default_config
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
 
@@ -31,6 +29,14 @@
         default_config = get_default_config()
         self.assertEqual(gethostname(), default_config['maas_name'])
 
+    def test_defaults(self):
+        expected = get_default_config()
+        observed = {
+            name: Config.objects.get_config(name)
+            for name in expected
+            }
+        self.assertEqual(expected, observed)
+
 
 class CallRecorder:
     """A utility class which tracks the calls to its 'call' method and
@@ -63,13 +69,15 @@
     def test_manager_get_config_not_found_in_default_config(self):
         name = factory.getRandomString()
         value = factory.getRandomString()
-        DEFAULT_CONFIG[name] = value
+        self.patch(maasserver.models.config, "DEFAULT_CONFIG", {name: value})
         config = Config.objects.get_config(name, None)
         self.assertEqual(value, config)
 
     def test_default_config_cannot_be_changed(self):
         name = factory.getRandomString()
-        DEFAULT_CONFIG[name] = {'key': 'value'}
+        self.patch(
+            maasserver.models.config, "DEFAULT_CONFIG",
+            {name: {'key': 'value'}})
         config = Config.objects.get_config(name)
         config.update({'key2': 'value2'})
 

=== modified file 'src/maasserver/tests/test_preseed.py'
--- src/maasserver/tests/test_preseed.py	2012-10-05 04:21:12 +0000
+++ src/maasserver/tests/test_preseed.py	2012-10-30 10:29:27 +0000
@@ -330,10 +330,10 @@
         )
 
     def test_squashfs_available(self):
-        BootImage.objects.register_image(
-            self.arch, self.subarch, self.series, self.purpose)
         node = factory.make_node(
             architecture="i386/generic", distro_series="quantal")
+        BootImage.objects.register_image(
+            node.nodegroup, self.arch, self.subarch, self.series, self.purpose)
         self.assertEqual(self.present, is_squashfs_image_present(node))
 
 

=== modified file 'src/maasserver/tests/test_start_up.py'
--- src/maasserver/tests/test_start_up.py	2012-09-28 18:42:16 +0000
+++ src/maasserver/tests/test_start_up.py	2012-10-30 10:29:27 +0000
@@ -32,9 +32,9 @@
     NodeGroup,
     )
 from maasserver.testing.factory import factory
+from maasserver.testing.testcase import TestCase
 from maastesting.celery import CeleryFixture
 from maastesting.fakemethod import FakeMethod
-from maastesting.testcase import TestCase
 from mock import Mock
 from provisioningserver import tasks
 from testresources import FixtureResource

=== modified file 'src/maasserver/tests/test_views_settings.py'
--- src/maasserver/tests/test_views_settings.py	2012-10-03 15:48:11 +0000
+++ src/maasserver/tests/test_views_settings.py	2012-10-30 10:29:27 +0000
@@ -82,6 +82,7 @@
         self.patch(settings, "DNS_CONNECT", False)
         new_name = factory.getRandomString()
         new_domain = factory.getRandomString()
+        new_proxy = "http://%s.example.com:1234/"; % factory.getRandomString()
         response = self.client.post(
             reverse('settings'),
             get_prefixed_form_data(
@@ -89,12 +90,16 @@
                 data={
                     'maas_name': new_name,
                     'enlistment_domain': new_domain,
+                    'http_proxy': new_proxy,
                 }))
-
-        self.assertEqual(httplib.FOUND, response.status_code)
-        self.assertEqual(new_name, Config.objects.get_config('maas_name'))
+        self.assertEqual(httplib.FOUND, response.status_code, response.content)
         self.assertEqual(
-            new_domain, Config.objects.get_config('enlistment_domain'))
+            (new_name,
+             new_domain,
+             new_proxy),
+            (Config.objects.get_config('maas_name'),
+             Config.objects.get_config('enlistment_domain'),
+             Config.objects.get_config('http_proxy')))
 
     def test_settings_commissioning_POST(self):
         new_after_commissioning = factory.getRandomEnum(

=== modified file 'src/provisioningserver/boot_images.py'
--- src/provisioningserver/boot_images.py	2012-09-14 11:04:33 +0000
+++ src/provisioningserver/boot_images.py	2012-10-30 10:29:27 +0000
@@ -32,6 +32,7 @@
 from provisioningserver.config import Config
 from provisioningserver.logging import task_logger
 from provisioningserver.pxe import tftppath
+from provisioningserver.start_cluster_controller import get_cluster_uuid
 
 
 def get_cached_knowledge():
@@ -53,7 +54,7 @@
     """Submit images to server."""
     MAASClient(MAASOAuth(*api_credentials), MAASDispatcher(), maas_url).post(
         'api/1.0/boot-images/', 'report_boot_images',
-        images=json.dumps(images))
+        nodegroup=get_cluster_uuid(), images=json.dumps(images))
 
 
 def report_to_server():

=== modified file 'src/provisioningserver/tasks.py'
--- src/provisioningserver/tasks.py	2012-10-11 13:25:43 +0000
+++ src/provisioningserver/tasks.py	2012-10-30 10:29:27 +0000
@@ -23,6 +23,7 @@
     'write_full_dns_config',
     ]
 
+import os
 from subprocess import (
     CalledProcessError,
     check_call,
@@ -347,6 +348,11 @@
 UPDATE_NODE_TAGS_RETRY_DELAY = 2
 
 
+# =====================================================================
+# Tags-related tasks
+# =====================================================================
+
+
 @task(max_retries=UPDATE_NODE_TAGS_MAX_RETRY)
 def update_node_tags(tag_name, tag_definition, retry=True):
     """Update the nodes for a new/changed tag definition.
@@ -363,3 +369,16 @@
                 exc=exc, countdown=UPDATE_NODE_TAGS_RETRY_DELAY)
         else:
             raise
+
+
+# =====================================================================
+# Image importing-related tasks
+# =====================================================================
+
+@task
+def import_pxe_files(http_proxy=None):
+    env = dict(os.environ)
+    if http_proxy is not None:
+        env['http_proxy'] = http_proxy
+        env['https_proxy'] = http_proxy
+    check_call(['maas-import-pxe-files'], env=env)

=== modified file 'src/provisioningserver/testing/boot_images.py'
--- src/provisioningserver/testing/boot_images.py	2012-09-14 14:16:01 +0000
+++ src/provisioningserver/testing/boot_images.py	2012-10-30 10:29:27 +0000
@@ -17,7 +17,7 @@
 from maastesting.factory import factory
 
 
-def make_boot_image_params(**kwargs):
+def make_boot_image_params():
     """Create an arbitrary dict of boot-image parameters.
 
     These are the parameters that together describe a kind of boot that we
@@ -25,10 +25,8 @@
     Ubuntu release, and boot purpose.  See the `tftppath` module for how
     these fit together.
     """
-    fields = dict(
+    return dict(
         architecture=factory.make_name('architecture'),
         subarchitecture=factory.make_name('subarchitecture'),
         release=factory.make_name('release'),
         purpose=factory.make_name('purpose'))
-    fields.update(kwargs)
-    return fields

=== modified file 'src/provisioningserver/tests/test_boot_images.py'
--- src/provisioningserver/tests/test_boot_images.py	2012-10-04 05:49:09 +0000
+++ src/provisioningserver/tests/test_boot_images.py	2012-10-30 10:29:27 +0000
@@ -15,7 +15,10 @@
 import json
 
 from apiclient.maas_client import MAASClient
-from mock import Mock
+from mock import (
+    Mock,
+    sentinel,
+    )
 from provisioningserver import boot_images
 from provisioningserver.pxe import tftppath
 from provisioningserver.testing.boot_images import make_boot_image_params
@@ -34,11 +37,12 @@
         self.set_api_credentials()
         image = make_boot_image_params()
         self.patch(tftppath, 'list_boot_images', Mock(return_value=[image]))
+        get_cluster_uuid = self.patch(boot_images, "get_cluster_uuid")
+        get_cluster_uuid.return_value = sentinel.uuid
         self.patch(MAASClient, 'post')
-
         boot_images.report_to_server()
-
         args, kwargs = MAASClient.post.call_args
+        self.assertIs(sentinel.uuid, kwargs["nodegroup"])
         self.assertItemsEqual([image], json.loads(kwargs['images']))
 
     def test_does_nothing_without_maas_url(self):

=== modified file 'src/provisioningserver/tests/test_tasks.py'
--- src/provisioningserver/tests/test_tasks.py	2012-10-08 08:24:11 +0000
+++ src/provisioningserver/tests/test_tasks.py	2012-10-30 10:29:27 +0000
@@ -25,6 +25,7 @@
 from apiclient.maas_client import MAASClient
 from apiclient.testing.credentials import make_api_credentials
 from celery.app import app_or_default
+from celery.task import Task
 from maastesting.celery import CeleryFixture
 from maastesting.factory import factory
 from maastesting.fakemethod import (
@@ -32,7 +33,10 @@
     MultiFakeMethod,
     )
 from maastesting.matchers import ContainsAll
-from mock import Mock
+from mock import (
+    ANY,
+    Mock,
+    )
 from netaddr import IPNetwork
 from provisioningserver import (
     auth,
@@ -59,6 +63,7 @@
 from provisioningserver.tags import MissingCredentials
 from provisioningserver.tasks import (
     add_new_dhcp_host_map,
+    import_pxe_files,
     Omshell,
     power_off,
     power_on,
@@ -534,3 +539,26 @@
         self.assertRaises(
             MissingCredentials, update_node_tags.delay, tag,
             '//node', retry=True)
+
+
+class TestImportPxeFiles(PservTestCase):
+
+    def test_import_pxe_files(self):
+        recorder = self.patch(tasks, 'check_call', Mock())
+        import_pxe_files()
+        recorder.assert_called_once_with(['maas-import-pxe-files'], env=ANY)
+        self.assertIsInstance(import_pxe_files, Task)
+
+    def test_import_pxe_files_preserves_environment(self):
+        recorder = self.patch(tasks, 'check_call', Mock())
+        import_pxe_files()
+        recorder.assert_called_once_with(
+            ['maas-import-pxe-files'], env=os.environ)
+
+    def test_import_pxe_files_sets_proxy(self):
+        recorder = self.patch(tasks, 'check_call', Mock())
+        proxy = factory.getRandomString()
+        import_pxe_files(http_proxy=proxy)
+        expected_env = dict(os.environ, http_proxy=proxy, https_proxy=proxy)
+        recorder.assert_called_once_with(
+            ['maas-import-pxe-files'], env=expected_env)

=== modified file 'src/provisioningserver/tests/test_tftp.py'
--- src/provisioningserver/tests/test_tftp.py	2012-10-02 10:53:22 +0000
+++ src/provisioningserver/tests/test_tftp.py	2012-10-30 10:29:27 +0000
@@ -35,6 +35,7 @@
     inlineCallbacks,
     succeed,
     )
+from twisted.python import context
 from zope.interface.verify import verifyObject
 
 
@@ -213,6 +214,16 @@
         mac = factory.getRandomMACAddress(b"-")
         config_path = compose_config_path(mac)
         backend = TFTPBackend(self.make_dir(), b"http://example.com/";)
+        # python-tx-tftp sets up call context so that backends can discover
+        # more about the environment in which they're running.
+        call_context = {
+            "local": (
+                factory.getRandomIPAddress(),
+                factory.getRandomPort()),
+            "remote": (
+                factory.getRandomIPAddress(),
+                factory.getRandomPort()),
+            }
 
         @partial(self.patch, backend, "get_config_reader")
         def get_config_reader(params):
@@ -220,9 +231,16 @@
             params_json_reader = BytesReader(params_json)
             return succeed(params_json_reader)
 
-        reader = yield backend.get_reader(config_path)
+        reader = yield context.call(
+            call_context, backend.get_reader, config_path)
         output = reader.read(10000)
-        expected_params = dict(mac=mac)
+        # The addresses provided by python-tx-tftp in the call context are
+        # passed over the wire as address:port strings.
+        expected_params = {
+            "mac": mac,
+            "local": call_context["local"][0],  # address only.
+            "remote": call_context["remote"][0],  # address only.
+            }
         observed_params = json.loads(output)
         self.assertEqual(expected_params, observed_params)
 

=== modified file 'src/provisioningserver/tftp.py'
--- src/provisioningserver/tftp.py	2012-10-03 08:50:17 +0000
+++ src/provisioningserver/tftp.py	2012-10-30 10:29:27 +0000
@@ -34,6 +34,7 @@
     IReader,
     )
 from tftp.errors import FileNotFound
+from twisted.python.context import get
 from twisted.web.client import getPage
 import twisted.web.error
 from zope.interface import implementer
@@ -211,6 +212,11 @@
                 for key, value in config_file_match.groupdict().items()
                 if value is not None
                 }
+            # Send the local and remote endpoint addresses.
+            local_host, local_port = get("local", (None, None))
+            params["local"] = local_host
+            remote_host, remote_port = get("remote", (None, None))
+            params["remote"] = remote_host
             d = self.get_config_reader(params)
             d.addErrback(self.get_page_errback, file_name)
             return d


Follow ups