← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/create-nodegroup-with-optional-dhcp-config into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/create-nodegroup-with-optional-dhcp-config into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/create-nodegroup-with-optional-dhcp-config/+merge/115311

I ran most of the decisions made here by Julian as a sanity check.  It's all in support of a maas command that will configure (and if need be, create) the master node group.

Most of the fields in NodeGroup are currently DHCP settings, and those need to be optional because we may not be controlling DHCP.  It gets a little bit ugly in that it only makes sense to have all of these set, or none of them set but there's no pre-built mechanism for ensuring coherent settings.

I also had quite a fight with South trying to make these fields nullable.  The migration bailed out with a long incomprehensible request about needing a default when a field becomes nullable, even if you've already defined a default.  An offhand adjective in the very last line (not of the request itself where it would have made some sense, but in the last of the options presented) reveals this to be about reverse migrations: what value should South give a field when it finds a null while migrating from the newer schema to an older version, where the field can't be null?  I opted to disallow the reverse migration.

Along the way I had to create a dedicated system user to represent the node-group workers as they access the API.  As we did with the metadata server, I plan to create one system user which may have multiple tokens identifying different workers.  For now, each worker gets its own token.  We discussed refreshing tokens regularly and passing them to the workers all the time, but that's for later.

A complication there was that a user's email cannot be null.  What we did with the previous system user we created was leave the email field blank.  But that won't work here because the field is required to be unique as well — wouldn't have been a problem if the field could be null!  In the end I made up a local email address.  I don't think it ever gets used.  If it did, we'd probably have seen an error from the existing system user with the blank email.  We ought to consider giving that one a made-up email address too.

A later branch will add a maas command to create the initial nodegroup if it does not already exist, and configure it with current DHCP settings.  The packaging can then call this with configuration values obtained from debconf.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/create-nodegroup-with-optional-dhcp-config/+merge/115311
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/create-nodegroup-with-optional-dhcp-config into lp:maas.
=== added file 'src/maasserver/fixtures/initial_data.yaml'
--- src/maasserver/fixtures/initial_data.yaml	1970-01-01 00:00:00 +0000
+++ src/maasserver/fixtures/initial_data.yaml	2012-07-17 10:48:28 +0000
@@ -0,0 +1,13 @@
+- model: auth.user
+  pk: 2
+  fields:
+    username: 'maas-nodegroup-worker'
+    first_name: 'Node-group worker'
+    last_name: 'Special user'
+    email: 'maas-nodegroup-worker@localhost'
+    password: '!'
+    is_staff: false
+    is_active: false
+    is_superuser: false
+    last_login: 2012-07-17
+    date_joined: 2012-07-17

