Merge lp:~jameinel/maas/land-kernel-opts-in-trunk into lp:~maas-committers/maas/trunk

Proposed by John A Meinel
Status: Merged
Approved by: John A Meinel
Approved revision: no longer in the source branch.
Merged at revision: 1337
Proposed branch: lp:~jameinel/maas/land-kernel-opts-in-trunk
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 560 lines (+381/-5)
11 files modified
src/maasserver/api.py (+22/-1)
src/maasserver/forms.py (+1/-0)
src/maasserver/migrations/0045_add_tag_kernel_opts.py (+203/-0)
src/maasserver/models/node.py (+24/-1)
src/maasserver/models/tag.py (+3/-0)
src/maasserver/testing/factory.py (+4/-3)
src/maasserver/tests/test_api.py (+41/-0)
src/maasserver/tests/test_node.py (+56/-0)
src/maasserver/tests/test_tag.py (+10/-0)
src/provisioningserver/kernel_opts.py (+4/-0)
src/provisioningserver/tests/test_kernel_opts.py (+13/-0)
To merge this branch: bzr merge lp:~jameinel/maas/land-kernel-opts-in-trunk
Reviewer Review Type Date Requested Status
Martin Packman (community) Approve
Review via email: mp+133434@code.launchpad.net

Commit message

Land the changes for kernel_opts into trunk.

This brings in the changes for the Tag table, node.get_effective_kernel_options, Tag API to allow setting and getting the new options, and pxeconfig to handle the extra_opts field.

Description of the change

This restores all of the kernel opts goodness into the trunk branch, rather than being in the 1.2 branch.

To post a comment you must log in.
Revision history for this message
Martin Packman (gz) wrote :

