launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #14365
[Merge] lp:~jtv/maas/commissioning-script-model into lp:maas
Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/commissioning-script-model into lp:maas.
Commit message:
Add CommissioningScript, a simple model for storing custom commissioning scripts (which may be binaries).
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jtv/maas/commissioning-script-model/+merge/134953
Pre-imped with Gavin. Commissioning scripts could conceivably be proprietary binary vendor-supplied update tools and such, so we store them as binary. For now, however, we don't plan to expose that fact in UI or API -- we can just limit those to text, and expose the binary capability as needed.
Jeroen
--
https://code.launchpad.net/~jtv/maas/commissioning-script-model/+merge/134953
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/commissioning-script-model into lp:maas.
=== added file 'src/metadataserver/migrations/0004_add_commissioningscript.py'
--- src/metadataserver/migrations/0004_add_commissioningscript.py 1970-01-01 00:00:00 +0000
+++ src/metadataserver/migrations/0004_add_commissioningscript.py 2012-11-19 15:53:22 +0000
@@ -0,0 +1,160 @@
+# -*- 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 model 'CommissioningScript'
+ db.create_table(u'metadataserver_commissioningscript', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
+ ('content', self.gf('metadataserver.fields.BinaryField')()),
+ ))
+ db.send_create_signal(u'metadataserver', ['CommissioningScript'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'CommissioningScript'
+ db.delete_table(u'metadataserver_commissioningscript')
+
+
+ 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.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''", 'unique': 'True', '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-9b26bf42-3249-11e2-a760-fa163e25f662'", '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.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', [], {'blank': 'True'}),
+ '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'metadataserver.commissioningscript': {
+ 'Meta': {'object_name': 'CommissioningScript'},
+ 'content': ('metadataserver.fields.BinaryField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'})
+ },
+ u'metadataserver.nodecommissionresult': {
+ 'Meta': {'unique_together': "((u'node', u'name'),)", 'object_name': 'NodeCommissionResult'},
+ 'data': ('django.db.models.fields.CharField', [], {'max_length': '1048576'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"})
+ },
+ u'metadataserver.nodekey': {
+ 'Meta': {'object_name': 'NodeKey'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']", 'unique': 'True'}),
+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'})
+ },
+ u'metadataserver.nodeuserdata': {
+ 'Meta': {'object_name': 'NodeUserData'},
+ 'data': ('metadataserver.fields.BinaryField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']", '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': '1353330248L'}),
+ '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 = ['metadataserver']
\ No newline at end of file
=== modified file 'src/metadataserver/models/__init__.py'
--- src/metadataserver/models/__init__.py 2012-09-18 15:30:59 +0000
+++ src/metadataserver/models/__init__.py 2012-11-19 15:53:22 +0000
@@ -15,15 +15,17 @@
__metaclass__ = type
__all__ = [
+ 'CommissioningScript',
'NodeCommissionResult',
'NodeKey',
'NodeUserData',
]
from maasserver.utils import ignore_unused
+from metadataserver.models.commissioningscript import CommissioningScript
from metadataserver.models.nodecommissionresult import NodeCommissionResult
from metadataserver.models.nodekey import NodeKey
from metadataserver.models.nodeuserdata import NodeUserData
-ignore_unused(NodeCommissionResult, NodeKey, NodeUserData)
+ignore_unused(CommissioningScript, NodeCommissionResult, NodeKey, NodeUserData)
=== added file 'src/metadataserver/models/commissioningscript.py'
--- src/metadataserver/models/commissioningscript.py 1970-01-01 00:00:00 +0000
+++ src/metadataserver/models/commissioningscript.py 2012-11-19 15:53:22 +0000
@@ -0,0 +1,80 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Custom commissioning scripts, and their database backing."""
+
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = [
+ 'CommissioningScript',
+ ]
+
+
+from django.db.models import (
+ CharField,
+ Manager,
+ Model,
+ )
+from metadataserver import DefaultMeta
+from metadataserver.fields import BinaryField
+
+
+class CommissioningScriptManager(Manager):
+ """Manager for `CommissioningScript`.
+
+ Don't import or instantiate this directly; access as
+ `CommissioningScript.objects`.
+ """
+
+ def store_script(self, name, content):
+ """Store a commissioning script.
+
+ :param name: Name for this commissioning script. By convention, the
+ name should start with a two-digit number. The prefix "00-maas-"
+ is reserved for MAAS-internal scripts.
+ :type name: :class:`unicode`
+ :param content: Binary script content.
+ :type content: :class:`metadataserver.fields.Bin`
+ :return: :class:`CommissioningScript` object. If a script of the
+ given `name` already existed, it will be updated with the given
+ `content`. Otherwise, it will be newly created.
+ """
+ script, created = self.get_or_create(
+ name=name, defaults={'content': content})
+ if not created:
+ script.content = content
+ script.save()
+ return script
+
+ def get_scripts(self):
+ """Return all :class:`CommissioningScript` objects, sorted by name.
+
+ The ordering is used to ensure a predictable execution order.
+ """
+ return self.order_by('name')
+
+ def drop_script(self, name):
+ """Delete the named :class:`CommissioningScript`, if it existed."""
+ self.filter(name=name).delete()
+
+
+class CommissioningScript(Model):
+ """User-provided commissioning script.
+
+ Actually a commissioning "script" could be a binary, e.g. because a
+ hardware vendor supplied an update in the form of a binary executable.
+ """
+
+ class Meta(DefaultMeta):
+ """Needed for South to recognize this model."""
+
+ objects = CommissioningScriptManager()
+
+ name = CharField(max_length=255, null=False, editable=False, unique=True)
+ content = BinaryField(null=False)
=== added file 'src/metadataserver/tests/test_commissioningscript.py'
--- src/metadataserver/tests/test_commissioningscript.py 1970-01-01 00:00:00 +0000
+++ src/metadataserver/tests/test_commissioningscript.py 2012-11-19 15:53:22 +0000
@@ -0,0 +1,126 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test custom commissioning scripts."""
+
+from __future__ import (
+ absolute_import,
+ print_function,
+ unicode_literals,
+ )
+
+__metaclass__ = type
+__all__ = []
+
+import codecs
+from random import randint
+
+from maasserver.testing import reload_object
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import TestCase
+from metadataserver.fields import Bin
+from metadataserver.models import CommissioningScript
+
+
+def make_script_name(base_name=None, number=None):
+ """Make up a name for a commissioning script."""
+ if base_name is None:
+ base_name = 'script'
+ if number is None:
+ number = randint(0, 99)
+ return factory.make_name(
+ '%0.2d-%s' % (number, factory.make_name(base_name)))
+
+
+def make_script_content(recognizable_text='script'):
+ """Make up content for a commissioning script."""
+ text = "%s-%s" % (recognizable_text, factory.getRandomString())
+ return Bin(text.encode('ascii'))
+
+
+def list_names(scripts):
+ """List the respective names for `scripts`."""
+ return [script.name for script in scripts]
+
+
+class TestCommissioningScript(TestCase):
+
+ def test_store_script_creates_script(self):
+ name = make_script_name()
+ content = make_script_content()
+ CommissioningScript.objects.store_script(name, content)
+ stored_script = CommissioningScript.objects.get(name=name)
+ self.assertEqual(content, stored_script.content)
+
+ def test_store_script_overwrites_script(self):
+ name = make_script_name()
+ CommissioningScript.objects.create(
+ name=name, content=make_script_content('old'))
+ new_content = make_script_content('new')
+ CommissioningScript.objects.store_script(name, new_content)
+ stored_script = CommissioningScript.objects.filter(name=name)
+ self.assertEqual(1, len(stored_script))
+ self.assertEqual(new_content, stored_script[0].content)
+
+ def test_get_scripts_returns_nothing_if_no_scripts_defined(self):
+ self.assertItemsEqual([], CommissioningScript.objects.get_scripts())
+
+ def test_get_scripts_retrieves_all_scripts(self):
+ script = CommissioningScript.objects.store_script(
+ make_script_name(), make_script_content())
+ self.assertItemsEqual(
+ [script], CommissioningScript.objects.get_scripts())
+
+ def test_get_scripts_ignores_overwritten_scripts(self):
+ name = make_script_name()
+ CommissioningScript.objects.store_script(
+ name, make_script_content('old'))
+ new_content = make_script_content('new')
+ stored_script = CommissioningScript.objects.store_script(
+ name, new_content)
+ self.assertItemsEqual(
+ [stored_script],
+ CommissioningScript.objects.get_scripts())
+ self.assertEqual(new_content, reload_object(stored_script).content)
+
+ def test_get_scripts_orders_by_name(self):
+ names = [make_script_name(number=number) for number in [99, 1, 25, 8]]
+ for name in names:
+ CommissioningScript.objects.store_script(
+ name, make_script_content(name))
+ self.assertEqual(
+ sorted(names),
+ list_names(CommissioningScript.objects.get_scripts()))
+
+ def test_drop_script_removes_script(self):
+ name = make_script_name()
+ CommissioningScript.objects.store_script(name, make_script_content())
+ CommissioningScript.objects.drop_script(name)
+ self.assertItemsEqual([], CommissioningScript.objects.get_scripts())
+
+ def test_drop_script_leaves_other_scripts_alone(self):
+ doomed_script = make_script_name('doomed')
+ unaffected_script = make_script_name('unaffected')
+ CommissioningScript.objects.store_script(
+ doomed_script, make_script_content(doomed_script))
+ CommissioningScript.objects.store_script(
+ unaffected_script, make_script_content(unaffected_script))
+ CommissioningScript.objects.drop_script(doomed_script)
+ self.assertIn(
+ unaffected_script,
+ list_names(CommissioningScript.objects.get_scripts()))
+
+ def test_drop_script_tolerates_nonexistent_script(self):
+ CommissioningScript.objects.drop_script(
+ make_script_name('nonexistent'))
+ # The test is that we get here without errors.
+ pass
+
+ def test_scripts_may_be_binary(self):
+ name = make_script_name()
+ # Some binary data that would break just about any kind of text
+ # interpretation.
+ binary = Bin(codecs.BOM64_LE + codecs.BOM64_BE + b'\x00\xff\x00')
+ CommissioningScript.objects.store_script(name, binary)
+ [stored_script] = CommissioningScript.objects.get_scripts()
+ self.assertEqual(binary, stored_script.content)