=== added file 'src/maasserver/migrations/0014_nodegroup_dhcp_settings_are_optional.py'
--- src/maasserver/migrations/0014_nodegroup_dhcp_settings_are_optional.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/0014_nodegroup_dhcp_settings_are_optional.py	2012-07-17 10:48:28 +0000
@@ -0,0 +1,183 @@
+# encoding: utf-8
+import datetime
+
+from django.db import models
+from south.db import db
+from south.v2 import SchemaMigration
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Changing field 'NodeGroup.ip_range_high'
+        db.alter_column(u'maasserver_nodegroup', 'ip_range_high', self.gf('django.db.models.fields.IPAddressField')(max_length=15, unique=True, null=True))
+
+        # Changing field 'NodeGroup.broadcast_ip'
+        db.alter_column(u'maasserver_nodegroup', 'broadcast_ip', self.gf('django.db.models.fields.IPAddressField')(max_length=15, null=True))
+
+        # Changing field 'NodeGroup.ip_range_low'
+        db.alter_column(u'maasserver_nodegroup', 'ip_range_low', self.gf('django.db.models.fields.IPAddressField')(max_length=15, unique=True, null=True))
+
+        # Changing field 'NodeGroup.subnet_mask'
+        db.alter_column(u'maasserver_nodegroup', 'subnet_mask', self.gf('django.db.models.fields.IPAddressField')(max_length=15, null=True))
+
+        # Changing field 'NodeGroup.router_ip'
+        db.alter_column(u'maasserver_nodegroup', 'router_ip', self.gf('django.db.models.fields.IPAddressField')(max_length=15, null=True))
+
+
+    def backwards(self, orm):
+        
+        # User chose to not deal with backwards NULL issues for 'NodeGroup.ip_range_high'
+        raise RuntimeError("Cannot reverse this migration. 'NodeGroup.ip_range_high' and its values cannot be restored.")
+
+        # User chose to not deal with backwards NULL issues for 'NodeGroup.broadcast_ip'
+        raise RuntimeError("Cannot reverse this migration. 'NodeGroup.broadcast_ip' and its values cannot be restored.")
+
+        # User chose to not deal with backwards NULL issues for 'NodeGroup.ip_range_low'
+        raise RuntimeError("Cannot reverse this migration. 'NodeGroup.ip_range_low' and its values cannot be restored.")
+
+        # User chose to not deal with backwards NULL issues for 'NodeGroup.subnet_mask'
+        raise RuntimeError("Cannot reverse this migration. 'NodeGroup.subnet_mask' and its values cannot be restored.")
+
+        # User chose to not deal with backwards NULL issues for 'NodeGroup.router_ip'
+        raise RuntimeError("Cannot reverse this migration. 'NodeGroup.router_ip' and its values cannot be restored.")
+
+
+    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.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'", 'max_length': '10'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['maasserver.NodeGroup']", 'null': 'True', 'blank': '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-07cd231a-cff4-11e1-bbd4-002608dc6120'", 'unique': 'True', 'max_length': '41'}),
+            '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'}),
+            'broadcast_ip': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ip_range_high': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+            'ip_range_low': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'router_ip': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+            'subnet_mask': ('django.db.models.fields.IPAddressField', [], {'default': "u''", 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+            'updated': ('django.db.models.fields.DateTimeField', [], {}),
+            'worker_ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'})
+        },
+        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.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': '1342518254L'}),
+            '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/nodegroup.py'
--- src/maasserver/models/nodegroup.py	2012-07-11 10:53:04 +0000
+++ src/maasserver/models/nodegroup.py	2012-07-17 10:48:28 +0000
@@ -29,6 +29,9 @@
     )
 
 
+worker_user_name = 'maas-nodegroup-worker'
+
+
 class NodeGroupManager(Manager):
     """Manager for the NodeGroup class.
 
@@ -36,6 +39,36 @@
     the model class it manages.
     """
 
+    def new(self, name, worker_ip, subnet_mask=None, broadcast_ip=None,
+            router_ip=None, ip_range_low=None, ip_range_high=None):
+        """Create a :class:`NodeGroup` with the given parameters.
+
+        This method will generate API credentials for the nodegroup's
+        worker to use.
+        """
+        # Avoid circular imports.
+        from maasserver.models.user import create_auth_token
+        from maasserver.worker_user import get_worker_user
+
+        dhcp_values = [
+            subnet_mask,
+            broadcast_ip,
+            router_ip,
+            ip_range_low,
+            ip_range_high,
+            ]
+        assert all(dhcp_values) or not any(dhcp_values), (
+            "Provide all DHCP settings, or none at all.")
+
+        api_token = create_auth_token(get_worker_user())
+        nodegroup = NodeGroup(
+            name=name, worker_ip=worker_ip, subnet_mask=subnet_mask,
+            broadcast_ip=broadcast_ip, router_ip=router_ip,
+            ip_range_low=ip_range_low, ip_range_high=ip_range_high,
+            api_token=api_token, api_key=api_token.key)
+        nodegroup.save()
+        return nodegroup
+
 
 class NodeGroup(TimestampedModel):
 
@@ -48,17 +81,23 @@
     name = CharField(
         max_length=80, unique=True, editable=True, blank=False, null=False)
 
+    # Credentials for the worker to access the API with.
     api_token = ForeignKey(Token, null=False, editable=False, unique=True)
     api_key = CharField(
-        max_length=KEY_SIZE, null=False, editable=False, unique=True)
+        max_length=KEY_SIZE, null=False, blank=False, editable=False,
+        unique=True)
 
