← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jameinel/maas/merge-1.2 into lp:maas

 

John A Meinel has proposed merging lp:~jameinel/maas/merge-1.2 into lp:maas.

Commit message:
Merge the lp:maas/1.2 branch into lp:maas and resolve conflicts.

There were a few text conflicts introduced because of the cherrypicks back to 1.2, but this should make it easier to keep 1.2 changes in sync with trunk.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jameinel/maas/merge-1.2/+merge/133418

This gets 1.2 synced into trunk, including our recent changes for kernel options. Probably the only thing that concerns me is the db migration number gets reused (0043). I did test upgrading 1.2 => trunk, which succeeds, and None => trunk which also works fine. No tests fail, so I'm pretty comfortable that everything is ok.

If we do decide to revert the 1.2 changes to keep a simple bugfix-only SRU, then we can probably just remove the kernel_opts patch entirely, and regenerate it later.

After this lands, it should be easy to cherry pick something from trunk back into 1.2, and then merge 1.2 up to trunk. You'll want to do that quickly, because once trunk changes the cherrypick again, you'll end up getting a merge conflict if you haven't merged up already. With a merge-up, things will stay flowing smoothly.
-- 
https://code.launchpad.net/~jameinel/maas/merge-1.2/+merge/133418
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jameinel/maas/merge-1.2 into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-11-07 17:08:42 +0000
+++ src/maasserver/api.py	2012-11-08 08:07:22 +0000
@@ -748,6 +748,13 @@
         The minimum data required is:
         architecture=<arch string> (e.g "i386/generic")
         mac_address=<value>
+
+        :param architecture: A string containing the architecture type of
+            the node.
+        :param mac_address: The MAC address of the node.
+        :param hostname: A hostname. If not given, one will be generated.
+        :param powertype: A power management type, if applicable (e.g.
+            "virsh", "ipmi").
         """
         node = create_node(request)
         if request.user.is_superuser:
@@ -1527,6 +1534,7 @@
         'name',
         'definition',
         'comment',
+        'kernel_opts',
         )
 
     def read(self, request, name):
@@ -1658,6 +1666,11 @@
             It is meant as a human readable description of the tag.
         :param definition: An XPATH query that will be evaluated against the
             hardware_details stored for all nodes (output of `lshw -xml`).
+        :param kernel_opts: Can be None. If set, nodes associated with this tag
+            will add this string to their kernel options when booting. The
+            value overrides the global 'kernel_opts' setting. If more than one
+            tag is associated with a node, the one with the lowest alphabetical
+            name will be picked (eg 01-my-tag will be taken over 99-tag-name).
         """
         if not request.user.is_superuser:
             raise PermissionDenied()
@@ -1892,6 +1905,13 @@
     else:
         series = node.get_distro_series()
 
+    if node is not None:
+        # We don't care if the kernel opts is from the global setting or a tag,
+        # just get the options
+        _, extra_kernel_opts = node.get_effective_kernel_options()
+    else:
+        extra_kernel_opts = None
+
     purpose = get_boot_purpose(node)
     server_address = get_maas_facing_server_address()
     cluster_address = get_mandatory_param(request.GET, "local")
@@ -1899,7 +1919,8 @@
     params = KernelParameters(
         arch=arch, subarch=subarch, release=series, purpose=purpose,
         hostname=hostname, domain=domain, preseed_url=preseed_url,
-        log_host=server_address, fs_host=cluster_address)
+        log_host=server_address, fs_host=cluster_address,
+        extra_opts=extra_kernel_opts)
 
     return HttpResponse(
         json.dumps(params._asdict()),

=== modified file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	2012-11-01 15:13:10 +0000
+++ src/maasserver/forms.py	2012-11-08 08:07:22 +0000
@@ -855,6 +855,7 @@
             'name',
             'comment',
             'definition',
+            'kernel_opts',
             )
 
     def clean_definition(self):

