From 5a4b14e51b96e776e4d6e5dc019a369d574e1693 Mon Sep 17 00:00:00 2001 From: Jason Dunsmore Date: Mon, 27 Jan 2014 14:51:46 -0600 Subject: [PATCH] Native Nova Server compatibility for Cloud Server Make the Rackspace Cloud Server resource compatible with the native Nova Server resource so that a template author can replace OS::Nova::Server with Rackspace::Cloud::Server, without making any other changes, to make the resource use Rackspace Cloud Servers instead of Nova. Blueprint native-nova-cloud-server Change-Id: I312ddc25e02843b0116b8a2cb88defef05ca88ac --- contrib/rackspace/resources/cloud_server.py | 603 ++++++-------- .../tests/test_rackspace_cloud_server.py | 764 ++++++++++-------- heat/engine/resources/nova_utils.py | 8 + heat/engine/resources/server.py | 6 +- heat/tests/test_nova_utils.py | 22 + heat/tests/v1_1/fakes.py | 3 +- 6 files changed, 685 insertions(+), 721 deletions(-) diff --git a/contrib/rackspace/resources/cloud_server.py b/contrib/rackspace/resources/cloud_server.py index 854bba3990..6bff9f3f24 100644 --- a/contrib/rackspace/resources/cloud_server.py +++ b/contrib/rackspace/resources/cloud_server.py @@ -11,97 +11,36 @@ # under the License. import socket +import copy import tempfile -import json import paramiko from Crypto.PublicKey import RSA -import novaclient.exceptions as novaexception from heat.common import exception +from heat.engine.resources import nova_utils +from heat.engine.resources import server +from heat.db.sqlalchemy import api as db_api from heat.openstack.common import log as logging from heat.openstack.common.gettextutils import _ -from heat.engine import properties -from heat.engine import scheduler -from heat.engine.resources import instance -from heat.engine.resources import nova_utils -from heat.db.sqlalchemy import api as db_api try: import pyrax # noqa except ImportError: - def resource_mapping(): return {} else: - def resource_mapping(): return {'Rackspace::Cloud::Server': CloudServer} logger = logging.getLogger(__name__) -class CloudServer(instance.Instance): +class CloudServer(server.Server): """Resource for Rackspace Cloud Servers.""" - PROPERTIES = ( - FLAVOR, IMAGE, USER_DATA, KEY_NAME, VOLUMES, NAME, - ) = ( - 'flavor', 'image', 'user_data', 'key_name', 'Volumes', 'name', - ) - - properties_schema = { - FLAVOR: properties.Schema( - properties.Schema.STRING, - required=True, - update_allowed=True - ), - IMAGE: properties.Schema( - properties.Schema.STRING, - required=True - ), - USER_DATA: properties.Schema( - properties.Schema.STRING - ), - KEY_NAME: properties.Schema( - properties.Schema.STRING - ), - VOLUMES: properties.Schema( - properties.Schema.LIST, - default=[] - ), - NAME: properties.Schema( - properties.Schema.STRING - ), - } - - 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 || cloud-init init -bash -x /var/lib/cloud/data/cfn-userdata > /root/cfn-userdata.log 2>&1 || -exit 42 -""" - - # - Ubuntu 12.04: Verified working - ubuntu_script = base_script % """\ + SCRIPT_INSTALL_REQUIREMENTS = { + 'ubuntu': """ apt-get update export DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confdef" -o \ @@ -109,22 +48,13 @@ apt-get install -y -o Dpkg::Options::="--force-confdef" -o \ python-dev pip install heat-cfntools cfn-create-aws-symlinks --source /usr/local/bin -""" - - # - Fedora 17: Verified working - # - Fedora 18: Not working. selinux needs to be in "Permissive" - # mode for cloud-init to work. It's disabled by default in the - # Rackspace Cloud Servers image. To enable selinux, a reboot is - # required. - # - Fedora 19: Verified working - fedora_script = base_script % """\ +""", + 'fedora': """ yum install -y cloud-init python-boto python-pip gcc python-devel pip-python install heat-cfntools cfn-create-aws-symlinks -""" - - # - Centos 6.4: Verified working - centos_script = base_script % """\ +""", + 'centos': """ if ! (yum repolist 2> /dev/null | egrep -q "^[\!\*]?epel "); then rpm -ivh http://mirror.rackspace.com/epel/6/i386/epel-release-6-8.noarch.rpm @@ -132,10 +62,8 @@ fi yum install -y cloud-init python-boto python-pip gcc python-devel \ python-argparse pip-python install heat-cfntools -""" - - # - RHEL 6.4: Verified working - rhel_script = base_script % """\ +""", + 'rhel': """ if ! (yum repolist 2> /dev/null | egrep -q "^[\!\*]?epel "); then rpm -ivh http://mirror.rackspace.com/epel/6/i386/epel-release-6-8.noarch.rpm @@ -146,9 +74,8 @@ yum install -y cloud-init python-boto python-pip gcc python-devel \ python-argparse pip-python install heat-cfntools cfn-create-aws-symlinks -""" - - debian_script = base_script % """\ +""", + 'debian': """ echo "deb http://mirror.rackspace.com/debian wheezy-backports main" >> \ /etc/apt/sources.list apt-get update @@ -157,44 +84,46 @@ export DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confdef" -o \ Dpkg::Options::="--force-confold" python-pip gcc python-dev pip install heat-cfntools +"""} + + SCRIPT_CREATE_DATA_SOURCE = """ +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/* """ - # - Arch 2013.6: Not working (deps not in default package repos) - # TODO(jason): Install cloud-init & other deps from third-party repos - arch_script = base_script % """\ -pacman -S --noconfirm python-pip gcc + SCRIPT_RUN_CLOUD_INIT = """ +cloud-init start || cloud-init init """ - # - Gentoo 13.2: Not working (deps not in default package repos) - # 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 + SCRIPT_RUN_CFN_USERDATA = """ +bash -x /var/lib/cloud/data/cfn-userdata > /root/cfn-userdata.log 2>&1 || + exit 42 """ - # - OpenSUSE 12.3: Not working (deps not in default package repos) - # 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 -""" + SCRIPT_ERROR_MSG = _("The %(path)s script exited with a non-zero exit " + "status. To see the error message, log into the " + "server and view %(log)s") - # List of supported Linux distros and their corresponding config scripts - image_scripts = {'arch': None, - 'centos': centos_script, - 'debian': None, - 'fedora': fedora_script, - 'gentoo': None, - 'opensuse': None, - 'rhel': rhel_script, - 'ubuntu': ubuntu_script} + # Managed Cloud automation statuses + MC_STATUS_IN_PROGRESS = 'In Progress' + MC_STATUS_COMPLETE = 'Complete' + MC_STATUS_BUILD_ERROR = 'Build Error' - script_error_msg = (_("The %(path)s script exited with a non-zero exit " - "status. To see the error message, log into the " - "server and view %(log)s")) + # RackConnect automation statuses + RC_STATUS_DEPLOYING = 'DEPLOYING' + RC_STATUS_DEPLOYED = 'DEPLOYED' + RC_STATUS_FAILED = 'FAILED' + RC_STATUS_UNPROCESSABLE = 'UNPROCESSABLE' - # Template keys supported for handle_update. Properties not - # listed here trigger an UpdateReplace - update_allowed_keys = ('Metadata', 'Properties') + attributes_schema = copy.deepcopy(server.Server.attributes_schema) + attributes_schema.update( + { + 'distro': _('The Linux distribution on the server.'), + 'privateIPv4': _('The private IPv4 address of the server.'), + } + ) def __init__(self, name, json_snippet, stack): super(CloudServer, self).__init__(name, json_snippet, stack) @@ -202,59 +131,60 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel self._private_key = None self._server = None self._distro = None - self._public_ip = None - self._private_ip = None - self._flavor = None self._image = None @property def server(self): - """Get the Cloud Server object.""" - if not self._server: - logger.debug(_("Calling nova().servers.get()")) + """Return the Cloud Server object.""" + if self._server is None: self._server = self.nova().servers.get(self.resource_id) return self._server @property def distro(self): - """Get the Linux distribution for this server.""" - if not self._distro: - logger.debug(_("Calling nova().images.get()")) + """Return the Linux distribution for this server.""" + image = self.properties.get(self.IMAGE) + if self._distro is None and image: image_data = self.nova().images.get(self.image) self._distro = image_data.metadata['os_distro'] return self._distro @property def script(self): - """Get the config script for the Cloud Server image.""" - return self.image_scripts[self.distro] + """ + Return the config script for the Cloud Server image. - @property - def flavor(self): - """Get the flavors from the API.""" - if not self._flavor: - flavor = self.properties[self.FLAVOR] - self._flavor = nova_utils.get_flavor_id(self.nova(), flavor) - return self._flavor + The config script performs the following steps: + 1) Install cloud-init + 2) Create cloud-init data source + 3) Run cloud-init + 4) If user_data_format is 'HEAT_CFNTOOLS', run cfn-userdata script + """ + base_script = (self.SCRIPT_INSTALL_REQUIREMENTS[self.distro] + + self.SCRIPT_CREATE_DATA_SOURCE + + self.SCRIPT_RUN_CLOUD_INIT) + userdata_format = self.properties.get(self.USER_DATA_FORMAT) + if userdata_format == 'HEAT_CFNTOOLS': + return base_script + self.SCRIPT_RUN_CFN_USERDATA + elif userdata_format == 'RAW': + return base_script @property def image(self): - if not self._image: - self._image = nova_utils.get_image_id(self.nova(), - self.properties[self.IMAGE]) + """Return the server's image ID.""" + image = self.properties.get(self.IMAGE) + if image and self._image is None: + self._image = nova_utils.get_image_id(self.nova(), image) return self._image @property def private_key(self): """Return the private SSH key for the resource.""" - if self._private_key: + if self._private_key is not None: 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 + self._private_key = db_api.resource_data_get(self, 'private_key') + return self._private_key @private_key.setter def private_key(self, private_key): @@ -263,49 +193,54 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel 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.""" - if ip_type in self.server.addresses: - for ip in self.server.addresses[ip_type]: - if ip['version'] == 4: - return ip['addr'] - - raise exception.Error(_("Could not determine the %(ip)s IP of " - "%(image)s.") % - {'ip': ip_type, - 'image': self.properties[self.IMAGE]}) - - @property - def public_ip(self): - """Return the public IP of the Cloud Server.""" - if not self._public_ip: - self._public_ip = self._get_ip('public') - return self._public_ip - - @property - def private_ip(self): - """Return the private IP of the Cloud Server.""" - if not self._private_ip: - self._private_ip = self._get_ip('private') - return self._private_ip - @property def has_userdata(self): - if self.properties[self.USER_DATA] or self.metadata != {}: + """Return True if the server has user_data, False otherwise.""" + user_data = self.properties.get(self.USER_DATA) + if user_data or self.metadata != {}: return True else: return False def validate(self): """Validate user parameters.""" - self.flavor - self.image + image = self.properties.get(self.IMAGE) # It's okay if there's no script, as long as user_data and - # metadata are empty - if not self.script and self.has_userdata: - return {'Error': "user_data/metadata are not supported for image" - " %s." % self.properties[self.IMAGE]} + # metadata are both empty + if image and self.script is None and self.has_userdata: + msg = _("user_data is not supported for image %s.") % image + raise exception.StackValidationFailed(message=msg) + + # Validate that the personality does not contain a reserved + # key and that the number of personalities does not exceed the + # Rackspace limit. + personality = self.properties.get(self.PERSONALITY) + if personality: + limits = nova_utils.absolute_limits(self.nova()) + + # One personality will be used for an SSH key + personality_limit = limits['maxPersonality'] - 1 + + if "/root/.ssh/authorized_keys" in personality: + msg = _('The personality property may not contain a key ' + 'of "/root/.ssh/authorized_keys"') + raise exception.StackValidationFailed(message=msg) + + elif len(personality) > personality_limit: + msg = _("The personality property may not contain greater " + "than %s entries.") % personality_limit + raise exception.StackValidationFailed(message=msg) + + super(CloudServer, self).validate() + + # Validate that user_data is passed for servers with bootable + # volumes AFTER validating that the server has either an image + # or a bootable volume in Server.validate() + if not image and self.has_userdata: + msg = _("user_data scripts are not supported with bootable " + "volumes.") + raise exception.StackValidationFailed(message=msg) def _run_ssh_command(self, command): """Run a shell command on the Cloud Server via SSH.""" @@ -314,7 +249,7 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel private_key_file.seek(0) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy()) - ssh.connect(self.public_ip, + ssh.connect(self.server.accessIPv4, username="root", key_filename=private_key_file.name) chan = ssh.get_transport().open_session() @@ -324,8 +259,8 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel # The channel timeout only works for read/write operations chan.recv(1024) except socket.timeout: - raise exception.Error("SSH command timed out after %s minutes" - % self.stack.timeout_mins) + raise exception.Error(_("SSH command timed out after %s " + "minutes") % self.stack.timeout_mins) else: return chan.recv_exit_status() finally: @@ -338,7 +273,7 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel 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 = paramiko.Transport((self.server.accessIPv4, 22)) transport.connect(hostkey=None, username="root", pkey=pkey) sftp = paramiko.SFTPClient.from_transport(transport) try: @@ -352,221 +287,133 @@ zypper --non-interactive in cloud-init python-boto python-pip gcc python-devel sftp.close() transport.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. - """ - # Generate SSH public/private keypair + def _personality(self): + # Generate SSH public/private keypair for the engine to use if self._private_key is not None: rsa = RSA.importKey(self._private_key) else: rsa = RSA.generate(1024) self.private_key = rsa.exportKey() public_keys = [rsa.publickey().exportKey('OpenSSH')] - if self.properties.get(self.KEY_NAME): - key_name = self.properties[self.KEY_NAME] - public_keys.append(nova_utils.get_keypair(self.nova(), - key_name).public_key) - personality_files = { - "/root/.ssh/authorized_keys": '\n'.join(public_keys)} - # Create server - client = self.nova().servers - logger.debug(_("Calling nova().servers.create()")) - server = client.create(self.physical_resource_name(), - self.image, - self.flavor, - files=personality_files) + # Add the user-provided key_name to the authorized_keys file + key_name = self.properties.get(self.KEY_NAME) + if key_name: + user_keypair = nova_utils.get_keypair(self.nova(), key_name) + public_keys.append(user_keypair.public_key) + personality = {"/root/.ssh/authorized_keys": '\n'.join(public_keys)} - # Save resource ID to db - self.resource_id_set(server.id) + # Add any user-provided personality files + user_personality = self.properties.get(self.PERSONALITY) + if user_personality: + personality.update(user_personality) - return server, scheduler.TaskRunner(self._attach_volumes_task()) + return personality - 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 _key_name(self): + return None - def _attach_volume(self, volume_id, device): - logger.debug(_("Calling nova().volumes.create_server_volume()")) - 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 super(CloudServer, self).check_create_complete(cookie): + def _check_managed_cloud_complete(self, server): + if 'rax_service_level_automation' not in server.metadata: + logger.debug(_("Managed Cloud server does not have the " + "rax_service_level_automation metadata tag yet")) + return False + + mc_status = server.metadata['rax_service_level_automation'] + logger.debug(_("Managed Cloud automation status: ") + mc_status) + + if mc_status == self.MC_STATUS_IN_PROGRESS: + return False + + elif mc_status == self.MC_STATUS_COMPLETE: + return True + + elif mc_status == self.MC_STATUS_BUILD_ERROR: + raise exception.Error(_("Managed Cloud automation failed")) + + else: + raise exception.Error(_("Unknown Managed Cloud automation " + "status: ") + mc_status) + + def _check_rack_connect_complete(self, server): + if 'rackconnect_automation_status' not in server.metadata: + logger.debug(_("RackConnect server does not have the " + "rackconnect_automation_status metadata tag yet")) + return False + + rc_status = server.metadata['rackconnect_automation_status'] + logger.debug(_("RackConnect automation status: ") + rc_status) + + if rc_status == self.RC_STATUS_DEPLOYING: + return False + + elif rc_status == self.RC_STATUS_DEPLOYED: + self._server = None # The public IP changed, forget old one + return True + + elif rc_status == self.RC_STATUS_UNPROCESSABLE: + # UNPROCESSABLE means the RackConnect automation was not + # attempted (eg. Cloud Server in a different DC than + # dedicated gear, so RackConnect does not apply). It is + # okay if we do not raise an exception. + reason = server.metadata.get('rackconnect_unprocessable_reason', + None) + if reason is not None: + logger.warning(_("RackConnect unprocessable reason: ") + + reason) + return True + + elif rc_status == self.RC_STATUS_FAILED: + raise exception.Error(_("RackConnect automation FAILED")) + + else: + raise exception.Error(_("Unknown RackConnect automation status: ") + + rc_status) + + def _run_userdata(self): + # Create heat-script and userdata files on server + raw_userdata = self.properties[self.USER_DATA] + userdata = nova_utils.build_userdata(self, 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 + cmd = "bash -ex /root/heat-script.sh > /root/heat-script.log 2>&1" + exit_code = self._run_ssh_command(cmd) + if exit_code == 42: + raise exception.Error(self.SCRIPT_ERROR_MSG % + {'path': "cfn-userdata", + 'log': "/root/cfn-userdata.log"}) + elif exit_code != 0: + raise exception.Error(self.SCRIPT_ERROR_MSG % + {'path': "heat-script.sh", + 'log': "/root/heat-script.log"}) + + def check_create_complete(self, server): + """Check if server creation is complete and handle server configs.""" + if not self._check_active(server): return False - server = cookie[0] server.get() - if 'rack_connect' in self.context.roles: # Account has RackConnect - if 'rackconnect_automation_status' not in server.metadata: - logger.debug(_("RackConnect server does not have the " - "rackconnect_automation_status metadata tag " - "yet")) - return False - rc_status = server.metadata['rackconnect_automation_status'] - logger.debug(_("RackConnect automation status: ") + rc_status) + if 'rack_connect' in self.context.roles and not \ + self._check_rack_connect_complete(server): + return False - if rc_status == 'DEPLOYING': - return False - - elif rc_status == 'DEPLOYED': - self._public_ip = None # The public IP changed, forget old one - - elif rc_status == 'FAILED': - raise exception.Error(_("RackConnect automation FAILED")) - - elif rc_status == 'UNPROCESSABLE': - reason = server.metadata.get( - "rackconnect_unprocessable_reason", None) - if reason is not None: - logger.warning(_("RackConnect unprocessable reason: ") - + reason) - # UNPROCESSABLE means the RackConnect automation was - # not attempted (eg. Cloud Server in a different DC - # than dedicated gear, so RackConnect does not apply). - # It is okay if we do not raise an exception. - - else: - raise exception.Error(_("Unknown RackConnect automation " - "status: ") + rc_status) - - if 'rax_managed' in self.context.roles: # Managed Cloud account - if 'rax_service_level_automation' not in server.metadata: - logger.debug(_("Managed Cloud server does not have the " - "rax_service_level_automation metadata tag yet")) - return False - - mc_status = server.metadata['rax_service_level_automation'] - logger.debug(_("Managed Cloud automation status: ") + mc_status) - - if mc_status == 'In Progress': - return False - - elif mc_status == 'Complete': - pass - - elif mc_status == 'Build Error': - raise exception.Error(_("Managed Cloud automation failed")) - - else: - raise exception.Error(_("Unknown Managed Cloud automation " - "status: ") + mc_status) + if 'rax_managed' in self.context.roles and not \ + self._check_managed_cloud_complete(server): + return False if self.has_userdata: - # Create heat-script and userdata files on server - raw_userdata = self.properties[self.USER_DATA] or '' - userdata = nova_utils.build_userdata(self, 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 - cmd = "bash -ex /root/heat-script.sh > /root/heat-script.log 2>&1" - exit_code = self._run_ssh_command(cmd) - if exit_code == 42: - raise exception.Error(self.script_error_msg % - {'path': "cfn-userdata", - 'log': "/root/cfn-userdata.log"}) - elif exit_code != 0: - raise exception.Error(self.script_error_msg % - {'path': "heat-script.sh", - 'log': "/root/heat-script.log"}) + self._run_userdata() 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 == "DELETED": - break - elif server.status == "ERROR": - raise exception.Error(_("Deletion of server %s failed.") % - server.name) - 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" - exit_code = self._run_ssh_command(command) - if exit_code != 0: - raise exception.Error(self.script_error_msg % - {'path': "cfn-userdata", - 'log': "/root/cfn-userdata.log"}) - - if self.FLAVOR in prop_diff: - flav = json_snippet['Properties'][self.FLAVOR] - new_flavor = nova_utils.get_flavor_id(self.nova(), flav) - self.server.resize(new_flavor) - resize = scheduler.TaskRunner(nova_utils.check_resize, - self.server, - flav) - resize.start() - return resize - - 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) + def _resolve_attribute(self, name): + if name == 'distro': + return self.distro + if name == 'privateIPv4': + return nova_utils.get_ip(self.server, 'private', 4) + return super(CloudServer, self)._resolve_attribute(name) diff --git a/contrib/rackspace/tests/test_rackspace_cloud_server.py b/contrib/rackspace/tests/test_rackspace_cloud_server.py index 9c799d0499..cbd2dbb217 100644 --- a/contrib/rackspace/tests/test_rackspace_cloud_server.py +++ b/contrib/rackspace/tests/test_rackspace_cloud_server.py @@ -10,24 +10,22 @@ # License for the specific language governing permissions and limitations # under the License. -import copy -import uuid - import mox import paramiko -import novaclient from heat.db import api as db_api +from heat.engine import environment from heat.tests.v1_1 import fakes -from heat.common import template_format from heat.common import exception +from heat.common import template_format +from heat.engine import clients from heat.engine import parser from heat.engine import resource from heat.engine import scheduler +from heat.openstack.common import uuidutils from heat.tests.common import HeatTestCase from heat.tests import utils -from heat.engine import clients from ..resources import cloud_server # noqa @@ -36,20 +34,19 @@ wp_template = ''' "AWSTemplateFormatVersion" : "2010-09-09", "Description" : "WordPress", "Parameters" : { - "flavor" : { - "Description" : "Rackspace Cloud Server flavor", + "key_name" : { + "Description" : "key_name", "Type" : "String", - "Default" : "m1.small", - "AllowedValues" : ["256 MB Server", "m1.small", "m1.large", "invalid"], - "ConstraintDescription" : "must be a valid Rackspace Cloud Server flavor" - }, + "Default" : "test" + } }, "Resources" : { "WebServer": { "Type": "Rackspace::Cloud::Server", "Properties": { - "image" : "CentOS 5.2", - "flavor" : "256 MB Server", + "image" : "CentOS 5.2", + "flavor" : "256 MB Server", + "key_name" : "test", "user_data" : "wordpress" } } @@ -75,9 +72,9 @@ gDZkz7KcZC7TkO0NYVRssA6/84mCqx6QHpKaYNG9kg== """ -class RackspaceCloudServerTest(HeatTestCase): +class CloudServersTest(HeatTestCase): def setUp(self): - super(RackspaceCloudServerTest, self).setUp() + super(CloudServersTest, self).setUp() self.fc = fakes.FakeClient() utils.setup_dummy_db() # Test environment may not have pyrax client library installed and if @@ -86,14 +83,6 @@ class RackspaceCloudServerTest(HeatTestCase): resource._register_class("Rackspace::Cloud::Server", cloud_server.CloudServer) - def _setup_test_stack(self, stack_name): - t = template_format.parse(wp_template) - template = parser.Template(t) - stack = parser.Stack(utils.dummy_context(), stack_name, template, - {}, - stack_id=str(uuid.uuid4())) - return (t, stack) - def _mock_ssh_sftp(self, exit_code=0): # SSH self.m.StubOutWithMock(paramiko, "SSHClient") @@ -132,343 +121,312 @@ class RackspaceCloudServerTest(HeatTestCase): sftp.close() transport.close() - def _setup_test_cs(self, return_server, name, exit_code=0): - stack_name = '%s_stack' % name + def _setup_test_stack(self, stack_name): + t = template_format.parse(wp_template) + template = parser.Template(t) + stack = parser.Stack(utils.dummy_context(), stack_name, template, + environment.Environment({'key_name': 'test'}), + stack_id=uuidutils.generate_uuid()) + return (t, stack) + + def _setup_test_server(self, return_server, name, image_id=None, + override_name=False, stub_create=True, exit_code=0): + stack_name = '%s_s' % name (t, stack) = self._setup_test_stack(stack_name) + t['Resources']['WebServer']['Properties']['image'] = \ + image_id or 'CentOS 5.2' + t['Resources']['WebServer']['Properties']['flavor'] = \ + '256 MB Server' + + server_name = '%s' % name + if override_name: + t['Resources']['WebServer']['Properties']['name'] = \ + server_name + + server = cloud_server.CloudServer(server_name, + t['Resources']['WebServer'], stack) + self.m.StubOutWithMock(cloud_server.CloudServer, "nova") cloud_server.CloudServer.nova().MultipleTimes().AndReturn(self.fc) + self.m.StubOutWithMock(clients.OpenStackClients, 'nova') + clients.OpenStackClients.nova().MultipleTimes().AndReturn(self.fc) - t['Resources']['WebServer']['Properties']['image'] = 'CentOS 5.2' - t['Resources']['WebServer']['Properties']['flavor'] = '256 MB Server' - - cs = cloud_server.CloudServer('%s_name' % name, - t['Resources']['WebServer'], stack) - cs._private_key = rsa_key - cs.t = cs.stack.resolve_runtime_data(cs.t) - - self.m.StubOutWithMock(self.fc.servers, 'create') - name_limit = cloud_server.CloudServer.physical_resource_name_limit - server_name = utils.PhysName(stack_name, cs.name, limit=name_limit) - self.fc.servers.create(server_name, 1, 1, - files=mox.IgnoreArg()).AndReturn(return_server) return_server.adminPass = "foobar" + server._private_key = rsa_key + server.t = server.stack.resolve_runtime_data(server.t) + + if stub_create: + self.m.StubOutWithMock(self.fc.servers, 'create') + self.fc.servers.create( + image=1, + flavor=1, + key_name=None, + name=override_name and server.name or utils.PhysName( + stack_name, server.name), + security_groups=[], + userdata=mox.IgnoreArg(), + scheduler_hints=None, + meta=None, + nics=None, + availability_zone=None, + block_device_mapping=None, + config_drive=None, + disk_config=None, + reservation_id=None, + files=mox.IgnoreArg()).AndReturn(return_server) self.m.StubOutWithMock(cloud_server.CloudServer, 'script') cloud_server.CloudServer.script = "foobar" self._mock_ssh_sftp(exit_code) - return cs - - def _create_test_cs(self, return_server, name, exit_code=0): - cs = self._setup_test_cs(return_server, name, exit_code) + return server + def _create_test_server(self, return_server, name, override_name=False, + stub_create=True, exit_code=0): + server = self._setup_test_server(return_server, name, + stub_create=stub_create, + exit_code=exit_code) self.m.ReplayAll() - scheduler.TaskRunner(cs.create)() - return cs + scheduler.TaskRunner(server.create)() + return server - def _update_test_cs(self, return_server, name, exit_code=0): + def _update_test_server(self, return_server, name, exit_code=0): self._mock_ssh_sftp(exit_code) - self.m.StubOutWithMock(clients.OpenStackClients, "nova") - clients.OpenStackClients.nova( - mox.IgnoreArg()).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(expected_public, cs.FnGetAtt('PublicIp')) - self.assertEqual(expected_private, cs.FnGetAtt('PrivateIp')) - self.assertEqual(expected_public, cs.FnGetAtt('PublicDnsName')) - self.assertEqual(expected_public, cs.FnGetAtt('PrivateDnsName')) - - 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(expected_public, cs.FnGetAtt('PublicIp')) - self.assertEqual(expected_private, cs.FnGetAtt('PrivateIp')) - self.assertEqual(expected_public, cs.FnGetAtt('PublicDnsName')) - self.assertEqual(expected_public, cs.FnGetAtt('PrivateDnsName')) - self.assertRaises(exception.InvalidTemplateAttribute, - cs.FnGetAtt, 'foo') - self.m.VerifyAll() - - def test_cs_create_image_name_err(self): self.m.StubOutWithMock(cloud_server.CloudServer, "nova") cloud_server.CloudServer.nova().MultipleTimes().AndReturn(self.fc) - stack_name = 'test_cs_create_image_name_err_stack' + + def _mock_metadata_os_distro(self): + image_data = self.m.CreateMockAnything() + image_data.metadata = {'os_distro': 'centos'} + self.m.StubOutWithMock(self.fc.images, 'get') + self.fc.images.get(mox.IgnoreArg()).MultipleTimes().\ + AndReturn(image_data) + + def test_script_raw_userdata(self): + stack_name = 'raw_userdata_s' (t, stack) = self._setup_test_stack(stack_name) - # create a cloud server with non exist image name - t['Resources']['WebServer']['Properties']['image'] = 'Slackware' + t['Resources']['WebServer']['Properties']['user_data_format'] = \ + 'RAW' - # Mock flavors - cloud_server.CloudServer.script = None + server = cloud_server.CloudServer('WebServer', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self._mock_metadata_os_distro() self.m.ReplayAll() - cs = cloud_server.CloudServer('cs_create_image_err', - t['Resources']['WebServer'], stack) - - self.assertRaises(exception.ImageNotFound, cs.validate) + self.assertNotIn("/var/lib/cloud/data/cfn-userdata", server.script) self.m.VerifyAll() - def test_cs_create_image_name_okay(self): + def test_script_cfntools_userdata(self): + stack_name = 'raw_userdata_s' + (t, stack) = self._setup_test_stack(stack_name) + + t['Resources']['WebServer']['Properties']['user_data_format'] = \ + 'HEAT_CFNTOOLS' + + server = cloud_server.CloudServer('WebServer', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self._mock_metadata_os_distro() + self.m.ReplayAll() + + self.assertIn("/var/lib/cloud/data/cfn-userdata", server.script) + self.m.VerifyAll() + + def test_validate_no_script_okay(self): + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create an server with non exist image Id + t['Resources']['WebServer']['Properties']['image'] = '1' + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self.m.StubOutWithMock(clients.OpenStackClients, 'nova') + clients.OpenStackClients.nova().MultipleTimes().AndReturn(self.fc) + + self.m.StubOutWithMock(server.__class__, 'script') + server.script = None + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = False + + self.m.StubOutWithMock(uuidutils, "is_uuid_like") + uuidutils.is_uuid_like('1').MultipleTimes().AndReturn(True) + self.m.ReplayAll() + + self.assertIsNone(server.validate()) + + self.m.VerifyAll() + + def test_validate_disallowed_personality(self): + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create an server with non exist image Id + t['Resources']['WebServer']['Properties']['personality'] = \ + {"/fake/path1": "fake contents1", + "/fake/path2": "fake_contents2", + "/fake/path3": "fake_contents3", + "/root/.ssh/authorized_keys": "fake_contents4"} + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server.__class__, 'script') + server.script = None + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = False + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self.m.ReplayAll() + + exc = self.assertRaises(exception.StackValidationFailed, + server.validate) + self.assertEqual("The personality property may not contain a " + "key of \"/root/.ssh/authorized_keys\"", str(exc)) + self.m.VerifyAll() + + def test_user_personality(self): + return_server = self.fc.servers.list()[1] + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create an server with non exist image Id + t['Resources']['WebServer']['Properties']['personality'] = \ + {"/fake/path1": "fake contents1", + "/fake/path2": "fake_contents2", + "/fake/path3": "fake_contents3"} + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server.__class__, 'script') + server.script = None + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = False + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self.m.StubOutWithMock(clients.OpenStackClients, 'nova') + clients.OpenStackClients.nova().MultipleTimes().AndReturn(self.fc) + self.m.ReplayAll() + + self.assertIsNone(server.validate()) + + expected_personality = {'/fake/path1': 'fake contents1', + '/fake/path3': 'fake_contents3', + '/fake/path2': 'fake_contents2', + '/root/.ssh/authorized_keys': mox.IgnoreArg()} + self.m.StubOutWithMock(self.fc.servers, 'create') + self.fc.servers.create( + image=1, flavor=1, key_name=None, + name=utils.PhysName(stack_name, server.name), + security_groups=[], + userdata=mox.IgnoreArg(), scheduler_hints=None, + meta=None, nics=None, availability_zone=None, + block_device_mapping=None, config_drive=None, + disk_config=None, reservation_id=None, + files=expected_personality).AndReturn(return_server) + + self.m.ReplayAll() + scheduler.TaskRunner(server.create)() + self.m.VerifyAll() + + def test_validate_no_script_not_okay(self): + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create a server with non-existent image ID + t['Resources']['WebServer']['Properties']['image'] = '1' + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server.__class__, 'script') + server.script = None + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = True + self.m.ReplayAll() + + exc = self.assertRaises(exception.StackValidationFailed, + server.validate) + self.assertIn("user_data is not supported", str(exc)) + self.m.VerifyAll() + + def test_validate_with_bootable_vol_and_userdata(self): + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create a server without an image + del t['Resources']['WebServer']['Properties']['image'] + t['Resources']['WebServer']['Properties']['block_device_mapping'] = \ + [{ + "device_name": u'vda', + "volume_id": "5d7e27da-6703-4f7e-9f94-1f67abef734c", + "delete_on_termination": False + }] + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = True + self.m.StubOutWithMock(cloud_server.CloudServer, "nova") cloud_server.CloudServer.nova().MultipleTimes().AndReturn(self.fc) - stack_name = 'test_cs_create_image_name_err_stack' - (t, stack) = self._setup_test_stack(stack_name) + self.m.StubOutWithMock(clients.OpenStackClients, 'nova') + clients.OpenStackClients.nova().MultipleTimes().AndReturn(self.fc) - # create a cloud server with non exist image name - t['Resources']['WebServer']['Properties']['image'] = 'CentOS 5.2' - t['Resources']['WebServer']['Properties']['user_data'] = '' - - cloud_server.CloudServer.script = None self.m.ReplayAll() - cs = cloud_server.CloudServer('cs_create_image_err', - t['Resources']['WebServer'], stack) - - self.assertIsNone(cs.validate()) + exc = self.assertRaises(exception.StackValidationFailed, + server.validate) + self.assertIn("user_data scripts are not supported with bootable " + "volumes", str(exc)) self.m.VerifyAll() - def test_cs_create_heatscript_nonzero_exit_status(self): - return_server = self.fc.servers.list()[1] - cs = self._setup_test_cs(return_server, 'test_cs_create_image_id', - exit_code=1) - self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) - exc = self.assertRaises(exception.ResourceFailure, create) - self.assertIn("The heat-script.sh script exited", str(exc)) - self.m.VerifyAll() - - def test_cs_create_cfnuserdata_nonzero_exit_status(self): - return_server = self.fc.servers.list()[1] - cs = self._setup_test_cs(return_server, 'test_cs_create_image_id', - exit_code=42) - self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) - exc = self.assertRaises(exception.ResourceFailure, create) - self.assertIn("The cfn-userdata script exited", str(exc)) - self.m.VerifyAll() - - def test_cs_update_cfnuserdata_nonzero_exit_status(self): - return_server = self.fc.servers.list()[1] - cs = self._create_test_cs(return_server, - 'test_cs_update_cfnuserdata_nonzero_exit') - self.m.UnsetStubs() - self._update_test_cs(return_server, - 'test_cs_update_cfnuserdata_nonzero_exit', - exit_code=1) - self.m.ReplayAll() - update_template = copy.deepcopy(cs.t) - update_template['Metadata'] = {'test': 123} - update = scheduler.TaskRunner(cs.update, update_template) - exc = self.assertRaises(exception.ResourceFailure, update) - self.assertIn("The cfn-userdata script exited", str(exc)) - - def test_cs_create_flavor_err(self): - """validate() should throw an if the flavor is invalid.""" - self.m.StubOutWithMock(cloud_server.CloudServer, "nova") - cloud_server.CloudServer.nova().MultipleTimes().AndReturn(self.fc) - 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'] = 'invalid' - - # Mock flavors - self.m.ReplayAll() - - cs = cloud_server.CloudServer('cs_create_flavor_err', - t['Resources']['WebServer'], stack) - - self.assertRaises(exception.FlavorMissing, 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) - - scheduler.TaskRunner(cs.delete)() - self.assertIsNone(cs.resource_id) - self.assertEqual((cs.DELETE, cs.COMPLETE), cs.state) - 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} - scheduler.TaskRunner(cs.update, update_template)() - self.assertEqual({'test': 123}, cs.metadata) - - 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} - updater = scheduler.TaskRunner(cs.update, update_template) - self.assertRaises(resource.UpdateReplace, updater) - - 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']['user_data'] = 'mustreplace' - updater = scheduler.TaskRunner(cs.update, update_template) - self.assertRaises(resource.UpdateReplace, updater) - - 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.CREATE, cs.COMPLETE), cs.state) - - 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.CREATE, cs.COMPLETE), cs.state) - - 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('4.5.6.7', cs.public_ip) - self.mock_get_ip(cs) - self.assertEqual('10.13.12.13', cs.private_ip) - - cs.addresses = {'public': [], - 'private': []} - self.mock_get_ip(cs) - self.assertRaises(exception.Error, 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) + server = cloud_server.CloudServer('server_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') + server._store_or_update(server.CREATE, server.IN_PROGRESS, + 'test_store') - cs.private_key = 'fake private key' + server.private_key = 'fake private key' self.ctx = utils.dummy_context() rs = db_api.resource_get_by_name_and_stack(self.ctx, - 'cs_private_key', + 'server_private_key', stack.id) encrypted_key = rs.data[0]['value'] self.assertNotEqual(encrypted_key, "fake private key") - # Test private_key property returns decrypted value - self.assertEqual("fake private key", cs.private_key) + decrypted_key = server.private_key + self.assertEqual("fake private key", decrypted_key) def test_rackconnect_deployed(self): return_server = self.fc.servers.list()[1] return_server.metadata = {'rackconnect_automation_status': 'DEPLOYED'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, 'test_rackconnect_deployed') - cs.context.roles = ['rack_connect'] + server = self._setup_test_server(return_server, + 'test_rackconnect_deployed') + server.context.roles = ['rack_connect'] self.m.ReplayAll() - scheduler.TaskRunner(cs.create)() - self.assertEqual('CREATE', cs.action) - self.assertEqual('COMPLETE', cs.status) + scheduler.TaskRunner(server.create)() + self.assertEqual('CREATE', server.action) + self.assertEqual('COMPLETE', server.status) self.m.VerifyAll() def test_rackconnect_failed(self): @@ -476,10 +434,11 @@ class RackspaceCloudServerTest(HeatTestCase): return_server.metadata = {'rackconnect_automation_status': 'FAILED'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, 'test_rackconnect_failed') - cs.context.roles = ['rack_connect'] + server = self._setup_test_server(return_server, + 'test_rackconnect_failed') + server.context.roles = ['rack_connect'] self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) + create = scheduler.TaskRunner(server.create) exc = self.assertRaises(exception.ResourceFailure, create) self.assertEqual('Error: RackConnect automation FAILED', str(exc)) @@ -491,13 +450,13 @@ class RackspaceCloudServerTest(HeatTestCase): 'Fake reason'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, - 'test_rackconnect_unprocessable') - cs.context.roles = ['rack_connect'] + server = self._setup_test_server(return_server, + 'test_rackconnect_unprocessable') + server.context.roles = ['rack_connect'] self.m.ReplayAll() - scheduler.TaskRunner(cs.create)() - self.assertEqual('CREATE', cs.action) - self.assertEqual('COMPLETE', cs.status) + scheduler.TaskRunner(server.create)() + self.assertEqual('CREATE', server.action) + self.assertEqual('COMPLETE', server.status) self.m.VerifyAll() def test_rackconnect_unknown(self): @@ -505,25 +464,97 @@ class RackspaceCloudServerTest(HeatTestCase): return_server.metadata = {'rackconnect_automation_status': 'FOO'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, 'test_rackconnect_unknown') - cs.context.roles = ['rack_connect'] + server = self._setup_test_server(return_server, + 'test_rackconnect_unknown') + server.context.roles = ['rack_connect'] self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) + create = scheduler.TaskRunner(server.create) exc = self.assertRaises(exception.ResourceFailure, create) self.assertEqual('Error: Unknown RackConnect automation status: FOO', str(exc)) - def test_managed_cloud_complete(self): - return_server = self.fc.servers.list()[1] - return_server.metadata = {'rax_service_level_automation': 'Complete'} - self.m.StubOutWithMock(return_server, 'get') - return_server.get() - cs = self._setup_test_cs(return_server, 'test_managed_cloud_complete') - cs.context.roles = ['rax_managed'] + def test_rackconnect_deploying(self): + return_server = self.fc.servers.list()[0] + server = self._setup_test_server(return_server, + 'srv_sts_bld') + server.resource_id = 1234 + server.context.roles = ['rack_connect'] + + check_iterations = [0] + + # Bind fake get method which check_create_complete will call + def activate_status(server): + check_iterations[0] += 1 + if check_iterations[0] == 1: + server.metadata['rackconnect_automation_status'] = 'DEPLOYING' + if check_iterations[0] == 2: + server.status = 'ACTIVE' + if check_iterations[0] > 3: + server.metadata['rackconnect_automation_status'] = 'DEPLOYED' + return_server.get = activate_status.__get__(return_server) self.m.ReplayAll() - scheduler.TaskRunner(cs.create)() - self.assertEqual('CREATE', cs.action) - self.assertEqual('COMPLETE', cs.status) + + scheduler.TaskRunner(server.create)() + self.assertEqual((server.CREATE, server.COMPLETE), server.state) + + self.m.VerifyAll() + + def test_rackconnect_no_status(self): + return_server = self.fc.servers.list()[0] + server = self._setup_test_server(return_server, + 'srv_sts_bld') + server.resource_id = 1234 + server.context.roles = ['rack_connect'] + + check_iterations = [0] + + # Bind fake get method which check_create_complete will call + def activate_status(server): + check_iterations[0] += 1 + if check_iterations[0] == 1: + server.status = 'ACTIVE' + if check_iterations[0] == 2: + server.metadata = {} + if check_iterations[0] > 2: + server.metadata['rackconnect_automation_status'] = 'DEPLOYED' + return_server.get = activate_status.__get__(return_server) + self.m.ReplayAll() + + scheduler.TaskRunner(server.create)() + self.assertEqual((server.CREATE, server.COMPLETE), server.state) + + self.m.VerifyAll() + + def test_managed_cloud_lifecycle(self): + return_server = self.fc.servers.list()[0] + server = self._setup_test_server(return_server, + 'srv_sts_bld') + server.resource_id = 1234 + server.context.roles = ['rack_connect', 'rax_managed'] + + check_iterations = [0] + + # Bind fake get method which check_create_complete will call + def activate_status(server): + check_iterations[0] += 1 + if check_iterations[0] == 1: + server.status = 'ACTIVE' + if check_iterations[0] == 2: + server.metadata = {'rackconnect_automation_status': 'DEPLOYED'} + if check_iterations[0] == 3: + server.metadata = { + 'rackconnect_automation_status': 'DEPLOYED', + 'rax_service_level_automation': 'In Progress'} + if check_iterations[0] > 3: + server.metadata = { + 'rackconnect_automation_status': 'DEPLOYED', + 'rax_service_level_automation': 'Complete'} + return_server.get = activate_status.__get__(return_server) + self.m.ReplayAll() + + scheduler.TaskRunner(server.create)() + self.assertEqual((server.CREATE, server.COMPLETE), server.state) + self.m.VerifyAll() def test_managed_cloud_build_error(self): @@ -532,11 +563,11 @@ class RackspaceCloudServerTest(HeatTestCase): 'Build Error'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, - 'test_managed_cloud_build_error') - cs.context.roles = ['rax_managed'] + server = self._setup_test_server(return_server, + 'test_managed_cloud_build_error') + server.context.roles = ['rax_managed'] self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) + create = scheduler.TaskRunner(server.create) exc = self.assertRaises(exception.ResourceFailure, create) self.assertEqual('Error: Managed Cloud automation failed', str(exc)) @@ -545,10 +576,61 @@ class RackspaceCloudServerTest(HeatTestCase): return_server.metadata = {'rax_service_level_automation': 'FOO'} self.m.StubOutWithMock(return_server, 'get') return_server.get() - cs = self._setup_test_cs(return_server, 'test_managed_cloud_unknown') - cs.context.roles = ['rax_managed'] + server = self._setup_test_server(return_server, + 'test_managed_cloud_unknown') + server.context.roles = ['rax_managed'] self.m.ReplayAll() - create = scheduler.TaskRunner(cs.create) + create = scheduler.TaskRunner(server.create) exc = self.assertRaises(exception.ResourceFailure, create) self.assertEqual('Error: Unknown Managed Cloud automation status: FOO', str(exc)) + + def test_create_heatscript_nonzero_exit_status(self): + return_server = self.fc.servers.list()[1] + server = self._setup_test_server(return_server, 'test_create_image_id', + exit_code=1) + self.m.ReplayAll() + create = scheduler.TaskRunner(server.create) + exc = self.assertRaises(exception.ResourceFailure, create) + self.assertIn("The heat-script.sh script exited", str(exc)) + self.m.VerifyAll() + + def test_create_cfnuserdata_nonzero_exit_status(self): + return_server = self.fc.servers.list()[1] + server = self._setup_test_server(return_server, 'test_create_image_id', + exit_code=42) + self.m.ReplayAll() + create = scheduler.TaskRunner(server.create) + exc = self.assertRaises(exception.ResourceFailure, create) + self.assertIn("The cfn-userdata script exited", str(exc)) + self.m.VerifyAll() + + def test_validate_too_many_personality_rackspace(self): + stack_name = 'srv_val' + (t, stack) = self._setup_test_stack(stack_name) + + # create an server with non exist image Id + t['Resources']['WebServer']['Properties']['personality'] = \ + {"/fake/path1": "fake contents1", + "/fake/path2": "fake_contents2", + "/fake/path3": "fake_contents3", + "/fake/path4": "fake_contents4", + "/fake/path5": "fake_contents5"} + server = cloud_server.CloudServer('server_create_image_err', + t['Resources']['WebServer'], stack) + + self.m.StubOutWithMock(server.__class__, 'script') + server.script = None + + self.m.StubOutWithMock(server.__class__, 'has_userdata') + server.has_userdata = False + + self.m.StubOutWithMock(server, 'nova') + server.nova().MultipleTimes().AndReturn(self.fc) + self.m.ReplayAll() + + exc = self.assertRaises(exception.StackValidationFailed, + server.validate) + self.assertEqual("The personality property may not contain " + "greater than 4 entries.", str(exc)) + self.m.VerifyAll() diff --git a/heat/engine/resources/nova_utils.py b/heat/engine/resources/nova_utils.py index fa2618105e..bbc9750a24 100644 --- a/heat/engine/resources/nova_utils.py +++ b/heat/engine/resources/nova_utils.py @@ -88,6 +88,14 @@ def get_image_id(nova_client, image_identifier): return image_id +def get_ip(server, net_type, ip_version): + """Return the server's IP of the given type and version.""" + if net_type in server.addresses: + for ip in server.addresses[net_type]: + if ip['version'] == ip_version: + return ip['addr'] + + def get_flavor_id(nova_client, flavor): ''' Get the id for the specified flavor name. diff --git a/heat/engine/resources/server.py b/heat/engine/resources/server.py index 934b3ee682..4da1e7ed62 100644 --- a/heat/engine/resources/server.py +++ b/heat/engine/resources/server.py @@ -281,6 +281,10 @@ class Server(resource.Resource): # This method is overridden by the derived CloudServer resource return self.properties.get(self.PERSONALITY) + def _key_name(self): + # This method is overridden by the derived CloudServer resource + return self.properties.get(self.KEY_NAME) + def handle_create(self): security_groups = self.properties.get(self.SECURITY_GROUPS) @@ -318,7 +322,7 @@ class Server(resource.Resource): name=self.physical_resource_name(), image=image, flavor=flavor_id, - key_name=self.properties[self.KEY_NAME], + key_name=self._key_name(), security_groups=security_groups, userdata=userdata, meta=instance_meta, diff --git a/heat/tests/test_nova_utils.py b/heat/tests/test_nova_utils.py index dec3045ab7..a9be7f4711 100644 --- a/heat/tests/test_nova_utils.py +++ b/heat/tests/test_nova_utils.py @@ -50,6 +50,28 @@ class NovaUtilsTests(HeatTestCase): self.nova_client, 'noimage') self.m.VerifyAll() + def test_get_ip(self): + my_image = self.m.CreateMockAnything() + my_image.addresses = { + 'public': [{'version': 4, + 'addr': '4.5.6.7'}, + {'version': 6, + 'addr': '2401:1801:7800:0101:c058:dd33:ff18:04e6'}], + 'private': [{'version': 4, + 'addr': '10.13.12.13'}]} + + expected = '4.5.6.7' + observed = nova_utils.get_ip(my_image, 'public', 4) + self.assertEqual(expected, observed) + + expected = '10.13.12.13' + observed = nova_utils.get_ip(my_image, 'private', 4) + self.assertEqual(expected, observed) + + expected = '2401:1801:7800:0101:c058:dd33:ff18:04e6' + observed = nova_utils.get_ip(my_image, 'public', 6) + self.assertEqual(expected, observed) + def test_get_flavor_id(self): """Tests the get_flavor_id function.""" flav_id = str(uuid.uuid4()) diff --git a/heat/tests/v1_1/fakes.py b/heat/tests/v1_1/fakes.py index 5167c079dd..0e7e918043 100644 --- a/heat/tests/v1_1/fakes.py +++ b/heat/tests/v1_1/fakes.py @@ -355,7 +355,8 @@ class FakeHTTPClient(base_client.HTTPClient): # def get_os_keypairs(self, *kw): return (200, {"keypairs": [{'fingerprint': 'FAKE_KEYPAIR', - 'name': 'test'}]}) + 'name': 'test', + 'public_key': 'foo'}]}) def get_os_availability_zone(self, *kw): return (200, {"availabilityZoneInfo": [{'zoneName': 'nova1'}]})