+    # Address of the worker.
     worker_ip = IPAddressField(null=False, editable=True, unique=True)
 
-    subnet_mask = IPAddressField(null=False, editable=True, unique=False)
-
-    broadcast_ip = IPAddressField(null=False, editable=True, unique=False)
-
-    router_ip = IPAddressField(null=False, editable=True, unique=False)
-
-    ip_range_low = IPAddressField(null=False, editable=True, unique=True)
-    ip_range_high = IPAddressField(null=False, editable=True, unique=True)
+    # DHCP server settings.
+    subnet_mask = IPAddressField(
+        editable=True, unique=False, blank=True, null=True, default='')
+    broadcast_ip = IPAddressField(
+        editable=True, unique=False, blank=True, null=True, default='')
+    router_ip = IPAddressField(
+        editable=True, unique=False, blank=True, null=True, default='')
+    ip_range_low = IPAddressField(
+        editable=True, unique=True, blank=True, null=True, default='')
+    ip_range_high = IPAddressField(
+        editable=True, unique=True, blank=True, null=True, default='')

=== modified file 'src/maasserver/models/user.py'
--- src/maasserver/models/user.py	2012-06-14 06:53:15 +0000
+++ src/maasserver/models/user.py	2012-07-17 10:48:28 +0000
@@ -16,6 +16,7 @@
     'get_auth_tokens',
     ]
 
+from maasserver import worker_user
 from metadataserver import nodeinituser
 from piston.models import (
     Consumer,
@@ -26,6 +27,8 @@
 SYSTEM_USERS = [
     # For nodes' access to the metadata API:
     nodeinituser.user_name,
+    # For node-group's workers to the MAAS API:
+    worker_user.user_name,
     ]
 
 GENERIC_CONSUMER = 'MAAS consumer'

=== modified file 'src/maasserver/tests/test_nodegroup.py'
--- src/maasserver/tests/test_nodegroup.py	2012-06-05 08:08:15 +0000
+++ src/maasserver/tests/test_nodegroup.py	2012-07-17 10:48:28 +0000
@@ -12,27 +12,60 @@
 __metaclass__ = type
 __all__ = []
 
+from maasserver.models import NodeGroup
+from maasserver.testing import reload_object
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
+from maasserver.worker_user import get_worker_user
 from testtools.matchers import MatchesStructure
 
 
-class TestNodeGroup(TestCase):
-
-    def test_model_stores_to_database(self):
-        ng = factory.make_node_group(
-            name=factory.getRandomString(80),
-            worker_ip="10.1.1.1",
-            subnet_mask="255.0.0.0",
-            broadcast_ip="10.255.255.255",
-            router_ip="10.1.1.254",
-            ip_range_low="10.1.1.2",
-            ip_range_high="10.254.254.253")
+def make_dhcp_settings():
+    """Create a dict of arbitrary nodegroup configuration parameters."""
+    return {
+        'subnet_mask': '255.0.0.0',
+        'broadcast_ip': '10.255.255.255',
+        'router_ip': factory.getRandomIPAddress(),
+        'ip_range_low': '10.0.0.1',
+        'ip_range_high': '10.254.254.254',
+    }
+
+
+class TestNodeGroupManager(TestCase):
+
+    def test_new_does_not_require_dhcp_settings(self):
+        name = factory.make_name('nodegroup')
+        ip = factory.getRandomIPAddress()
+        nodegroup = NodeGroup.objects.new(name, ip)
+        self.assertEqual(name, nodegroup.name)
+        self.assertEqual(ip, nodegroup.worker_ip)
+        self.assertIsNone(nodegroup.subnet_mask)
+        self.assertIsNone(nodegroup.broadcast_ip)
+        self.assertIsNone(nodegroup.router_ip)
+        self.assertIsNone(nodegroup.ip_range_low)
+        self.assertIsNone(nodegroup.ip_range_high)
+
+    def test_new_requires_all_dhcp_settings_or_none(self):
+        name = factory.make_name('nodegroup')
+        ip = factory.getRandomIPAddress()
+        self.assertRaises(
+            AssertionError,
+            NodeGroup.objects.new, name, ip, subnet_mask='255.0.0.0')
+
+    def test_new_creates_nodegroup_with_given_dhcp_settings(self):
+        name = factory.make_name('nodegroup')
+        ip = factory.getRandomIPAddress()
+        dhcp_settings = make_dhcp_settings()
+        nodegroup = NodeGroup.objects.new(name, ip, **dhcp_settings)
+        nodegroup = reload_object(nodegroup)
+        self.assertEqual(name, nodegroup.name)
         self.assertThat(
-            ng, MatchesStructure.byEquality(
-                worker_ip="10.1.1.1",
-                subnet_mask="255.0.0.0",
-                broadcast_ip="10.255.255.255",
-                router_ip="10.1.1.254",
-                ip_range_low="10.1.1.2",
-                ip_range_high="10.254.254.253"))
+            nodegroup, MatchesStructure.fromExample(dhcp_settings))
+
+    def test_new_assigns_token_and_key_for_worker_user(self):
+        nodegroup = NodeGroup.objects.new(
+            factory.make_name('nodegroup'), factory.getRandomIPAddress())
+        self.assertIsNotNone(nodegroup.api_token)
+        self.assertIsNotNone(nodegroup.api_key)
+        self.assertEqual(get_worker_user(), nodegroup.api_token.user)
+        self.assertEqual(nodegroup.api_key, nodegroup.api_token.key)

