From 2684f2bb4cda1b1a23ce596fcdb476bb961ea3f8 Mon Sep 17 00:00:00 2001 From: Jason Dunsmore Date: Mon, 22 Jul 2013 11:00:51 -0500 Subject: [PATCH] Add resource for Rackspace Cloud Servers. Blueprint rackspace-cloud-servers-provider Change-Id: Ie6659e0e8b519180ce5973cc798c914b56a95426 --- heat/api/openstack/v1/util.py | 3 + heat/common/exception.py | 4 + heat/engine/resources/instance.py | 9 +- .../resources/rackspace/cloud_server.py | 409 ++++++++++++++++++ heat/tests/test_instance.py | 2 +- heat/tests/test_rackspace_cloud_server.py | 409 ++++++++++++++++++ requirements.txt | 3 +- test-requirements.txt | 1 - 8 files changed, 836 insertions(+), 4 deletions(-) create mode 100644 heat/engine/resources/rackspace/cloud_server.py create mode 100644 heat/tests/test_rackspace_cloud_server.py diff --git a/heat/api/openstack/v1/util.py b/heat/api/openstack/v1/util.py index 7e6d1491..a344154f 100644 --- a/heat/api/openstack/v1/util.py +++ b/heat/api/openstack/v1/util.py @@ -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, } diff --git a/heat/common/exception.py b/heat/common/exception.py index ec607694..0fc68e9a 100644 --- a/heat/common/exception.py +++ b/heat/common/exception.py @@ -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.") diff --git a/heat/engine/resources/instance.py b/heat/engine/resources/instance.py index c792eed8..9e588e81 100644 --- a/heat/engine/resources/instance.py +++ b/heat/engine/resources/instance.py @@ -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 index 00000000..2adac132 --- /dev/null +++ b/heat/engine/resources/rackspace/cloud_server.py @@ -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 {} diff --git a/heat/tests/test_instance.py b/heat/tests/test_instance.py index 66192719..b91552b0 100644 --- a/heat/tests/test_instance.py +++ b/heat/tests/test_instance.py @@ -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 index 00000000..b811b08e --- /dev/null +++ b/heat/tests/test_rackspace_cloud_server.py @@ -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") diff --git a/requirements.txt b/requirements.txt index b6b34455..200108a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/test-requirements.txt b/test-requirements.txt index 3e442e05..6d84efda 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -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 -- 2.45.2