Looks good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2012-11-08 09:12:44 +0000
3+++ src/maasserver/api.py 2012-11-08 10:09:36 +0000
4@@ -748,6 +748,13 @@
5 The minimum data required is:
6 architecture=<arch string> (e.g "i386/generic")
7 mac_address=<value>
8+
9+ :param architecture: A string containing the architecture type of
10+ the node.
11+ :param mac_address: The MAC address of the node.
12+ :param hostname: A hostname. If not given, one will be generated.
13+ :param powertype: A power management type, if applicable (e.g.
14+ "virsh", "ipmi").
15 """
16 node = create_node(request)
17 if request.user.is_superuser:
18@@ -1527,6 +1534,7 @@
19 'name',
20 'definition',
21 'comment',
22+ 'kernel_opts',
23 )
24
25 def read(self, request, name):
26@@ -1658,6 +1666,11 @@
27 It is meant as a human readable description of the tag.
28 :param definition: An XPATH query that will be evaluated against the
29 hardware_details stored for all nodes (output of `lshw -xml`).
30+ :param kernel_opts: Can be None. If set, nodes associated with this tag
31+ will add this string to their kernel options when booting. The
32+ value overrides the global 'kernel_opts' setting. If more than one
33+ tag is associated with a node, the one with the lowest alphabetical
34+ name will be picked (eg 01-my-tag will be taken over 99-tag-name).
35 """
36 if not request.user.is_superuser:
37 raise PermissionDenied()
38@@ -1892,6 +1905,13 @@
39 else:
40 series = node.get_distro_series()
41
42+ if node is not None:
43+ # We don't care if the kernel opts is from the global setting or a tag,
44+ # just get the options
45+ _, extra_kernel_opts = node.get_effective_kernel_options()
46+ else:
47+ extra_kernel_opts = None
48+
49 purpose = get_boot_purpose(node)
50 server_address = get_maas_facing_server_address()
51 cluster_address = get_mandatory_param(request.GET, "local")
52@@ -1899,7 +1919,8 @@
53 params = KernelParameters(
54 arch=arch, subarch=subarch, release=series, purpose=purpose,
55 hostname=hostname, domain=domain, preseed_url=preseed_url,
56- log_host=server_address, fs_host=cluster_address)
57+ log_host=server_address, fs_host=cluster_address,
58+ extra_opts=extra_kernel_opts)
59
60 return HttpResponse(
61 json.dumps(params._asdict()),
62
63=== modified file 'src/maasserver/forms.py'
64--- src/maasserver/forms.py 2012-11-08 08:33:59 +0000
65+++ src/maasserver/forms.py 2012-11-08 10:09:36 +0000
66@@ -864,6 +864,7 @@
67 'name',
68 'comment',
69 'definition',
70+ 'kernel_opts',
71 )
72
73 def clean_definition(self):
74
75=== added file 'src/maasserver/migrations/0045_add_tag_kernel_opts.py'
76--- src/maasserver/migrations/0045_add_tag_kernel_opts.py 1970-01-01 00:00:00 +0000
77+++ src/maasserver/migrations/0045_add_tag_kernel_opts.py 2012-11-08 10:09:36 +0000
78@@ -0,0 +1,203 @@
79+# -*- coding: utf-8 -*-
80+import datetime
81+from south.db import db
82+from south.v2 import SchemaMigration
83+from django.db import models
84+
85+
86+class Migration(SchemaMigration):
87+
88+ def forwards(self, orm):
89+ # Adding field 'Tag.kernel_opts'
90+ db.add_column(u'maasserver_tag', 'kernel_opts',
91+ self.gf('django.db.models.fields.TextField')(null=True, blank=True),
92+ keep_default=False)
93+
94+
95+ def backwards(self, orm):
96+ # Deleting field 'Tag.kernel_opts'
97+ db.delete_column(u'maasserver_tag', 'kernel_opts')
98+
99+
100+ models = {
101+ 'auth.group': {
102+ 'Meta': {'object_name': 'Group'},
103+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
104+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
105+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
106+ },
107+ 'auth.permission': {
108+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
109+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
110+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
111+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
112+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
113+ },
114+ 'auth.user': {
115+ 'Meta': {'object_name': 'User'},
116+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
117+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
118+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
119+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
120+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
121+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
122+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
123+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
124+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
125+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
126+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
127+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
128+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
129+ },
130+ 'contenttypes.contenttype': {
131+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
132+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
133+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
134+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
135+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
136+ },
137+ u'maasserver.bootimage': {
138+ 'Meta': {'unique_together': "((u'nodegroup', u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'},
139+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
140+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
141+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
142+ 'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
143+ 'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
144+ 'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'})
145+ },
146+ u'maasserver.componenterror': {
147+ 'Meta': {'object_name': 'ComponentError'},
148+ 'component': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}),
149+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
150+ 'error': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
151+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
152+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
153+ },
154+ u'maasserver.config': {
155+ 'Meta': {'object_name': 'Config'},
156+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
157+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
158+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
159+ },
160+ u'maasserver.dhcplease': {
161+ 'Meta': {'object_name': 'DHCPLease'},
162+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
163+ 'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
164+ 'mac': ('maasserver.fields.MACAddressField', [], {}),
165+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
166+ },
167+ u'maasserver.filestorage': {
168+ 'Meta': {'object_name': 'FileStorage'},
169+ 'content': ('metadataserver.fields.BinaryField', [], {}),
170+ 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
171+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
172+ },
173+ u'maasserver.macaddress': {
174+ 'Meta': {'object_name': 'MACAddress'},
175+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
176+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
177+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
178+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
179+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
180+ },
181+ u'maasserver.node': {
182+ 'Meta': {'object_name': 'Node'},
183+ 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
184+ 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386/generic'", 'max_length': '31'}),
185+ 'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
186+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
187+ 'distro_series': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '10', 'null': 'True', 'blank': 'True'}),
188+ 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
189+ 'hardware_details': ('maasserver.fields.XMLField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
190+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'unique': 'True', 'max_length': '255', 'blank': 'True'}),
191+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
192+ 'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
193+ 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
194+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
195+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
196+ 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
197+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
198+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
199+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-a776a79c-298b-11e2-90d1-080027748fea'", 'unique': 'True', 'max_length': '41'}),
200+ 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
201+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
202+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
203+ },
204+ u'maasserver.nodegroup': {
205+ 'Meta': {'object_name': 'NodeGroup'},
206+ 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
207+ 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
208+ 'cluster_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100', 'blank': 'True'}),
209+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
210+ 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
211+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
212+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'blank': 'True'}),
213+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
214+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
215+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
216+ },
217+ u'maasserver.nodegroupinterface': {
218+ 'Meta': {'unique_together': "((u'nodegroup', u'interface'),)", 'object_name': 'NodeGroupInterface'},
219+ 'broadcast_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
220+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
221+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
222+ 'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
223+ 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
224+ 'ip_range_high': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
225+ 'ip_range_low': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
226+ 'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
227+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
228+ 'router_ip': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
229+ 'subnet_mask': ('django.db.models.fields.GenericIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
230+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
231+ },
232+ u'maasserver.sshkey': {
233+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
234+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
235+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
236+ 'key': ('django.db.models.fields.TextField', [], {}),
237+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
238+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
239+ },
240+ u'maasserver.tag': {
241+ 'Meta': {'object_name': 'Tag'},
242+ 'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
243+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
244+ 'definition': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
245+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
246+ 'kernel_opts': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
247+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
248+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
249+ },
250+ u'maasserver.userprofile': {
251+ 'Meta': {'object_name': 'UserProfile'},
252+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
253+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
254+ },
255+ 'piston.consumer': {
256+ 'Meta': {'object_name': 'Consumer'},
257+ 'description': ('django.db.models.fields.TextField', [], {}),
258+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
259+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
260+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
261+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
262+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
263+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
264+ },
265+ 'piston.token': {
266+ 'Meta': {'object_name': 'Token'},
267+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
268+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
269+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
270+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
271+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
272+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
273+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
274+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1352369055L'}),
275+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
276+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
277+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
278+ }
279+ }
280+
281+ complete_apps = ['maasserver']
282\ No newline at end of file
283
284=== modified file 'src/maasserver/models/node.py'
285--- src/maasserver/models/node.py 2012-11-05 08:23:36 +0000
286+++ src/maasserver/models/node.py 2012-11-08 10:09:36 +0000
287@@ -73,7 +73,10 @@
288 get_db_state,
289 strip_domain,
290 )
291-from maasserver.utils.orm import get_first
292+from maasserver.utils.orm import (
293+ get_first,
294+ get_one,
295+ )
296 from piston.models import Token
297 from provisioningserver.enum import (
298 POWER_TYPE,
299@@ -722,6 +725,26 @@
300 else:
301 return None
302
303+ def get_effective_kernel_options(self):
304+ """Determine any special kernel parameters for this node.
305+
306+ :return: (tag, kernel_options)
307+ tag is a Tag object or None. If None, the kernel_options came from
308+ the global setting.
309+ kernel_options, a string indicating extra kernel_options that
310+ should be used when booting this node. May be None if no tags match
311+ and no global setting has been configured.
312+ """
313+ # First, see if there are any tags associated with this node that has a
314+ # custom kernel parameter
315+ tags = self.tags.filter(kernel_opts__isnull=False)
316+ tags = tags.order_by('name')[:1]
317+ tag = get_one(tags)
318+ if tag is not None:
319+ return tag, tag.kernel_opts
320+ global_value = Config.objects.get_config('kernel_opts')
321+ return None, global_value
322+
323 @property
324 def work_queue(self):
325 """The name of the queue for tasks specific to this node."""
326
327=== modified file 'src/maasserver/models/tag.py'
328--- src/maasserver/models/tag.py 2012-10-24 16:07:00 +0000
329+++ src/maasserver/models/tag.py 2012-11-08 10:09:36 +0000
330@@ -89,6 +89,8 @@
331 tag.
332 :ivar comment: A long-form description for humans about what this tag is
333 trying to accomplish.
334+ :ivar kernel_opts: Optional kernel command-line parameters string to be
335+ used in the PXE config for nodes with this tags.
336 :ivar objects: The :class:`TagManager`.
337 """
338
339@@ -101,6 +103,7 @@
340 validators=[RegexValidator(_tag_name_regex)])
341 definition = TextField(blank=True)
342 comment = TextField(blank=True)
343+ kernel_opts = TextField(blank=True, null=True)
344
345 objects = TagManager()
346
347
348=== modified file 'src/maasserver/testing/factory.py'
349--- src/maasserver/testing/factory.py 2012-11-01 15:59:22 +0000
350+++ src/maasserver/testing/factory.py 2012-11-08 10:09:36 +0000
351@@ -251,14 +251,15 @@
352 key.save()
353 return key
354
355- def make_tag(self, name=None, definition=None, comment='', created=None,
356- updated=None):
357+ def make_tag(self, name=None, definition=None, comment='',
358+ kernel_opts=None, created=None, updated=None):
359 if name is None:
360 name = self.make_name('tag')
361 if definition is None:
362 # Is there a 'node' in this xml?
363 definition = '//node'
364- tag = Tag(name=name, definition=definition, comment=comment)
365+ tag = Tag(name=name, definition=definition, comment=comment,
366+ kernel_opts=kernel_opts)
367 self._save_node_unchecked(tag)
368 # Update the 'updated'/'created' fields with a call to 'update'
369 # preventing a call to save() from overriding the values.
370
371=== modified file 'src/maasserver/tests/test_api.py'
372--- src/maasserver/tests/test_api.py 2012-11-08 09:12:44 +0000
373+++ src/maasserver/tests/test_api.py 2012-11-08 10:09:36 +0000
374@@ -3141,6 +3141,30 @@
375 % (invalid,))
376 self.assertFalse(Tag.objects.filter(name=invalid).exists())
377
378+ def test_POST_new_kernel_opts(self):
379+ self.become_admin()
380+ name = factory.getRandomString()
381+ definition = '//node'
382+ comment = factory.getRandomString()
383+ extra_kernel_opts = factory.getRandomString()
384+ response = self.client.post(
385+ self.get_uri('tags/'),
386+ {
387+ 'op': 'new',
388+ 'name': name,
389+ 'comment': comment,
390+ 'definition': definition,
391+ 'kernel_opts': extra_kernel_opts,
392+ })
393+ self.assertEqual(httplib.OK, response.status_code)
394+ parsed_result = json.loads(response.content)
395+ self.assertEqual(name, parsed_result['name'])
396+ self.assertEqual(comment, parsed_result['comment'])
397+ self.assertEqual(definition, parsed_result['definition'])
398+ self.assertEqual(extra_kernel_opts, parsed_result['kernel_opts'])
399+ self.assertEqual(
400+ extra_kernel_opts, Tag.objects.filter(name=name)[0].kernel_opts)
401+
402 def test_POST_new_populates_nodes(self):
403 self.become_admin()
404 node1 = factory.make_node()
405@@ -3504,6 +3528,23 @@
406 kernel_params = KernelParameters(**self.get_pxeconfig(params))
407 self.assertEqual(params["local"], kernel_params.fs_host)
408
409+ def test_pxeconfig_returns_extra_kernel_options(self):
410+ node = factory.make_node()
411+ extra_kernel_opts = factory.getRandomString()
412+ Config.objects.set_config('kernel_opts', extra_kernel_opts)
413+ mac = factory.make_mac_address(node=node)
414+ params = self.get_default_params()
415+ params['mac'] = mac.mac_address
416+ pxe_config = self.get_pxeconfig(params)
417+ self.assertEqual(extra_kernel_opts, pxe_config['extra_opts'])
418+
419+ def test_pxeconfig_returns_None_for_extra_kernel_opts(self):
420+ mac = factory.make_mac_address()
421+ params = self.get_default_params()
422+ params['mac'] = mac.mac_address
423+ pxe_config = self.get_pxeconfig(params)
424+ self.assertEqual(None, pxe_config['extra_opts'])
425+
426
427 class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase):
428 scenarios = [
429
430=== modified file 'src/maasserver/tests/test_node.py'
431--- src/maasserver/tests/test_node.py 2012-11-05 08:23:36 +0000
432+++ src/maasserver/tests/test_node.py 2012-11-08 10:09:36 +0000
433@@ -317,6 +317,62 @@
434 successful_types = [node_power_types[node] for node in started_nodes]
435 self.assertItemsEqual(configless_power_types, successful_types)
436
437+ def test_get_effective_kernel_options_with_nothing_set(self):
438+ node = factory.make_node()
439+ self.assertEqual((None, None), node.get_effective_kernel_options())
440+
441+ def test_get_effective_kernel_options_sees_global_config(self):
442+ node = factory.make_node()
443+ kernel_opts = factory.getRandomString()
444+ Config.objects.set_config('kernel_opts', kernel_opts)
445+ self.assertEqual(
446+ (None, kernel_opts), node.get_effective_kernel_options())
447+
448+ def test_get_effective_kernel_options_not_confused_by_empty_tag(self):
449+ node = factory.make_node()
450+ tag = factory.make_tag()
451+ node.tags.add(tag)
452+ kernel_opts = factory.getRandomString()
453+ Config.objects.set_config('kernel_opts', kernel_opts)
454+ self.assertEqual(
455+ (None, kernel_opts), node.get_effective_kernel_options())
456+
457+ def test_get_effective_kernel_options_ignores_unassociated_tag_value(self):
458+ node = factory.make_node()
459+ factory.make_tag(kernel_opts=factory.getRandomString())
460+ self.assertEqual((None, None), node.get_effective_kernel_options())
461+
462+ def test_get_effective_kernel_options_uses_tag_value(self):
463+ node = factory.make_node()
464+ tag = factory.make_tag(kernel_opts=factory.getRandomString())
465+ node.tags.add(tag)
466+ self.assertEqual(
467+ (tag, tag.kernel_opts), node.get_effective_kernel_options())
468+
469+ def test_get_effective_kernel_options_tag_overrides_global(self):
470+ node = factory.make_node()
471+ global_opts = factory.getRandomString()
472+ Config.objects.set_config('kernel_opts', global_opts)
473+ tag = factory.make_tag(kernel_opts=factory.getRandomString())
474+ node.tags.add(tag)
475+ self.assertEqual(
476+ (tag, tag.kernel_opts), node.get_effective_kernel_options())
477+
478+ def test_get_effective_kernel_options_uses_first_real_tag_value(self):
479+ node = factory.make_node()
480+ # Intentionally create them in reverse order, so the default 'db' order
481+ # doesn't work, and we have asserted that we sort them.
482+ tag3 = factory.make_tag(factory.make_name('tag-03-'),
483+ kernel_opts=factory.getRandomString())
484+ tag2 = factory.make_tag(factory.make_name('tag-02-'),
485+ kernel_opts=factory.getRandomString())
486+ tag1 = factory.make_tag(factory.make_name('tag-01-'), kernel_opts=None)
487+ self.assertTrue(tag1.name < tag2.name)
488+ self.assertTrue(tag2.name < tag3.name)
489+ node.tags.add(tag1, tag2, tag3)
490+ self.assertEqual(
491+ (tag2, tag2.kernel_opts), node.get_effective_kernel_options())
492+
493 def test_acquire(self):
494 node = factory.make_node(status=NODE_STATUS.READY)
495 user = factory.make_user()
496
497=== modified file 'src/maasserver/tests/test_tag.py'
498--- src/maasserver/tests/test_tag.py 2012-10-10 09:41:48 +0000
499+++ src/maasserver/tests/test_tag.py 2012-11-08 10:09:36 +0000
500@@ -29,6 +29,16 @@
501 self.assertEqual('tag-name', tag.name)
502 self.assertEqual('//node[@id=display]', tag.definition)
503 self.assertEqual('', tag.comment)
504+ self.assertIs(None, tag.kernel_opts)
505+ self.assertIsNot(None, tag.updated)
506+ self.assertIsNot(None, tag.created)
507+
508+ def test_factory_make_tag_with_hardware_details(self):
509+ tag = factory.make_tag('a-tag', 'true', kernel_opts="console=ttyS0")
510+ self.assertEqual('a-tag', tag.name)
511+ self.assertEqual('true', tag.definition)
512+ self.assertEqual('', tag.comment)
513+ self.assertEqual('console=ttyS0', tag.kernel_opts)
514 self.assertIsNot(None, tag.updated)
515 self.assertIsNot(None, tag.created)
516
517
518=== modified file 'src/provisioningserver/kernel_opts.py'
519--- src/provisioningserver/kernel_opts.py 2012-10-09 15:43:33 +0000
520+++ src/provisioningserver/kernel_opts.py 2012-11-08 10:09:36 +0000
521@@ -37,6 +37,8 @@
522 "preseed_url", # URL from which a preseed can be obtained.
523 "log_host", # Host/IP to which syslog can be streamed.
524 "fs_host", # Host/IP on which ephemeral filesystems are hosted.
525+ "extra_opts", # String of extra options to supply, will be appended
526+ # verbatim to the kernel command line
527 ))
528
529
530@@ -176,4 +178,6 @@
531 # as it would be nice to have.
532 options += compose_logging_opts(params.log_host)
533 options += compose_arch_opts(params)
534+ if params.extra_opts:
535+ options.append(params.extra_opts)
536 return ' '.join(options)
537
538=== modified file 'src/provisioningserver/tests/test_kernel_opts.py'
539--- src/provisioningserver/tests/test_kernel_opts.py 2012-10-09 15:39:54 +0000
540+++ src/provisioningserver/tests/test_kernel_opts.py 2012-11-08 10:09:36 +0000
541@@ -133,6 +133,19 @@
542 "overlayroot=tmpfs",
543 "ip=::::%s:BOOTIF" % params.hostname]))
544
545+ def test_commissioning_compose_kernel_command_line_inc_extra_opts(self):
546+ extra_opts = "special console=ABCD -- options to pass"
547+ params = make_kernel_parameters(extra_opts=extra_opts)
548+ cmdline = compose_kernel_command_line(params)
549+ # There should be a blank space before the options, but otherwise added
550+ # verbatim.
551+ self.assertThat(cmdline, Contains(' ' + extra_opts))
552+
553+ def test_commissioning_compose_kernel_handles_extra_opts_None(self):
554+ params = make_kernel_parameters(extra_opts=None)
555+ cmdline = compose_kernel_command_line(params)
556+ self.assertNotIn(cmdline, "None")
557+
558 def test_compose_kernel_command_line_inc_common_opts(self):
559 # Test that some kernel arguments appear on both commissioning
560 # and install command lines.