=== added file 'src/maasserver/migrations/0043_add_tag_kernel_opts.py'
--- src/maasserver/migrations/0043_add_tag_kernel_opts.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/0043_add_tag_kernel_opts.py	2012-11-08 08:07:22 +0000
@@ -0,0 +1,203 @@
+# -*- 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):
+        # Adding field 'Tag.kernel_opts'
+        db.add_column(u'maasserver_tag', 'kernel_opts',
+                      self.gf('django.db.models.fields.TextField')(null=True, blank=True),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'Tag.kernel_opts'
+        db.delete_column(u'maasserver_tag', 'kernel_opts')
+
+
+    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'},
+            'content': ('metadataserver.fields.BinaryField', [], {}),
+            'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            '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-8b729164-2805-11e2-bc60-fa163e384ad1'", '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'}),
+            'kernel_opts': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': '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': '1352201505L'}),
+            '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/node.py'
--- src/maasserver/models/node.py	2012-11-05 08:23:36 +0000
+++ src/maasserver/models/node.py	2012-11-08 08:07:22 +0000
@@ -73,7 +73,10 @@
     get_db_state,
     strip_domain,
     )
-from maasserver.utils.orm import get_first
+from maasserver.utils.orm import (
+    get_first,
+    get_one,
+    )
 from piston.models import Token
 from provisioningserver.enum import (
     POWER_TYPE,
@@ -722,6 +725,26 @@
         else:
             return None
 
+    def get_effective_kernel_options(self):
+        """Determine any special kernel parameters for this node.
+
+        :return: (tag, kernel_options)
+            tag is a Tag object or None. If None, the kernel_options came from
+            the global setting.
+            kernel_options, a string indicating extra kernel_options that
+            should be used when booting this node. May be None if no tags match
+            and no global setting has been configured.
+        """
+        # First, see if there are any tags associated with this node that has a
+        # custom kernel parameter
+        tags = self.tags.filter(kernel_opts__isnull=False)
+        tags = tags.order_by('name')[:1]
+        tag = get_one(tags)
+        if tag is not None:
+            return tag, tag.kernel_opts
+        global_value = Config.objects.get_config('kernel_opts')
+        return None, global_value
+
     @property
     def work_queue(self):
         """The name of the queue for tasks specific to this node."""

=== modified file 'src/maasserver/models/tag.py'
--- src/maasserver/models/tag.py	2012-10-24 16:07:00 +0000
+++ src/maasserver/models/tag.py	2012-11-08 08:07:22 +0000
@@ -89,6 +89,8 @@
         tag.
     :ivar comment: A long-form description for humans about what this tag is
         trying to accomplish.
+    :ivar kernel_opts: Optional kernel command-line parameters string to be
+        used in the PXE config for nodes with this tags.
     :ivar objects: The :class:`TagManager`.
     """
 
@@ -101,6 +103,7 @@
                      validators=[RegexValidator(_tag_name_regex)])
     definition = TextField(blank=True)
     comment = TextField(blank=True)
+    kernel_opts = TextField(blank=True, null=True)
 
     objects = TagManager()
 

=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py	2012-11-01 15:59:22 +0000
+++ src/maasserver/testing/factory.py	2012-11-08 08:07:22 +0000
@@ -251,14 +251,15 @@
         key.save()
         return key
 
-    def make_tag(self, name=None, definition=None, comment='', created=None,
-                 updated=None):
+    def make_tag(self, name=None, definition=None, comment='',
+                 kernel_opts=None, created=None, updated=None):
         if name is None:
             name = self.make_name('tag')
         if definition is None:
             # Is there a 'node' in this xml?
             definition = '//node'
-        tag = Tag(name=name, definition=definition, comment=comment)
+        tag = Tag(name=name, definition=definition, comment=comment,
+            kernel_opts=kernel_opts)
         self._save_node_unchecked(tag)
         # Update the 'updated'/'created' fields with a call to 'update'
         # preventing a call to save() from overriding the values.

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-11-07 17:08:42 +0000
+++ src/maasserver/tests/test_api.py	2012-11-08 08:07:22 +0000
@@ -3137,6 +3137,30 @@
             % (invalid,))
         self.assertFalse(Tag.objects.filter(name=invalid).exists())
 
+    def test_POST_new_kernel_opts(self):
+        self.become_admin()
+        name = factory.getRandomString()
+        definition = '//node'
+        comment = factory.getRandomString()
+        extra_kernel_opts = factory.getRandomString()
+        response = self.client.post(
+            self.get_uri('tags/'),
+            {
+                'op': 'new',
+                'name': name,
+                'comment': comment,
+                'definition': definition,
+                'kernel_opts': extra_kernel_opts,
+            })
+        self.assertEqual(httplib.OK, response.status_code)
+        parsed_result = json.loads(response.content)
+        self.assertEqual(name, parsed_result['name'])
+        self.assertEqual(comment, parsed_result['comment'])
+        self.assertEqual(definition, parsed_result['definition'])
+        self.assertEqual(extra_kernel_opts, parsed_result['kernel_opts'])
+        self.assertEqual(
+            extra_kernel_opts, Tag.objects.filter(name=name)[0].kernel_opts)
+
     def test_POST_new_populates_nodes(self):
         self.become_admin()
         node1 = factory.make_node()
@@ -3500,6 +3524,23 @@
         kernel_params = KernelParameters(**self.get_pxeconfig(params))
         self.assertEqual(params["local"], kernel_params.fs_host)
 
+    def test_pxeconfig_returns_extra_kernel_options(self):
+        node = factory.make_node()
+        extra_kernel_opts = factory.getRandomString()
+        Config.objects.set_config('kernel_opts', extra_kernel_opts)
+        mac = factory.make_mac_address(node=node)
+        params = self.get_default_params()
+        params['mac'] = mac.mac_address
+        pxe_config = self.get_pxeconfig(params)
+        self.assertEqual(extra_kernel_opts, pxe_config['extra_opts'])
+
+    def test_pxeconfig_returns_None_for_extra_kernel_opts(self):
+        mac = factory.make_mac_address()
+        params = self.get_default_params()
+        params['mac'] = mac.mac_address
+        pxe_config = self.get_pxeconfig(params)
+        self.assertEqual(None, pxe_config['extra_opts'])
+
 
 class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase):
     scenarios = [

=== modified file 'src/maasserver/tests/test_node.py'
--- src/maasserver/tests/test_node.py	2012-11-05 08:23:36 +0000
+++ src/maasserver/tests/test_node.py	2012-11-08 08:07:22 +0000
@@ -317,6 +317,62 @@
         successful_types = [node_power_types[node] for node in started_nodes]
         self.assertItemsEqual(configless_power_types, successful_types)
 
+    def test_get_effective_kernel_options_with_nothing_set(self):
+        node = factory.make_node()
+        self.assertEqual((None, None), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_sees_global_config(self):
+        node = factory.make_node()
+        kernel_opts = factory.getRandomString()
+        Config.objects.set_config('kernel_opts', kernel_opts)
+        self.assertEqual(
+            (None, kernel_opts), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_not_confused_by_empty_tag(self):
+        node = factory.make_node()
+        tag = factory.make_tag()
+        node.tags.add(tag)
+        kernel_opts = factory.getRandomString()
+        Config.objects.set_config('kernel_opts', kernel_opts)
+        self.assertEqual(
+            (None, kernel_opts), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_ignores_unassociated_tag_value(self):
+        node = factory.make_node()
+        factory.make_tag(kernel_opts=factory.getRandomString())
+        self.assertEqual((None, None), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_uses_tag_value(self):
+        node = factory.make_node()
+        tag = factory.make_tag(kernel_opts=factory.getRandomString())
+        node.tags.add(tag)
+        self.assertEqual(
+            (tag, tag.kernel_opts), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_tag_overrides_global(self):
+        node = factory.make_node()
+        global_opts = factory.getRandomString()
+        Config.objects.set_config('kernel_opts', global_opts)
+        tag = factory.make_tag(kernel_opts=factory.getRandomString())
+        node.tags.add(tag)
+        self.assertEqual(
+            (tag, tag.kernel_opts), node.get_effective_kernel_options())
+
+    def test_get_effective_kernel_options_uses_first_real_tag_value(self):
+        node = factory.make_node()
+        # Intentionally create them in reverse order, so the default 'db' order
+        # doesn't work, and we have asserted that we sort them.
+        tag3 = factory.make_tag(factory.make_name('tag-03-'),
+                                kernel_opts=factory.getRandomString())
+        tag2 = factory.make_tag(factory.make_name('tag-02-'),
+                                kernel_opts=factory.getRandomString())
+        tag1 = factory.make_tag(factory.make_name('tag-01-'), kernel_opts=None)
+        self.assertTrue(tag1.name < tag2.name)
+        self.assertTrue(tag2.name < tag3.name)
+        node.tags.add(tag1, tag2, tag3)
+        self.assertEqual(
+            (tag2, tag2.kernel_opts), node.get_effective_kernel_options())
+
     def test_acquire(self):
         node = factory.make_node(status=NODE_STATUS.READY)
         user = factory.make_user()

=== modified file 'src/maasserver/tests/test_tag.py'
--- src/maasserver/tests/test_tag.py	2012-10-10 09:41:48 +0000
+++ src/maasserver/tests/test_tag.py	2012-11-08 08:07:22 +0000
@@ -29,6 +29,16 @@
         self.assertEqual('tag-name', tag.name)
         self.assertEqual('//node[@id=display]', tag.definition)
         self.assertEqual('', tag.comment)
+        self.assertIs(None, tag.kernel_opts)
+        self.assertIsNot(None, tag.updated)
+        self.assertIsNot(None, tag.created)
+
+    def test_factory_make_tag_with_hardware_details(self):
+        tag = factory.make_tag('a-tag', 'true', kernel_opts="console=ttyS0")
+        self.assertEqual('a-tag', tag.name)
+        self.assertEqual('true', tag.definition)
+        self.assertEqual('', tag.comment)
+        self.assertEqual('console=ttyS0', tag.kernel_opts)
         self.assertIsNot(None, tag.updated)
         self.assertIsNot(None, tag.created)
 

=== modified file 'src/provisioningserver/kernel_opts.py'
--- src/provisioningserver/kernel_opts.py	2012-10-09 15:43:33 +0000
+++ src/provisioningserver/kernel_opts.py	2012-11-08 08:07:22 +0000
@@ -37,6 +37,8 @@
         "preseed_url",  # URL from which a preseed can be obtained.
         "log_host",  # Host/IP to which syslog can be streamed.
         "fs_host",  # Host/IP on which ephemeral filesystems are hosted.
+        "extra_opts",  # String of extra options to supply, will be appended
+                       # verbatim to the kernel command line
         ))
 
 
@@ -176,4 +178,6 @@
     #       as it would be nice to have.
     options += compose_logging_opts(params.log_host)
     options += compose_arch_opts(params)
+    if params.extra_opts:
+        options.append(params.extra_opts)
     return ' '.join(options)

=== modified file 'src/provisioningserver/tests/test_kernel_opts.py'
--- src/provisioningserver/tests/test_kernel_opts.py	2012-10-09 15:39:54 +0000
+++ src/provisioningserver/tests/test_kernel_opts.py	2012-11-08 08:07:22 +0000
@@ -133,6 +133,19 @@
                 "overlayroot=tmpfs",
                 "ip=::::%s:BOOTIF" % params.hostname]))
 
+    def test_commissioning_compose_kernel_command_line_inc_extra_opts(self):
+        extra_opts = "special console=ABCD -- options to pass"
+        params = make_kernel_parameters(extra_opts=extra_opts)
+        cmdline = compose_kernel_command_line(params)
+        # There should be a blank space before the options, but otherwise added
+        # verbatim.
+        self.assertThat(cmdline, Contains(' ' + extra_opts))
+
+    def test_commissioning_compose_kernel_handles_extra_opts_None(self):
+        params = make_kernel_parameters(extra_opts=None)
+        cmdline = compose_kernel_command_line(params)
+        self.assertNotIn(cmdline, "None")
+
     def test_compose_kernel_command_line_inc_common_opts(self):
         # Test that some kernel arguments appear on both commissioning
         # and install command lines.