]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
Add resource for Rackspace Cloud Servers.
authorJason Dunsmore <jasondunsmore@gmail.com>
Mon, 22 Jul 2013 16:00:51 +0000 (11:00 -0500)
committerJason Dunsmore <jasondunsmore@gmail.com>
Mon, 22 Jul 2013 16:00:51 +0000 (11:00 -0500)
Blueprint rackspace-cloud-servers-provider

Change-Id: Ie6659e0e8b519180ce5973cc798c914b56a95426

heat/api/openstack/v1/util.py
heat/common/exception.py
heat/engine/resources/instance.py
heat/engine/resources/rackspace/cloud_server.py [new file with mode: 0644]
heat/tests/test_instance.py
heat/tests/test_rackspace_cloud_server.py [new file with mode: 0644]
requirements.txt
test-requirements.txt

index 7e6d14917f678ec2c5ee4022b9b96603a3fc683b..a344154fd6dfba7f145552a9835b7b467b3970ec 100644 (file)
@@ -85,6 +85,9 @@ def remote_error(ex):
         'StackValidationFailed': exc.HTTPBadRequest,
         'InvalidTemplateReference': exc.HTTPBadRequest,
         'UnknownUserParameter': exc.HTTPBadRequest,
+        'RevertFailed': exc.HTTPInternalServerError,
+        'ServerBuildFailed': exc.HTTPInternalServerError,
+        'NotSupported': exc.HTTPBadRequest,
         'MissingCredentialError': exc.HTTPBadRequest,
         'UserParameterMissing': exc.HTTPBadRequest,
     }
index ec60769487abbb6c7d557ec7d3836a157bc49c53..0fc68e9a4ed84302879487b0fbe5547bed09e3c4 100644 (file)
@@ -270,3 +270,7 @@ class ResourceFailure(OpenstackException):
         exc_type = type(exception).__name__
         super(ResourceFailure, self).__init__(exc_type=exc_type,
                                               message=str(exception))
+
+
+class NotSupported(OpenstackException):
+    message = _("%(feature)s is not supported.")
index c792eed8a487bbe5db70b8e379807d312b24239d..9e588e8180bc7b623bca5978ddec10c1eaae6e5b 100644 (file)
@@ -380,10 +380,17 @@ class Instance(resource.Resource):
                 self._set_ipaddress(server.networks)
                 volume_attach.start()
                 return volume_attach.done()
+            elif server.status == 'ERROR':
+                delete = scheduler.TaskRunner(self._delete_server, server)
+                delete(wait_time=0.2)
+                exc = exception.Error("Build of server %s failed." %
+                                      server.name)
+                raise exception.ResourceFailure(exc)
             else:
-                raise exception.Error('%s instance[%s] status[%s]' %
+                exc = exception.Error('%s instance[%s] status[%s]' %
                                       ('nova reported unexpected',
                                        self.name, server.status))
+                raise exception.ResourceFailure(exc)
         else:
             return volume_attach.step()
 
diff --git a/heat/engine/resources/rackspace/cloud_server.py b/heat/engine/resources/rackspace/cloud_server.py
new file mode 100644 (file)
index 0000000..2adac13
--- /dev/null
@@ -0,0 +1,409 @@
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import tempfile
+
+import json
+import paramiko
+from Crypto.PublicKey import RSA
+import novaclient.exceptions as novaexception
+
+from heat.common import exception
+from heat.openstack.common import log as logging
+from heat.engine import scheduler
+from heat.engine.resources import instance
+from heat.engine.resources.rackspace import rackspace_resource
+from heat.db.sqlalchemy import api as db_api
+
+logger = logging.getLogger(__name__)
+
+
+class CloudServer(instance.Instance):
+    """Resource for Rackspace Cloud Servers."""
+
+    properties_schema = {'ServerName': {'Type': 'String', 'Required': True},
+                         'Flavor': {'Type': 'String', 'Required': True},
+                         'ImageName': {'Type': 'String', 'Required': True},
+                         'UserData': {'Type': 'String'},
+                         'PublicKey': {'Type': 'String'},
+                         'Volumes': {'Type': 'List'}}
+
+    attributes_schema = {'PrivateDnsName': ('Private DNS name of the specified'
+                                            ' instance.'),
+                         'PublicDnsName': ('Public DNS name of the specified '
+                                           'instance.'),
+                         'PrivateIp': ('Private IP address of the specified '
+                                       'instance.'),
+                         'PublicIp': ('Public IP address of the specified '
+                                      'instance.')}
+
+    base_script = """#!/bin/bash
+
+# Install cloud-init and heat-cfntools
+%s
+# Create data source for cloud-init
+mkdir -p /var/lib/cloud/seed/nocloud-net
+mv /tmp/userdata /var/lib/cloud/seed/nocloud-net/user-data
+touch /var/lib/cloud/seed/nocloud-net/meta-data
+chmod 600 /var/lib/cloud/seed/nocloud-net/*
+
+# Run cloud-init & cfn-init
+cloud-init start
+bash -x /var/lib/cloud/data/cfn-userdata > /root/cfn-userdata.log 2>&1
+"""
+
+    ubuntu_script = base_script % """\
+apt-get update
+apt-get install -y cloud-init python-boto python-pip gcc python-dev
+pip install heat-cfntools
+"""
+
+    fedora_script = base_script % """\
+yum install -y cloud-init python-boto python-pip gcc python-devel
+pip-python install heat-cfntools
+"""
+
+    # TODO(jason): Install cloud-init & other deps from third-party repos
+    centos_script = base_script % """\
+yum install -y cloud-init python-boto python-pip gcc python-devel
+pip-python install heat-cfntools
+"""
+
+    # TODO(jason): Install cloud-init & other deps from third-party repos
+    arch_script = base_script % """\
+pacman -S --noconfirm python-pip gcc
+"""
+
+    # TODO(jason): Install cloud-init & other deps from third-party repos
+    gentoo_script = base_script % """\
+emerge cloud-init python-boto python-pip gcc python-devel
+"""
+
+    # TODO(jason): Install cloud-init & other deps from third-party repos
+    opensuse_script = base_script % """\
+zypper --non-interactive rm patterns-openSUSE-minimal_base-conflicts
+zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel
+"""
+
+    # List of supported Linux distros and their corresponding config scripts
+    image_scripts = {'arch': None,
+                     'centos': None,
+                     'debian': None,
+                     'fedora': fedora_script,
+                     'gentoo': None,
+                     'opensuse': None,
+                     'rhel': None,
+                     'ubuntu': ubuntu_script}
+
+    # Cache data retrieved from APIs in class attributes
+    _image_id_map = {}
+    _distro_map = {}
+    _server_map = {}
+
+    # Template keys supported for handle_update.  Properties not
+    # listed here trigger an UpdateReplace
+    update_allowed_keys = ('Metadata', 'Properties')
+    update_allowed_properties = ('Flavor', 'ServerName')
+
+    def __init__(self, name, json_snippet, stack):
+        super(CloudServer, self).__init__(name, json_snippet, stack)
+        self._private_key = None
+        self.rs = rackspace_resource.RackspaceResource(name,
+                                                       json_snippet,
+                                                       stack)
+
+    def nova(self):
+        return self.rs.nova()  # Override the Instance method
+
+    def cinder(self):
+        return self.rs.cinder()
+
+    @property
+    def server(self):
+        """Get the Cloud Server object."""
+        if self.resource_id in self.__class__._server_map:
+            return self.__class__._server_map[self.resource_id]
+        else:
+            server = self.nova().servers.get(self.resource_id)
+            self.__class__._server_map[self.resource_id] = server
+            return server
+
+    @property
+    def image_id(self):
+        """Get the image ID corresponding to the ImageName property."""
+        image_name = self.properties['ImageName']
+        if image_name in self.__class__._image_id_map:
+            return self.__class__._image_id_map[image_name]
+        else:
+            image_id = self._get_image_id(image_name)
+            self.__class__._image_id_map[image_name] = image_id
+            return image_id
+
+    @property
+    def distro(self):
+        """Get the Linux distribution for this server."""
+        if self.image_id in self.__class__._distro_map:
+            return self.__class__._distro_map[self.image_id]
+        else:
+            image = self.nova().images.get(self.image_id)
+            distro = image.metadata['os_distro']
+            self.__class__._distro_map[self.image_id] = distro
+            return distro
+
+    @property
+    def script(self):
+        """Get the config script for the Cloud Server image."""
+        return self.image_scripts[self.distro]
+
+    @property
+    def flavors(self):
+        """Get the flavors from the API or cache (updated every 6 hours)."""
+        return [flavor.id for flavor in self.nova().flavors.list()]
+
+    @property
+    def private_key(self):
+        """Return the private SSH key for the resource."""
+        if self._private_key:
+            return self._private_key
+        if self.id is not None:
+            private_key = db_api.resource_data_get(self, 'private_key')
+            if not private_key:
+                return None
+            self._private_key = private_key
+            return private_key
+
+    @private_key.setter
+    def private_key(self, private_key):
+        """Save the resource's private SSH key to the database."""
+        self._private_key = private_key
+        if self.id is not None:
+            db_api.resource_data_set(self, 'private_key', private_key, True)
+
+    def _get_ip(self, ip_type):
+        """Return the IP of the Cloud Server."""
+        def ip_not_found():
+            exc = exception.Error("Could not determine the %s IP of %s." %
+                                  (ip_type, self.properties['ImageName']))
+            raise exception.ResourceFailure(exc)
+
+        if ip_type not in self.server.addresses:
+            ip_not_found()
+        for ip in self.server.addresses[ip_type]:
+            if ip['version'] == 4:
+                return ip['addr']
+        ip_not_found()
+
+    @property
+    def public_ip(self):
+        """Return the public IP of the Cloud Server."""
+        return self._get_ip('public')
+
+    @property
+    def private_ip(self):
+        """Return the private IP of the Cloud Server."""
+        try:
+            return self._get_ip('private')
+        except exception.ResourceFailure as ex:
+            logger.info(ex.message)
+
+    def validate(self):
+        """Validate user parameters."""
+        if self.properties['Flavor'] not in self.flavors:
+            return {'Error': "Flavor not found."}
+        if not self.script:
+            return {'Error': "Image %s not supported." %
+                    self.properties['ImageName']}
+
+    def _run_ssh_command(self, command):
+        """Run a shell command on the Cloud Server via SSH."""
+        with tempfile.NamedTemporaryFile() as private_key_file:
+            private_key_file.write(self.private_key)
+            private_key_file.seek(0)
+            ssh = paramiko.SSHClient()
+            ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy())
+            ssh.connect(self.public_ip,
+                        username="root",
+                        key_filename=private_key_file.name)
+            stdin, stdout, stderr = ssh.exec_command(command)
+            logger.debug(stdout.read())
+            logger.debug(stderr.read())
+
+    def _sftp_files(self, files):
+        """Transfer files to the Cloud Server via SFTP."""
+        with tempfile.NamedTemporaryFile() as private_key_file:
+            private_key_file.write(self.private_key)
+            private_key_file.seek(0)
+            pkey = paramiko.RSAKey.from_private_key_file(private_key_file.name)
+            transport = paramiko.Transport((self.public_ip, 22))
+            transport.connect(hostkey=None, username="root", pkey=pkey)
+            sftp = paramiko.SFTPClient.from_transport(transport)
+            for remote_file in files:
+                sftp_file = sftp.open(remote_file['path'], 'w')
+                sftp_file.write(remote_file['data'])
+                sftp_file.close()
+
+    def handle_create(self):
+        """Create a Rackspace Cloud Servers container.
+
+        Rackspace Cloud Servers does not have the metadata service
+        running, so we have to transfer the user-data file to the
+        server and then trigger cloud-init.
+        """
+        # Retrieve server creation parameters from properties
+        flavor = self.properties['Flavor']
+        user_public_key = self.properties['PublicKey'] or ''
+
+        # Generate SSH public/private keypair
+        rsa = RSA.generate(1024)
+        self.private_key = rsa.exportKey()
+        public_key = rsa.publickey().exportKey('OpenSSH')
+        public_keys = public_key + "\n" + user_public_key
+        personality_files = {"/root/.ssh/authorized_keys": public_keys}
+
+        # Create server
+        client = self.nova().servers
+        server = client.create(self.properties['ServerName'],
+                               self.image_id,
+                               flavor,
+                               files=personality_files)
+
+        # Save resource ID to db
+        self.resource_id_set(server.id)
+
+        return server, scheduler.TaskRunner(self._attach_volumes_task())
+
+    def _attach_volumes_task(self):
+        tasks = (scheduler.TaskRunner(self._attach_volume, volume_id, device)
+                 for volume_id, device in self.volumes())
+        return scheduler.PollingTaskGroup(tasks)
+
+    def _attach_volume(self, volume_id, device):
+        self.nova().volumes.create_server_volume(self.server.id,
+                                                 volume_id,
+                                                 device or None)
+        yield
+        volume = self.cinder().get(volume_id)
+        while volume.status in ('available', 'attaching'):
+            yield
+            volume.get()
+
+        if volume.status != 'in-use':
+            raise exception.Error(volume.status)
+
+    def _detach_volumes_task(self):
+        tasks = (scheduler.TaskRunner(self._detach_volume, volume_id)
+                 for volume_id, device in self.volumes())
+        return scheduler.PollingTaskGroup(tasks)
+
+    def _detach_volume(self, volume_id):
+        volume = self.cinder().get(volume_id)
+        volume.detach()
+        yield
+        while volume.status in ('in-use', 'detaching'):
+            yield
+            volume.get()
+
+        if volume.status != 'available':
+            raise exception.Error(volume.status)
+
+    def check_create_complete(self, cookie):
+        """Check if server creation is complete and handle server configs."""
+        if not self._check_active(cookie):
+            return False
+
+        # Create heat-script and userdata files on server
+        raw_userdata = self.properties['UserData'] or ''
+        userdata = self._build_userdata(raw_userdata)
+        files = [{'path': "/tmp/userdata", 'data': userdata},
+                 {'path': "/root/heat-script.sh", 'data': self.script}]
+        self._sftp_files(files)
+
+        # Connect via SSH and run script
+        command = "bash -ex /root/heat-script.sh > /root/heat-script.log 2>&1"
+        self._run_ssh_command(command)
+
+        return True
+
+    # TODO(jason): Make this consistent with Instance and inherit
+    def _delete_server(self, server):
+        """Return a coroutine that deletes the Cloud Server."""
+        server.delete()
+        while True:
+            yield
+            try:
+                server.get()
+                if server.status == "ERROR":
+                    exc = exception.Error("Deletion of server %s failed." %
+                                          server.name)
+                    raise exception.ResourceFailure(exc)
+            except novaexception.NotFound:
+                break
+
+    def handle_update(self, json_snippet, tmpl_diff, prop_diff):
+        """Try to update a Cloud Server's parameters.
+
+        If the Cloud Server's Metadata or Flavor changed, update the
+        Cloud Server.  If any other parameters changed, re-create the
+        Cloud Server with the new parameters.
+        """
+        if 'Metadata' in tmpl_diff:
+            self.metadata = json_snippet['Metadata']
+            metadata_string = json.dumps(self.metadata)
+
+            files = [{'path': "/var/cache/heat-cfntools/last_metadata",
+                      'data': metadata_string}]
+            self._sftp_files(files)
+
+            command = "bash -x /var/lib/cloud/data/cfn-userdata > " + \
+                      "/root/cfn-userdata.log 2>&1"
+            self._run_ssh_command(command)
+
+        if 'Flavor' in prop_diff:
+            self.flavor = json_snippet['Properties']['Flavor']
+            self.server.resize(self.flavor)
+            resize = scheduler.TaskRunner(self._check_resize,
+                                          self.server,
+                                          self.flavor)
+            resize(wait_time=1.0)
+
+        # If ServerName is the only update, fail update
+        if prop_diff.keys() == ['ServerName'] and \
+           tmpl_diff.keys() == ['Properties']:
+            raise exception.NotSupported(feature="Cloud Server rename")
+        # Other updates were successful, so don't cause update to fail
+        elif 'ServerName' in prop_diff:
+            logger.info("Cloud Server rename not supported.")
+
+        return True
+
+    def _resolve_attribute(self, key):
+        """Return the method that provides a given template attribute."""
+        attribute_function = {'PublicIp': self.public_ip,
+                              'PrivateIp': self.private_ip,
+                              'PublicDnsName': self.public_ip,
+                              'PrivateDnsName': self.public_ip}
+        if key not in attribute_function:
+            raise exception.InvalidTemplateAttribute(resource=self.name,
+                                                     key=key)
+        function = attribute_function[key]
+        logger.info('%s._resolve_attribute(%s) == %s'
+                    % (self.name, key, function))
+        return unicode(function)
+
+
+# pyrax module is required to work with Rackspace cloud server provider.
+# If it is not installed, don't register cloud server provider
+def resource_mapping():
+    if rackspace_resource.PYRAX_INSTALLED:
+        return {'Rackspace::Cloud::Server': CloudServer}
+    else:
+        return {}
index 661927197eeaf09ffaff7014eae98a142f2b08bd..b91552b007630fd6dea4b5884971bbf41d74d6e8 100644 (file)
@@ -139,7 +139,7 @@ class InstancesTest(HeatTestCase):
         expected_ip = return_server.networks['public'][0]
         self.assertEqual(instance.FnGetAtt('PublicIp'), expected_ip)
         self.assertEqual(instance.FnGetAtt('PrivateIp'), expected_ip)