=== added file 'src/maasserver/tests/test_worker_user.py'
--- src/maasserver/tests/test_worker_user.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_worker_user.py	2012-07-17 10:48:28 +0000
@@ -0,0 +1,37 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test for the system user that represents node-group workers."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from django.contrib.auth.models import User
+from maasserver.models import UserProfile
+from maasserver.testing.testcase import TestCase
+from maasserver.worker_user import (
+    get_worker_user,
+    user_name,
+    )
+
+
+class TestNodeGroupUser(TestCase):
+    """Test the special "user" that celery workers use to access the API."""
+
+    def test_get_worker_user_always_returns_same_user(self):
+        self.assertEqual(get_worker_user().id, get_worker_user().id)
+
+    def test_get_worker_user_holds_the_worker_user(self):
+        worker_user = get_worker_user()
+        self.assertIsInstance(worker_user, User)
+        self.assertEqual(user_name, worker_user.username)
+
+    def test_worker_user_has_no_profile(self):
+        worker_user = get_worker_user()
+        self.assertRaises(UserProfile.DoesNotExist, worker_user.get_profile)

=== added file 'src/maasserver/worker_user.py'
--- src/maasserver/worker_user.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/worker_user.py	2012-07-17 10:48:28 +0000
@@ -0,0 +1,37 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""System user representing node-group workers.
+
+The Celery workers access the MAAS API under this user identity.
+"""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'get_worker_user',
+    'user_name',
+    ]
+
+from django.contrib.auth.models import User
+
+
+user_name = 'maas-nodegroup-worker'
+
+
+# Cached, shared reference to this special user.  Keep internal to this
+# module.
+worker_user = None
+
+
+def get_worker_user():
+    """Get the system user representing the node-group workers."""
+    global worker_user
+    if worker_user is None:
+        worker_user = User.objects.get(username=user_name)
+    return worker_user

=== modified file 'src/metadataserver/nodeinituser.py'
--- src/metadataserver/nodeinituser.py	2012-04-16 10:00:51 +0000
+++ src/metadataserver/nodeinituser.py	2012-07-17 10:48:28 +0000
@@ -12,6 +12,7 @@
 __metaclass__ = type
 __all__ = [
     'get_node_init_user',
+    'user_name',
     ]
 
 from django.contrib.auth.models import User
@@ -20,6 +21,8 @@
 user_name = 'maas-init-node'
 
 
+# Cached, shared reference to this special user.  Keep internal to this
+# module.
 node_init_user = None