← Back to team overview

launchpad-reviewers team mailing list archive

[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)