-        self.assertEqual(instance.FnGetAtt('PrivateDnsName'), expected_ip)
+        self.assertEqual(instance.FnGetAtt('PublicDnsName'), expected_ip)
         self.assertEqual(instance.FnGetAtt('PrivateDnsName'), expected_ip)
 
         self.m.VerifyAll()
diff --git a/heat/tests/test_rackspace_cloud_server.py b/heat/tests/test_rackspace_cloud_server.py
new file mode 100644 (file)
index 0000000..b811b08
--- /dev/null
@@ -0,0 +1,409 @@
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import copy
+
+import mox
+import paramiko
+import novaclient
+
+from heat.db import api as db_api
+from heat.tests.v1_1 import fakes
+from heat.common import template_format
+from heat.common import exception
+from heat.engine import parser
+from heat.engine import resource
+from heat.engine import scheduler
+from heat.engine import environment
+from heat.engine.resources.rackspace import cloud_server
+from heat.engine.resources.rackspace import rackspace_resource
+from heat.openstack.common import uuidutils
+from heat.tests.common import HeatTestCase
+from heat.tests.utils import setup_dummy_db
+
+
+wp_template = '''
+{
+  "AWSTemplateFormatVersion" : "2010-09-09",
+  "Description" : "WordPress",
+  "Parameters" : {
+    "Flavor" : {
+      "Description" : "Rackspace Cloud Server flavor",
+      "Type" : "String",
+      "Default" : "2",
+      "AllowedValues" : [ "2", "3", "4", "5", "6", "7", "8" ],
+      "ConstraintDescription" : "must be a valid Rackspace Cloud Server flavor"
+    },
+  },
+  "Resources" : {
+    "WebServer": {
+      "Type": "Rackspace::Cloud::Server",
+      "Properties": {
+        "ImageName"      : "Fedora 17 (Beefy Miracle)",
+        "ServerName"     : "Heat test",
+        "Flavor"         : "2",
+        "UserData"       : "wordpress"
+      }
+    }
+  }
+}
+'''
+
+
+class RackspaceCloudServerTest(HeatTestCase):
+    def setUp(self):
+        super(RackspaceCloudServerTest, self).setUp()
+        self.fc = fakes.FakeClient()
+        setup_dummy_db()
+        # Test environment may not have pyrax client library installed and if
+        # pyrax is not installed resource class would not be registered.
+        # So register resource provider class explicitly for unit testing.
+        resource._register_class("Rackspace::Cloud::Server",
+                                 cloud_server.CloudServer)
+
+        f2 = self.m.CreateMockAnything()
+        f2.id = '2'
+        f3 = self.m.CreateMockAnything()
+        f3.id = '3'
+        f4 = self.m.CreateMockAnything()
+        f4.id = '4'
+        f5 = self.m.CreateMockAnything()
+        f5.id = '5'
+        f6 = self.m.CreateMockAnything()
+        f6.id = '6'
+        f7 = self.m.CreateMockAnything()
+        f7.id = '7'
+        f8 = self.m.CreateMockAnything()
+        f8.id = '8'
+        self.flavors = [f2, f3, f4, f5, f6, f7, f8]
+
+    def _setup_test_stack(self, stack_name):
+        t = template_format.parse(wp_template)
+        template = parser.Template(t)
+        stack = parser.Stack(None, stack_name, template,
+                             environment.Environment({'Flavor': '2'}),
+                             stack_id=uuidutils.generate_uuid())
+        return (t, stack)
+
+    def _mock_ssh_sftp(self):
+        # SSH
+        self.m.StubOutWithMock(paramiko, "SSHClient")
+        self.m.StubOutWithMock(paramiko, "MissingHostKeyPolicy")
+        ssh = self.m.CreateMockAnything()
+        paramiko.SSHClient().AndReturn(ssh)
+        paramiko.MissingHostKeyPolicy()
+        ssh.set_missing_host_key_policy(None)
+        ssh.connect(mox.IgnoreArg(),
+                    key_filename=mox.IgnoreArg(),
+                    username='root')
+        stdin = self.m.CreateMockAnything()
+        stdout = self.m.CreateMockAnything()
+        stderr = self.m.CreateMockAnything()
+        stdout.read().AndReturn("stdout")
+        stderr.read().AndReturn("stderr")
+        ssh.exec_command(mox.IgnoreArg()).AndReturn((stdin, stdout, stderr))
+
+        # SFTP
+        self.m.StubOutWithMock(paramiko, "Transport")
+        transport = self.m.CreateMockAnything()
+        paramiko.Transport((mox.IgnoreArg(), 22)).AndReturn(transport)
+        transport.connect(hostkey=None, username="root", pkey=mox.IgnoreArg())
+        sftp = self.m.CreateMockAnything()
+        self.m.StubOutWithMock(paramiko, "SFTPClient")
+        paramiko.SFTPClient.from_transport(transport).AndReturn(sftp)
+        sftp_file = self.m.CreateMockAnything()
+        sftp.open(mox.IgnoreArg(), 'w').AndReturn(sftp_file)
+        sftp_file.write(mox.IgnoreArg())
+        sftp_file.close()
+        sftp_file = self.m.CreateMockAnything()
+        sftp.open(mox.IgnoreArg(), 'w').AndReturn(sftp_file)
+        sftp_file.write(mox.IgnoreArg())
+        sftp_file.close()
+
+    def _setup_test_cs(self, return_server, name):
+        stack_name = '%s_stack' % name
+        (t, stack) = self._setup_test_stack(stack_name)
+
+        server_name = "Heat test"
+        t['Resources']['WebServer']['Properties']['ServerName'] = server_name
+        cs_name = 'Fedora 17 (Beefy Miracle)'
+        t['Resources']['WebServer']['Properties']['ImageName'] = cs_name
+        t['Resources']['WebServer']['Properties']['Flavor'] = '2'
+
+        cs = cloud_server.CloudServer('%s_name' % name,
+                                      t['Resources']['WebServer'], stack)
+        cs.t = cs.stack.resolve_runtime_data(cs.t)
+
+        flavor = t['Resources']['WebServer']['Properties']['Flavor']
+
+        self.m.StubOutWithMock(self.fc.servers, 'create')
+        self.fc.servers.create(server_name, "1", flavor,
+                               files=mox.IgnoreArg()).AndReturn(return_server)
+        return_server.adminPass = "foobar"
+
+        self.m.StubOutWithMock(cloud_server.CloudServer, 'image_id')
+        cloud_server.CloudServer.image_id = "1"
+
+        self.m.StubOutWithMock(cloud_server.CloudServer, 'script')
+        cloud_server.CloudServer.script = "foobar"
+
+        self.m.StubOutWithMock(rackspace_resource.RackspaceResource, "nova")
+        rackspace_resource.RackspaceResource.nova().MultipleTimes()\
+                                                   .AndReturn(self.fc)
+
+        self._mock_ssh_sftp()
+        return cs
+
+    def _create_test_cs(self, return_server, name):
+        cs = self._setup_test_cs(return_server, name)
+
+        self.m.ReplayAll()
+        scheduler.TaskRunner(cs.create)()
+        return cs
+
+    def _update_test_cs(self, return_server, name):
+        self._mock_ssh_sftp()
+        self.m.StubOutWithMock(rackspace_resource.RackspaceResource, "nova")
+        rackspace_resource.RackspaceResource.nova().MultipleTimes()\
+                                                   .AndReturn(self.fc)
+
+    def test_cs_create(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._create_test_cs(return_server, 'test_cs_create')
+        # this makes sure the auto increment worked on cloud server creation
+        self.assertTrue(cs.id > 0)
+
+        expected_public = return_server.networks['public'][0]
+        expected_private = return_server.networks['private'][0]
+        self.assertEqual(cs.FnGetAtt('PublicIp'), expected_public)
+        self.assertEqual(cs.FnGetAtt('PrivateIp'), expected_private)
+        self.assertEqual(cs.FnGetAtt('PublicDnsName'), expected_public)
+        self.assertEqual(cs.FnGetAtt('PrivateDnsName'), expected_public)
+
+        self.m.VerifyAll()
+
+    def test_cs_create_with_image_name(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._setup_test_cs(return_server, 'test_cs_create_image_id')
+
+        self.m.ReplayAll()
+        scheduler.TaskRunner(cs.create)()
+
+        # this makes sure the auto increment worked on cloud server creation
+        self.assertTrue(cs.id > 0)
+
+        expected_public = return_server.networks['public'][0]
+        expected_private = return_server.networks['private'][0]
+        self.assertEqual(cs.FnGetAtt('PublicIp'), expected_public)
+        self.assertEqual(cs.FnGetAtt('PrivateIp'), expected_private)
+        self.assertEqual(cs.FnGetAtt('PublicDnsName'), expected_public)
+        self.assertEqual(cs.FnGetAtt('PrivateDnsName'), expected_public)
+        self.assertRaises(exception.InvalidTemplateAttribute,
+                          cs.FnGetAtt, 'foo')
+        self.m.VerifyAll()
+
+    def test_cs_create_image_name_err(self):
+        stack_name = 'test_cs_create_image_name_err_stack'
+        (t, stack) = self._setup_test_stack(stack_name)
+
+        # create a cloud server with non exist image name
+        t['Resources']['WebServer']['Properties']['ImageName'] = 'Slackware'
+
+        # Mock flavors
+        self.m.StubOutWithMock(cloud_server.CloudServer, "flavors")
+        cloud_server.CloudServer.flavors.__contains__('2').AndReturn(True)
+        cloud_server.CloudServer.script = None
+        self.m.ReplayAll()
+
+        cs = cloud_server.CloudServer('cs_create_image_err',
+                                      t['Resources']['WebServer'], stack)
+
+        self.assertEqual({'Error': "Image %s not supported." % 'Slackware'},
+                         cs.validate())
+
+        self.m.VerifyAll()
+
+    def test_cs_create_flavor_err(self):
+        """validate() should throw an if the Flavor is invalid."""
+        stack_name = 'test_cs_create_flavor_err_stack'
+        (t, stack) = self._setup_test_stack(stack_name)
+
+        # create a cloud server with non exist image name
+        t['Resources']['WebServer']['Properties']['Flavor'] = '1'
+
+        # Mock flavors
+        self.m.StubOutWithMock(cloud_server.CloudServer, "flavors")
+        flavors = ['2', '3', '4', '5', '6', '7', '8']
+        cloud_server.CloudServer.flavors = flavors
+        self.m.ReplayAll()
+
+        cs = cloud_server.CloudServer('cs_create_flavor_err',
+                                      t['Resources']['WebServer'], stack)
+
+        self.assertEqual({'Error': "Flavor not found."}, cs.validate())
+
+        self.m.VerifyAll()
+
+    def test_cs_create_delete(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._create_test_cs(return_server,
+                                  'test_cs_create_delete')
+        cs.resource_id = 1234
+
+        # this makes sure the auto-increment worked on cloud server creation
+        self.assertTrue(cs.id > 0)
+
+        self.m.StubOutWithMock(self.fc.client, 'get_servers_1234')
+        get = self.fc.client.get_servers_1234
+        get().AndRaise(novaclient.exceptions.NotFound(404))
+        mox.Replay(get)
+
+        cs.delete()
+        self.assertTrue(cs.resource_id is None)
+        self.assertEqual(cs.state, (cs.DELETE, cs.COMPLETE))
+        self.m.VerifyAll()
+
+    def test_cs_update_metadata(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._create_test_cs(return_server, 'test_cs_metadata_update')
+        self.m.UnsetStubs()
+        self._update_test_cs(return_server, 'test_cs_metadata_update')
+        self.m.ReplayAll()
+        update_template = copy.deepcopy(cs.t)
+        update_template['Metadata'] = {'test': 123}
+        self.assertEqual(None, cs.update(update_template))
+        self.assertEqual(cs.metadata, {'test': 123})
+
+    def test_cs_update_replace(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._create_test_cs(return_server, 'test_cs_update')
+
+        update_template = copy.deepcopy(cs.t)
+        update_template['Notallowed'] = {'test': 123}
+        self.assertRaises(resource.UpdateReplace, cs.update, update_template)
+
+    def test_cs_update_properties(self):
+        return_server = self.fc.servers.list()[1]
+        cs = self._create_test_cs(return_server, 'test_cs_update')
+
+        update_template = copy.deepcopy(cs.t)
+        update_template['Properties']['UserData'] = 'mustreplace'
+        self.assertRaises(resource.UpdateReplace,
+                          cs.update, update_template)
+
+    def test_cs_status_build(self):
+        return_server = self.fc.servers.list()[0]
+        cs = self._setup_test_cs(return_server, 'test_cs_status_build')
+        cs.resource_id = 1234
+
+        # Bind fake get method which cs.check_create_complete will call
+        def activate_status(server):
+            server.status = 'ACTIVE'
+        return_server.get = activate_status.__get__(return_server)
+        self.m.ReplayAll()
+
+        scheduler.TaskRunner(cs.create)()
+        self.assertEqual(cs.state, (cs.CREATE, cs.COMPLETE))
+
+    def test_cs_status_hard_reboot(self):
+        self._test_cs_status_not_build_active('HARD_REBOOT')
+
+    def test_cs_status_password(self):
+        self._test_cs_status_not_build_active('PASSWORD')
+
+    def test_cs_status_reboot(self):
+        self._test_cs_status_not_build_active('REBOOT')
+
+    def test_cs_status_rescue(self):
+        self._test_cs_status_not_build_active('RESCUE')
+
+    def test_cs_status_resize(self):
+        self._test_cs_status_not_build_active('RESIZE')
+
+    def test_cs_status_revert_resize(self):
+        self._test_cs_status_not_build_active('REVERT_RESIZE')
+
+    def test_cs_status_shutoff(self):
+        self._test_cs_status_not_build_active('SHUTOFF')
+
+    def test_cs_status_suspended(self):
+        self._test_cs_status_not_build_active('SUSPENDED')
+
+    def test_cs_status_verify_resize(self):
+        self._test_cs_status_not_build_active('VERIFY_RESIZE')
+
+    def _test_cs_status_not_build_active(self, uncommon_status):
+        return_server = self.fc.servers.list()[0]
+        cs = self._setup_test_cs(return_server, 'test_cs_status_build')
+        cs.resource_id = 1234
+
+        # Bind fake get method which cs.check_create_complete will call
+        def activate_status(server):
+            if hasattr(server, '_test_check_iterations'):
+                server._test_check_iterations += 1
+            else:
+                server._test_check_iterations = 1
+            if server._test_check_iterations == 1:
+                server.status = uncommon_status
+            if server._test_check_iterations > 2:
+                server.status = 'ACTIVE'
+        return_server.get = activate_status.__get__(return_server)
+        self.m.ReplayAll()
+
+        scheduler.TaskRunner(cs.create)()
+        self.assertEqual(cs.state, (cs.CREATE, cs.COMPLETE))
+
+        self.m.VerifyAll()
+
+    def mock_get_ip(self, cs):
+        self.m.UnsetStubs()
+        self.m.StubOutWithMock(cloud_server.CloudServer, "server")
+        cloud_server.CloudServer.server = cs
+        self.m.ReplayAll()
+
+    def test_cs_get_ip(self):
+        stack_name = 'test_cs_get_ip_err'
+        (t, stack) = self._setup_test_stack(stack_name)
+        cs = cloud_server.CloudServer('cs_create_image_err',
+                                      t['Resources']['WebServer'],
+                                      stack)
+        cs.addresses = {'public': [{'version': 4, 'addr': '4.5.6.7'},
+                                   {'version': 6, 'addr': 'fake:ip::6'}],
+                        'private': [{'version': 4, 'addr': '10.13.12.13'}]}
+        self.mock_get_ip(cs)
+        self.assertEqual(cs.public_ip, '4.5.6.7')
+        self.mock_get_ip(cs)
+        self.assertEqual(cs.private_ip, '10.13.12.13')
+
+        cs.addresses = {'public': [],
+                        'private': []}
+        self.mock_get_ip(cs)
+        self.assertRaises(exception.ResourceFailure, cs._get_ip, 'public')
+
+    def test_private_key(self):
+        stack_name = 'test_private_key'
+        (t, stack) = self._setup_test_stack(stack_name)
+        cs = cloud_server.CloudServer('cs_private_key',
+                                      t['Resources']['WebServer'],
+                                      stack)
+
+        # This gives the fake cloud server an id and created_time attribute
+        cs._store_or_update(cs.CREATE, cs.IN_PROGRESS, 'test_store')
+
+        cs.private_key = 'fake private key'
+        rs = db_api.resource_get_by_name_and_stack(None,
+                                                   'cs_private_key',
+                                                   stack.id)
+        encrypted_key = rs.data[0]['value']
+        self.assertNotEqual(encrypted_key, "fake private key")
+        decrypted_key = cs.private_key
+        self.assertEqual(decrypted_key, "fake private key")
index b6b34455e93af375a0544df1ed7a2f2488370030..200108a46d74c383a42e1e8122e49e523800aa4e 100644 (file)
@@ -1,6 +1,6 @@
 d2to1>=0.2.10,<0.3
 pbr>=0.5.10,<0.6
-PyCrypto>=2.1.0
+PyCrypto>=2.6
 boto>=2.4.0
 eventlet>=0.12.0
 greenlet>=0.3.2
@@ -22,3 +22,4 @@ python-quantumclient>=2.2.0
 python-cinderclient>=1.0.4
 PyYAML>=3.1.0
 oslo.config>=1.1.0
+paramiko>=1.8.0
index 3e442e051c435a11d1940c63d1078c721b6e6783..6d84efdadd37d588329eabab4f72b179364ce9f9 100644 (file)
@@ -9,7 +9,6 @@ discover
 mox==0.5.3
 testtools>=0.9.29
 testrepository>=0.0.13
-paramiko
 python-glanceclient
 sphinx>=1.1.2
 Babel