98003258a8
Change-Id: I6be9c3d46c3c763468b6e9eb2250eecfd6308c79
1080 lines
43 KiB
Python
1080 lines
43 KiB
Python
#
|
|
# 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 uuid
|
|
|
|
from oslo.config import cfg
|
|
|
|
from heat.common import exception
|
|
from heat.engine import attributes
|
|
from heat.engine import constraints
|
|
from heat.engine import properties
|
|
from heat.engine import resource
|
|
from heat.engine.resources.neutron import subnet
|
|
from heat.engine.resources import nova_utils
|
|
from heat.engine.resources.software_config import software_config as sc
|
|
from heat.engine import scheduler
|
|
from heat.engine import stack_user
|
|
from heat.engine import support
|
|
from heat.openstack.common.gettextutils import _
|
|
from heat.openstack.common import log as logging
|
|
from heat.openstack.common import uuidutils
|
|
|
|
cfg.CONF.import_opt('instance_user', 'heat.common.config')
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class Server(stack_user.StackUser):
|
|
|
|
PROPERTIES = (
|
|
NAME, IMAGE, BLOCK_DEVICE_MAPPING, FLAVOR,
|
|
FLAVOR_UPDATE_POLICY, IMAGE_UPDATE_POLICY, KEY_NAME,
|
|
ADMIN_USER, AVAILABILITY_ZONE, SECURITY_GROUPS, NETWORKS,
|
|
SCHEDULER_HINTS, METADATA, USER_DATA_FORMAT, USER_DATA,
|
|
RESERVATION_ID, CONFIG_DRIVE, DISK_CONFIG, PERSONALITY,
|
|
ADMIN_PASS, SOFTWARE_CONFIG_TRANSPORT
|
|
) = (
|
|
'name', 'image', 'block_device_mapping', 'flavor',
|
|
'flavor_update_policy', 'image_update_policy', 'key_name',
|
|
'admin_user', 'availability_zone', 'security_groups', 'networks',
|
|
'scheduler_hints', 'metadata', 'user_data_format', 'user_data',
|
|
'reservation_id', 'config_drive', 'diskConfig', 'personality',
|
|
'admin_pass', 'software_config_transport'
|
|
)
|
|
|
|
_BLOCK_DEVICE_MAPPING_KEYS = (
|
|
BLOCK_DEVICE_MAPPING_DEVICE_NAME, BLOCK_DEVICE_MAPPING_VOLUME_ID,
|
|
BLOCK_DEVICE_MAPPING_SNAPSHOT_ID,
|
|
BLOCK_DEVICE_MAPPING_VOLUME_SIZE,
|
|
BLOCK_DEVICE_MAPPING_DELETE_ON_TERM,
|
|
) = (
|
|
'device_name', 'volume_id',
|
|
'snapshot_id',
|
|
'volume_size',
|
|
'delete_on_termination',
|
|
)
|
|
|
|
_NETWORK_KEYS = (
|
|
NETWORK_UUID, NETWORK_ID, NETWORK_FIXED_IP, NETWORK_PORT,
|
|
) = (
|
|
'uuid', 'network', 'fixed_ip', 'port',
|
|
)
|
|
|
|
_SOFTWARE_CONFIG_FORMATS = (
|
|
HEAT_CFNTOOLS, RAW, SOFTWARE_CONFIG
|
|
) = (
|
|
'HEAT_CFNTOOLS', 'RAW', 'SOFTWARE_CONFIG'
|
|
)
|
|
|
|
_SOFTWARE_CONFIG_TRANSPORTS = (
|
|
POLL_SERVER_CFN, POLL_SERVER_HEAT
|
|
) = (
|
|
'POLL_SERVER_CFN', 'POLL_SERVER_HEAT'
|
|
)
|
|
|
|
ATTRIBUTES = (
|
|
NAME_ATTR, SHOW, ADDRESSES, NETWORKS_ATTR, FIRST_ADDRESS,
|
|
INSTANCE_NAME, ACCESSIPV4, ACCESSIPV6,
|
|
) = (
|
|
'name', 'show', 'addresses', 'networks', 'first_address',
|
|
'instance_name', 'accessIPv4', 'accessIPv6',
|
|
)
|
|
|
|
properties_schema = {
|
|
NAME: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Server name.'),
|
|
update_allowed=True
|
|
),
|
|
IMAGE: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('The ID or name of the image to boot with.'),
|
|
constraints=[
|
|
constraints.CustomConstraint('glance.image')
|
|
],
|
|
update_allowed=True
|
|
),
|
|
BLOCK_DEVICE_MAPPING: properties.Schema(
|
|
properties.Schema.LIST,
|
|
_('Block device mappings for this server.'),
|
|
schema=properties.Schema(
|
|
properties.Schema.MAP,
|
|
schema={
|
|
BLOCK_DEVICE_MAPPING_DEVICE_NAME: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('A device name where the volume will be '
|
|
'attached in the system at /dev/device_name. '
|
|
'This value is typically vda.'),
|
|
required=True
|
|
),
|
|
BLOCK_DEVICE_MAPPING_VOLUME_ID: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('The ID of the volume to boot from. Only one '
|
|
'of volume_id or snapshot_id should be '
|
|
'provided.')
|
|
),
|
|
BLOCK_DEVICE_MAPPING_SNAPSHOT_ID: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('The ID of the snapshot to create a volume '
|
|
'from.')
|
|
),
|
|
BLOCK_DEVICE_MAPPING_VOLUME_SIZE: properties.Schema(
|
|
properties.Schema.INTEGER,
|
|
_('The size of the volume, in GB. It is safe to '
|
|
'leave this blank and have the Compute service '
|
|
'infer the size.')
|
|
),
|
|
BLOCK_DEVICE_MAPPING_DELETE_ON_TERM: properties.Schema(
|
|
properties.Schema.BOOLEAN,
|
|
_('Indicate whether the volume should be deleted '
|
|
'when the server is terminated.')
|
|
),
|
|
},
|
|
)
|
|
),
|
|
FLAVOR: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('The ID or name of the flavor to boot onto.'),
|
|
required=True,
|
|
update_allowed=True
|
|
),
|
|
FLAVOR_UPDATE_POLICY: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Policy on how to apply a flavor update; either by requesting '
|
|
'a server resize or by replacing the entire server.'),
|
|
default='RESIZE',
|
|
constraints=[
|
|
constraints.AllowedValues(['RESIZE', 'REPLACE']),
|
|
],
|
|
update_allowed=True
|
|
),
|
|
IMAGE_UPDATE_POLICY: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Policy on how to apply an image-id update; either by '
|
|
'requesting a server rebuild or by replacing the entire server'),
|
|
default='REPLACE',
|
|
constraints=[
|
|
constraints.AllowedValues(['REBUILD', 'REPLACE',
|
|
'REBUILD_PRESERVE_EPHEMERAL']),
|
|
],
|
|
update_allowed=True
|
|
),
|
|
KEY_NAME: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Name of keypair to inject into the server.'),
|
|
constraints=[
|
|
constraints.CustomConstraint('nova.keypair')
|
|
]
|
|
),
|
|
ADMIN_USER: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Name of the administrative user to use on the server. '
|
|
'This property will be removed from Juno in favor of the '
|
|
'default cloud-init user set up for each image (e.g. "ubuntu" '
|
|
'for Ubuntu 12.04+, "fedora" for Fedora 19+ and "cloud-user" '
|
|
'for CentOS/RHEL 6.5).'),
|
|
support_status=support.SupportStatus(status=support.DEPRECATED)
|
|
),
|
|
AVAILABILITY_ZONE: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Name of the availability zone for server placement.')
|
|
),
|
|
SECURITY_GROUPS: properties.Schema(
|
|
properties.Schema.LIST,
|
|
_('List of security group names or IDs. Cannot be used if '
|
|
'neutron ports are associated with this server; assign '
|
|
'security groups to the ports instead.'),
|
|
default=[]
|
|
),
|
|
NETWORKS: properties.Schema(
|
|
properties.Schema.LIST,
|
|
_('An ordered list of nics to be added to this server, with '
|
|
'information about connected networks, fixed ips, port etc.'),
|
|
schema=properties.Schema(
|
|
properties.Schema.MAP,
|
|
schema={
|
|
NETWORK_UUID: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('ID of network to create a port on.'),
|
|
support_status=support.SupportStatus(
|
|
support.DEPRECATED,
|
|
_('Use property %s.') % NETWORK_ID)
|
|
),
|
|
NETWORK_ID: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Name or ID of network to create a port on.')
|
|
),
|
|
NETWORK_FIXED_IP: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Fixed IP address to specify for the port '
|
|
'created on the requested network.')
|
|
),
|
|
NETWORK_PORT: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('ID of an existing port to associate with this '
|
|
'server.')
|
|
),
|
|
},
|
|
),
|
|
update_allowed=True
|
|
),
|
|
SCHEDULER_HINTS: properties.Schema(
|
|
properties.Schema.MAP,
|
|
_('Arbitrary key-value pairs specified by the client to help '
|
|
'boot a server.')
|
|
),
|
|
METADATA: properties.Schema(
|
|
properties.Schema.MAP,
|
|
_('Arbitrary key/value metadata to store for this server. Both '
|
|
'keys and values must be 255 characters or less. Non-string '
|
|
'values will be serialized to JSON (and the serialized '
|
|
'string must be 255 characters or less).'),
|
|
update_allowed=True
|
|
),
|
|
USER_DATA_FORMAT: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('How the user_data should be formatted for the server. For '
|
|
'HEAT_CFNTOOLS, the user_data is bundled as part of the '
|
|
'heat-cfntools cloud-init boot configuration data. For RAW '
|
|
'the user_data is passed to Nova unmodified. '
|
|
'For SOFTWARE_CONFIG user_data is bundled as part of the '
|
|
'software config data, and metadata is derived from any '
|
|
'associated SoftwareDeployment resources.'),
|
|
default=HEAT_CFNTOOLS,
|
|
constraints=[
|
|
constraints.AllowedValues(_SOFTWARE_CONFIG_FORMATS),
|
|
]
|
|
),
|
|
SOFTWARE_CONFIG_TRANSPORT: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('How the server should receive the metadata required for '
|
|
'software configuration. POLL_SERVER_CFN will allow calls to '
|
|
'the cfn API action DescribeStackResource authenticated with '
|
|
'the provided keypair. POLL_SERVER_HEAT will allow calls to '
|
|
'the Heat API resource-show using the provided keystone '
|
|
'credentials.'),
|
|
default=POLL_SERVER_CFN,
|
|
constraints=[
|
|
constraints.AllowedValues(_SOFTWARE_CONFIG_TRANSPORTS),
|
|
]
|
|
),
|
|
USER_DATA: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('User data script to be executed by cloud-init.'),
|
|
default=''
|
|
),
|
|
RESERVATION_ID: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('A UUID for the set of servers being requested.')
|
|
),
|
|
CONFIG_DRIVE: properties.Schema(
|
|
properties.Schema.BOOLEAN,
|
|
_('If True, enable config drive on the server.')
|
|
),
|
|
DISK_CONFIG: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('Control how the disk is partitioned when the server is '
|
|
'created.'),
|
|
constraints=[
|
|
constraints.AllowedValues(['AUTO', 'MANUAL']),
|
|
]
|
|
),
|
|
PERSONALITY: properties.Schema(
|
|
properties.Schema.MAP,
|
|
_('A map of files to create/overwrite on the server upon boot. '
|
|
'Keys are file names and values are the file contents.'),
|
|
default={}
|
|
),
|
|
ADMIN_PASS: properties.Schema(
|
|
properties.Schema.STRING,
|
|
_('The administrator password for the server.'),
|
|
required=False,
|
|
update_allowed=True
|
|
),
|
|
}
|
|
|
|
attributes_schema = {
|
|
NAME_ATTR: attributes.Schema(
|
|
_('Name of the server.')
|
|
),
|
|
SHOW: attributes.Schema(
|
|
_('A dict of all server details as returned by the API.')
|
|
),
|
|
ADDRESSES: attributes.Schema(
|
|
_('A dict of all network addresses with corresponding port_id.')
|
|
),
|
|
NETWORKS_ATTR: attributes.Schema(
|
|
_('A dict of assigned network addresses of the form: '
|
|
'{"public": [ip1, ip2...], "private": [ip3, ip4]}.')
|
|
),
|
|
FIRST_ADDRESS: attributes.Schema(
|
|
_('Convenience attribute to fetch the first assigned network '
|
|
'address, or an empty string if nothing has been assigned at '
|
|
'this time. Result may not be predictable if the server has '
|
|
'addresses from more than one network.'),
|
|
support_status=support.SupportStatus(
|
|
status=support.DEPRECATED,
|
|
message=_('Use the networks attribute instead of '
|
|
'first_address. For example: "{get_attr: '
|
|
'[<server name>, networks, <network name>, 0]}"')
|
|
)
|
|
),
|
|
INSTANCE_NAME: attributes.Schema(
|
|
_('AWS compatible instance name.')
|
|
),
|
|
ACCESSIPV4: attributes.Schema(
|
|
_('The manually assigned alternative public IPv4 address '
|
|
'of the server.')
|
|
),
|
|
ACCESSIPV6: attributes.Schema(
|
|
_('The manually assigned alternative public IPv6 address '
|
|
'of the server.')
|
|
),
|
|
}
|
|
|
|
# Server host name limit to 53 characters by due to typical default
|
|
# linux HOST_NAME_MAX of 64, minus the .novalocal appended to the name
|
|
physical_resource_name_limit = 53
|
|
|
|
default_client_name = 'nova'
|
|
|
|
def __init__(self, name, json_snippet, stack):
|
|
super(Server, self).__init__(name, json_snippet, stack)
|
|
if self.user_data_software_config():
|
|
self._register_access_key()
|
|
|
|
def _server_name(self):
|
|
name = self.properties.get(self.NAME)
|
|
if name:
|
|
return name
|
|
|
|
return self.physical_resource_name()
|
|
|
|
def _config_drive(self):
|
|
# This method is overridden by the derived CloudServer resource
|
|
return self.properties.get(self.CONFIG_DRIVE)
|
|
|
|
def _populate_deployments_metadata(self):
|
|
meta = self.metadata_get(True) or {}
|
|
meta['deployments'] = meta.get('deployments', [])
|
|
if self.transport_poll_server_heat():
|
|
meta['os-collect-config'] = {'heat': {
|
|
'user_id': self._get_user_id(),
|
|
'password': self.password,
|
|
'auth_url': self.context.auth_url,
|
|
'project_id': self.stack.stack_user_project_id,
|
|
'stack_id': self.stack.identifier().stack_path(),
|
|
'resource_name': self.name}
|
|
}
|
|
elif self.transport_poll_server_cfn():
|
|
meta['os-collect-config'] = {'cfn': {
|
|
'metadata_url': '%s/v1/' % cfg.CONF.heat_metadata_server_url,
|
|
'access_key_id': self.access_key,
|
|
'secret_access_key': self.secret_key,
|
|
'stack_name': self.stack.name,
|
|
'path': '%s.Metadata' % self.name}
|
|
}
|
|
self.metadata_set(meta)
|
|
|
|
def _register_access_key(self):
|
|
'''
|
|
Access is limited to this resource, which created the keypair
|
|
'''
|
|
def access_allowed(resource_name):
|
|
return resource_name == self.name
|
|
|
|
if self.transport_poll_server_cfn():
|
|
self.stack.register_access_allowed_handler(
|
|
self.access_key, access_allowed)
|
|
elif self.transport_poll_server_heat():
|
|
self.stack.register_access_allowed_handler(
|
|
self._get_user_id(), access_allowed)
|
|
|
|
def _create_transport_credentials(self):
|
|
if self.transport_poll_server_cfn():
|
|
self._create_user()
|
|
self._create_keypair()
|
|
|
|
elif self.transport_poll_server_heat():
|
|
self.password = uuid.uuid4().hex
|
|
self._create_user()
|
|
|
|
self._register_access_key()
|
|
|
|
@property
|
|
def access_key(self):
|
|
return self.data().get('access_key')
|
|
|
|
@property
|
|
def secret_key(self):
|
|
return self.data().get('secret_key')
|
|
|
|
@property
|
|
def password(self):
|
|
return self.data().get('password')
|
|
|
|
@password.setter
|
|
def password(self, password):
|
|
if password is None:
|
|
self.data_delete('password')
|
|
else:
|
|
self.data_set('password', password, True)
|
|
|
|
def user_data_raw(self):
|
|
return self.properties.get(self.USER_DATA_FORMAT) == self.RAW
|
|
|
|
def user_data_software_config(self):
|
|
return self.properties.get(
|
|
self.USER_DATA_FORMAT) == self.SOFTWARE_CONFIG
|
|
|
|
def transport_poll_server_cfn(self):
|
|
return self.properties.get(
|
|
self.SOFTWARE_CONFIG_TRANSPORT) == self.POLL_SERVER_CFN
|
|
|
|
def transport_poll_server_heat(self):
|
|
return self.properties.get(
|
|
self.SOFTWARE_CONFIG_TRANSPORT) == self.POLL_SERVER_HEAT
|
|
|
|
def handle_create(self):
|
|
security_groups = self.properties.get(self.SECURITY_GROUPS)
|
|
|
|
user_data_format = self.properties.get(self.USER_DATA_FORMAT)
|
|
ud_content = self.properties.get(self.USER_DATA)
|
|
if self.user_data_software_config() or self.user_data_raw():
|
|
if uuidutils.is_uuid_like(ud_content):
|
|
# attempt to load the userdata from software config
|
|
try:
|
|
ud_content = sc.SoftwareConfig.get_software_config(
|
|
self.heat(), ud_content)
|
|
except exception.SoftwareConfigMissing:
|
|
# no config was found, so do not modify the user_data
|
|
pass
|
|
|
|
if self.user_data_software_config():
|
|
self._create_transport_credentials()
|
|
self._populate_deployments_metadata()
|
|
|
|
if self.properties[self.ADMIN_USER]:
|
|
instance_user = self.properties[self.ADMIN_USER]
|
|
elif cfg.CONF.instance_user:
|
|
instance_user = cfg.CONF.instance_user
|
|
else:
|
|
instance_user = None
|
|
|
|
userdata = nova_utils.build_userdata(
|
|
self,
|
|
ud_content,
|
|
instance_user=instance_user,
|
|
user_data_format=user_data_format)
|
|
|
|
flavor = self.properties[self.FLAVOR]
|
|
availability_zone = self.properties[self.AVAILABILITY_ZONE]
|
|
|
|
image = self.properties.get(self.IMAGE)
|
|
if image:
|
|
image = self.client_plugin('glance').get_image_id(image)
|
|
|
|
flavor_id = nova_utils.get_flavor_id(self.nova(), flavor)
|
|
|
|
instance_meta = self.properties.get(self.METADATA)
|
|
if instance_meta is not None:
|
|
instance_meta = nova_utils.meta_serialize(instance_meta)
|
|
|
|
scheduler_hints = self.properties.get(self.SCHEDULER_HINTS)
|
|
nics = self._build_nics(self.properties.get(self.NETWORKS))
|
|
block_device_mapping = self._build_block_device_mapping(
|
|
self.properties.get(self.BLOCK_DEVICE_MAPPING))
|
|
reservation_id = self.properties.get(self.RESERVATION_ID)
|
|
disk_config = self.properties.get(self.DISK_CONFIG)
|
|
admin_pass = self.properties.get(self.ADMIN_PASS) or None
|
|
personality_files = self.properties.get(self.PERSONALITY)
|
|
key_name = self.properties.get(self.KEY_NAME)
|
|
|
|
server = None
|
|
try:
|
|
server = self.nova().servers.create(
|
|
name=self._server_name(),
|
|
image=image,
|
|
flavor=flavor_id,
|
|
key_name=key_name,
|
|
security_groups=security_groups,
|
|
userdata=userdata,
|
|
meta=instance_meta,
|
|
scheduler_hints=scheduler_hints,
|
|
nics=nics,
|
|
availability_zone=availability_zone,
|
|
block_device_mapping=block_device_mapping,
|
|
reservation_id=reservation_id,
|
|
config_drive=self._config_drive(),
|
|
disk_config=disk_config,
|
|
files=personality_files,
|
|
admin_pass=admin_pass)
|
|
finally:
|
|
# Avoid a race condition where the thread could be cancelled
|
|
# before the ID is stored
|
|
if server is not None:
|
|
self.resource_id_set(server.id)
|
|
|
|
return server
|
|
|
|
def check_create_complete(self, server):
|
|
return self._check_active(server)
|
|
|
|
def _check_active(self, server):
|
|
|
|
if server.status != 'ACTIVE':
|
|
nova_utils.refresh_server(server)
|
|
|
|
# Some clouds append extra (STATUS) strings to the status
|
|
short_server_status = server.status.split('(')[0]
|
|
if short_server_status in nova_utils.deferred_server_statuses:
|
|
return False
|
|
elif server.status == 'ACTIVE':
|
|
return True
|
|
elif server.status == 'ERROR':
|
|
fault = getattr(server, 'fault', {})
|
|
raise resource.ResourceInError(
|
|
resource_status=server.status,
|
|
status_reason=_("Message: %(message)s, Code: %(code)s") % {
|
|
'message': fault.get('message', _('Unknown')),
|
|
'code': fault.get('code', _('Unknown'))
|
|
})
|
|
else:
|
|
raise resource.ResourceUnknownStatus(
|
|
resource_status=server.status)
|
|
|
|
@classmethod
|
|
def _build_block_device_mapping(cls, bdm):
|
|
if not bdm:
|
|
return None
|
|
bdm_dict = {}
|
|
for mapping in bdm:
|
|
mapping_parts = []
|
|
snapshot_id = mapping.get(cls.BLOCK_DEVICE_MAPPING_SNAPSHOT_ID)
|
|
if snapshot_id:
|
|
mapping_parts.append(snapshot_id)
|
|
mapping_parts.append('snap')
|
|
else:
|
|
volume_id = mapping.get(cls.BLOCK_DEVICE_MAPPING_VOLUME_ID)
|
|
mapping_parts.append(volume_id)
|
|
mapping_parts.append('')
|
|
|
|
volume_size = mapping.get(cls.BLOCK_DEVICE_MAPPING_VOLUME_SIZE)
|
|
delete = mapping.get(cls.BLOCK_DEVICE_MAPPING_DELETE_ON_TERM)
|
|
if volume_size:
|
|
mapping_parts.append(str(volume_size))
|
|
else:
|
|
mapping_parts.append('')
|
|
if delete:
|
|
mapping_parts.append(str(delete))
|
|
|
|
device_name = mapping.get(cls.BLOCK_DEVICE_MAPPING_DEVICE_NAME)
|
|
bdm_dict[device_name] = ':'.join(mapping_parts)
|
|
|
|
return bdm_dict
|
|
|
|
def _build_nics(self, networks):
|
|
if not networks:
|
|
return None
|
|
|
|
nics = []
|
|
|
|
for net_data in networks:
|
|
nic_info = {}
|
|
if net_data.get(self.NETWORK_UUID):
|
|
nic_info['net-id'] = net_data[self.NETWORK_UUID]
|
|
label_or_uuid = net_data.get(self.NETWORK_ID)
|
|
if label_or_uuid:
|
|
if uuidutils.is_uuid_like(label_or_uuid):
|
|
nic_info['net-id'] = label_or_uuid
|
|
else:
|
|
network = self.nova().networks.find(label=label_or_uuid)
|
|
nic_info['net-id'] = network.id
|
|
if net_data.get(self.NETWORK_FIXED_IP):
|
|
nic_info['v4-fixed-ip'] = net_data[self.NETWORK_FIXED_IP]
|
|
if net_data.get(self.NETWORK_PORT):
|
|
nic_info['port-id'] = net_data[self.NETWORK_PORT]
|
|
nics.append(nic_info)
|
|
return nics
|
|
|
|
def _add_port_for_address(self, server):
|
|
nets = copy.deepcopy(server.addresses)
|
|
ifaces = server.interface_list()
|
|
ip_mac_mapping_on_port_id = dict(((iface.fixed_ips[0]['ip_address'],
|
|
iface.mac_addr), iface.port_id)
|
|
for iface in ifaces)
|
|
for net_name in nets:
|
|
for addr in nets[net_name]:
|
|
addr['port'] = ip_mac_mapping_on_port_id.get(
|
|
(addr['addr'], addr['OS-EXT-IPS-MAC:mac_addr']))
|
|
return nets
|
|
|
|
def _resolve_attribute(self, name):
|
|
if name == self.FIRST_ADDRESS:
|
|
return nova_utils.server_to_ipaddress(
|
|
self.nova(), self.resource_id) or ''
|
|
try:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
except Exception as e:
|
|
self.client_plugin().ignore_not_found(e)
|
|
LOG.warn(_('Instance (%s) not found') % self.resource_id)
|
|
return ''
|
|
if name == self.NAME_ATTR:
|
|
return self._server_name()
|
|
if name == self.ADDRESSES:
|
|
return self._add_port_for_address(server)
|
|
if name == self.NETWORKS_ATTR:
|
|
return server.networks
|
|
if name == self.INSTANCE_NAME:
|
|
return server._info.get('OS-EXT-SRV-ATTR:instance_name')
|
|
if name == self.ACCESSIPV4:
|
|
return server.accessIPv4
|
|
if name == self.ACCESSIPV6:
|
|
return server.accessIPv6
|
|
if name == self.SHOW:
|
|
return server._info
|
|
|
|
def add_dependencies(self, deps):
|
|
super(Server, self).add_dependencies(deps)
|
|
# Depend on any Subnet in this template with the same
|
|
# network_id as the networks attached to this server.
|
|
# It is not known which subnet a server might be assigned
|
|
# to so all subnets in a network should be created before
|
|
# the servers in that network.
|
|
for res in self.stack.itervalues():
|
|
if (res.has_interface('OS::Neutron::Subnet')):
|
|
subnet_net = res.properties.get(subnet.Subnet.NETWORK_ID)
|
|
for net in self.properties.get(self.NETWORKS):
|
|
# we do not need to worry about NETWORK_ID values which are
|
|
# names instead of UUIDs since these were not created
|
|
# by this stack
|
|
net_id = (net.get(self.NETWORK_ID) or
|
|
net.get(self.NETWORK_UUID))
|
|
if net_id and net_id == subnet_net:
|
|
deps += (self, res)
|
|
break
|
|
|
|
def _get_network_matches(self, old_networks, new_networks):
|
|
# make new_networks similar on old_networks
|
|
for net in new_networks:
|
|
for key in ('port', 'network', 'fixed_ip', 'uuid'):
|
|
net.setdefault(key)
|
|
# find matches and remove them from old and new networks
|
|
not_updated_networks = []
|
|
for net in old_networks:
|
|
if net in new_networks:
|
|
new_networks.remove(net)
|
|
not_updated_networks.append(net)
|
|
for net in not_updated_networks:
|
|
old_networks.remove(net)
|
|
return not_updated_networks
|
|
|
|
def update_networks_matching_iface_port(self, nets, interfaces):
|
|
|
|
def find_equal(port, net_id, ip, nets):
|
|
for net in nets:
|
|
if (net.get('port') == port or
|
|
(net.get('fixed_ip') == ip and
|
|
net.get('network') == net_id)):
|
|
return net
|
|
|
|
def find_poor_net(net_id, nets):
|
|
for net in nets:
|
|
if net == {'port': None, 'network': net_id, 'fixed_ip': None}:
|
|
return net
|
|
|
|
for iface in interfaces:
|
|
# get interface properties
|
|
props = {'port': iface.port_id,
|
|
'net_id': iface.net_id,
|
|
'ip': iface.fixed_ips[0]['ip_address'],
|
|
'nets': nets}
|
|
# try to match by port or network_id with fixed_ip
|
|
net = find_equal(**props)
|
|
if net is not None:
|
|
net['port'] = props['port']
|
|
continue
|
|
# find poor net that has only network_id
|
|
net = find_poor_net(props['net_id'], nets)
|
|
if net is not None:
|
|
net['port'] = props['port']
|
|
|
|
def handle_update(self, json_snippet, tmpl_diff, prop_diff):
|
|
if 'Metadata' in tmpl_diff:
|
|
self.metadata_set(tmpl_diff['Metadata'])
|
|
|
|
checkers = []
|
|
server = None
|
|
|
|
if self.METADATA in prop_diff:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
nova_utils.meta_update(self.nova(),
|
|
server,
|
|
prop_diff[self.METADATA])
|
|
|
|
if self.FLAVOR in prop_diff:
|
|
|
|
flavor_update_policy = (
|
|
prop_diff.get(self.FLAVOR_UPDATE_POLICY) or
|
|
self.properties.get(self.FLAVOR_UPDATE_POLICY))
|
|
|
|
if flavor_update_policy == 'REPLACE':
|
|
raise resource.UpdateReplace(self.name)
|
|
|
|
flavor = prop_diff[self.FLAVOR]
|
|
flavor_id = nova_utils.get_flavor_id(self.nova(), flavor)
|
|
if not server:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
checker = scheduler.TaskRunner(nova_utils.resize, server, flavor,
|
|
flavor_id)
|
|
checkers.append(checker)
|
|
|
|
if self.IMAGE in prop_diff:
|
|
image_update_policy = (
|
|
prop_diff.get(self.IMAGE_UPDATE_POLICY) or
|
|
self.properties.get(self.IMAGE_UPDATE_POLICY))
|
|
if image_update_policy == 'REPLACE':
|
|
raise resource.UpdateReplace(self.name)
|
|
image = prop_diff[self.IMAGE]
|
|
image_id = self.client_plugin('glance').get_image_id(image)
|
|
if not server:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
preserve_ephemeral = (
|
|
image_update_policy == 'REBUILD_PRESERVE_EPHEMERAL')
|
|
checker = scheduler.TaskRunner(
|
|
nova_utils.rebuild, server, image_id,
|
|
preserve_ephemeral=preserve_ephemeral)
|
|
checkers.append(checker)
|
|
|
|
if self.NAME in prop_diff:
|
|
if not server:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
nova_utils.rename(server, prop_diff[self.NAME])
|
|
|
|
if self.NETWORKS in prop_diff:
|
|
new_networks = prop_diff.get(self.NETWORKS)
|
|
attach_first_free_port = False
|
|
if not new_networks:
|
|
new_networks = []
|
|
attach_first_free_port = True
|
|
old_networks = self.properties.get(self.NETWORKS)
|
|
|
|
if not server:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
interfaces = server.interface_list()
|
|
|
|
# if old networks is None, it means that the server got first
|
|
# free port. so we should detach this interface.
|
|
if old_networks is None:
|
|
for iface in interfaces:
|
|
checker = scheduler.TaskRunner(server.interface_detach,
|
|
iface.port_id)
|
|
checkers.append(checker)
|
|
# if we have any information in networks field, we should:
|
|
# 1. find similar networks, if they exist
|
|
# 2. remove these networks from new_networks and old_networks
|
|
# lists
|
|
# 3. detach unmatched networks, which were present in old_networks
|
|
# 4. attach unmatched networks, which were present in new_networks
|
|
else:
|
|
# remove not updated networks from old and new networks lists,
|
|
# also get list these networks
|
|
not_updated_networks = \
|
|
self._get_network_matches(old_networks, new_networks)
|
|
|
|
self.update_networks_matching_iface_port(
|
|
old_networks + not_updated_networks, interfaces)
|
|
|
|
# according to nova interface-detach command detached port
|
|
# will be deleted
|
|
for net in old_networks:
|
|
checker = scheduler.TaskRunner(server.interface_detach,
|
|
net.get('port'))
|
|
checkers.append(checker)
|
|
|
|
# attach section similar for both variants that
|
|
# were mentioned above
|
|
|
|
for net in new_networks:
|
|
if net.get('port'):
|
|
checker = scheduler.TaskRunner(server.interface_attach,
|
|
net['port'], None, None)
|
|
checkers.append(checker)
|
|
elif net.get('network'):
|
|
checker = scheduler.TaskRunner(server.interface_attach,
|
|
None, net['network'],
|
|
net.get('fixed_ip'))
|
|
checkers.append(checker)
|
|
|
|
# if new_networks is None, we should attach first free port,
|
|
# according to similar behavior during instance creation
|
|
if attach_first_free_port:
|
|
checker = scheduler.TaskRunner(server.interface_attach,
|
|
None, None, None)
|
|
checkers.append(checker)
|
|
|
|
# Optimization: make sure the first task is started before
|
|
# check_update_complete.
|
|
if checkers:
|
|
checkers[0].start()
|
|
|
|
return checkers
|
|
|
|
def check_update_complete(self, checkers):
|
|
'''Push all checkers to completion in list order.'''
|
|
for checker in checkers:
|
|
if not checker.started():
|
|
checker.start()
|
|
if not checker.step():
|
|
return False
|
|
return True
|
|
|
|
def metadata_update(self, new_metadata=None):
|
|
'''
|
|
Refresh the metadata if new_metadata is None
|
|
'''
|
|
if new_metadata is None:
|
|
# Re-resolve the template metadata and merge it with the
|
|
# current resource metadata. This is necessary because the
|
|
# attributes referenced in the template metadata may change
|
|
# and the resource itself adds keys to the metadata which
|
|
# are not specified in the template (e.g the deployments data)
|
|
meta = self.metadata_get(refresh=True) or {}
|
|
tmpl_meta = self.t.metadata()
|
|
meta.update(tmpl_meta)
|
|
self.metadata_set(meta)
|
|
|
|
@staticmethod
|
|
def _check_maximum(count, maximum, msg):
|
|
'''
|
|
Check a count against a maximum, unless maximum is -1 which indicates
|
|
that there is no limit
|
|
'''
|
|
if maximum != -1 and count > maximum:
|
|
raise exception.StackValidationFailed(message=msg)
|
|
|
|
def validate(self):
|
|
'''
|
|
Validate any of the provided params
|
|
'''
|
|
super(Server, self).validate()
|
|
|
|
# either volume_id or snapshot_id needs to be specified, but not both
|
|
# for block device mapping.
|
|
bdm = self.properties.get(self.BLOCK_DEVICE_MAPPING) or []
|
|
bootable_vol = False
|
|
for mapping in bdm:
|
|
device_name = mapping[self.BLOCK_DEVICE_MAPPING_DEVICE_NAME]
|
|
if device_name == 'vda':
|
|
bootable_vol = True
|
|
|
|
volume_id = mapping.get(self.BLOCK_DEVICE_MAPPING_VOLUME_ID)
|
|
snapshot_id = mapping.get(self.BLOCK_DEVICE_MAPPING_SNAPSHOT_ID)
|
|
if volume_id and snapshot_id:
|
|
raise exception.ResourcePropertyConflict(
|
|
self.BLOCK_DEVICE_MAPPING_VOLUME_ID,
|
|
self.BLOCK_DEVICE_MAPPING_SNAPSHOT_ID)
|
|
if not volume_id and not snapshot_id:
|
|
msg = _('Either volume_id or snapshot_id must be specified for'
|
|
' device mapping %s') % device_name
|
|
raise exception.StackValidationFailed(message=msg)
|
|
|
|
# make sure the image exists if specified.
|
|
image = self.properties.get(self.IMAGE)
|
|
if not image and not bootable_vol:
|
|
msg = _('Neither image nor bootable volume is specified for'
|
|
' instance %s') % self.name
|
|
raise exception.StackValidationFailed(message=msg)
|
|
|
|
# network properties 'uuid' and 'network' shouldn't be used
|
|
# both at once for all networks
|
|
networks = self.properties.get(self.NETWORKS) or []
|
|
# record if any networks include explicit ports
|
|
networks_with_port = False
|
|
for network in networks:
|
|
networks_with_port = networks_with_port or \
|
|
network.get(self.NETWORK_PORT)
|
|
if network.get(self.NETWORK_UUID) and network.get(self.NETWORK_ID):
|
|
msg = _('Properties "%(uuid)s" and "%(id)s" are both set '
|
|
'to the network "%(network)s" for the server '
|
|
'"%(server)s". The "%(uuid)s" property is deprecated. '
|
|
'Use only "%(id)s" property.'
|
|
'') % dict(uuid=self.NETWORK_UUID,
|
|
id=self.NETWORK_ID,
|
|
network=network[self.NETWORK_ID],
|
|
server=self.name)
|
|
raise exception.StackValidationFailed(message=msg)
|
|
elif network.get(self.NETWORK_UUID):
|
|
LOG.info(_('For the server "%(server)s" the "%(uuid)s" '
|
|
'property is set to network "%(network)s". '
|
|
'"%(uuid)s" property is deprecated. Use '
|
|
'"%(id)s" property instead.')
|
|
% dict(uuid=self.NETWORK_UUID,
|
|
id=self.NETWORK_ID,
|
|
network=network[self.NETWORK_ID],
|
|
server=self.name))
|
|
|
|
# retrieve provider's absolute limits if it will be needed
|
|
metadata = self.properties.get(self.METADATA)
|
|
personality = self.properties.get(self.PERSONALITY)
|
|
if metadata is not None or personality:
|
|
limits = nova_utils.absolute_limits(self.nova())
|
|
|
|
# if 'security_groups' present for the server and explict 'port'
|
|
# in one or more entries in 'networks', raise validation error
|
|
if networks_with_port and self.properties.get(self.SECURITY_GROUPS):
|
|
raise exception.ResourcePropertyConflict(
|
|
self.SECURITY_GROUPS,
|
|
"/".join([self.NETWORKS, self.NETWORK_PORT]))
|
|
|
|
# verify that the number of metadata entries is not greater
|
|
# than the maximum number allowed in the provider's absolute
|
|
# limits
|
|
if metadata is not None:
|
|
msg = _('Instance metadata must not contain greater than %s '
|
|
'entries. This is the maximum number allowed by your '
|
|
'service provider') % limits['maxServerMeta']
|
|
self._check_maximum(len(metadata),
|
|
limits['maxServerMeta'], msg)
|
|
|
|
# verify the number of personality files and the size of each
|
|
# personality file against the provider's absolute limits
|
|
if personality:
|
|
msg = _("The personality property may not contain "
|
|
"greater than %s entries.") % limits['maxPersonality']
|
|
self._check_maximum(len(personality),
|
|
limits['maxPersonality'], msg)
|
|
|
|
for path, contents in personality.items():
|
|
msg = (_("The contents of personality file \"%(path)s\" "
|
|
"is larger than the maximum allowed personality "
|
|
"file size (%(max_size)s bytes).") %
|
|
{'path': path,
|
|
'max_size': limits['maxPersonalitySize']})
|
|
self._check_maximum(len(bytes(contents)),
|
|
limits['maxPersonalitySize'], msg)
|
|
|
|
def handle_delete(self):
|
|
|
|
if self.resource_id is None:
|
|
return
|
|
|
|
if self.user_data_software_config():
|
|
self._delete_user()
|
|
|
|
try:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
except Exception as e:
|
|
self.client_plugin().ignore_not_found(e)
|
|
else:
|
|
deleter = scheduler.TaskRunner(nova_utils.delete_server, server)
|
|
deleter.start()
|
|
return deleter
|
|
|
|
def check_delete_complete(self, deleter):
|
|
if deleter is None or deleter.step():
|
|
self.resource_id_set(None)
|
|
return True
|
|
return False
|
|
|
|
def handle_suspend(self):
|
|
'''
|
|
Suspend a server - note we do not wait for the SUSPENDED state,
|
|
this is polled for by check_suspend_complete in a similar way to the
|
|
create logic so we can take advantage of coroutines
|
|
'''
|
|
if self.resource_id is None:
|
|
raise exception.Error(_('Cannot suspend %s, resource_id not set') %
|
|
self.name)
|
|
|
|
try:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
except Exception as e:
|
|
if self.client_plugin().is_not_found(e):
|
|
raise exception.NotFound(_('Failed to find server %s') %
|
|
self.resource_id)
|
|
else:
|
|
raise
|
|
else:
|
|
LOG.debug('suspending server %s' % self.resource_id)
|
|
# We want the server.suspend to happen after the volume
|
|
# detachement has finished, so pass both tasks and the server
|
|
suspend_runner = scheduler.TaskRunner(server.suspend)
|
|
return server, suspend_runner
|
|
|
|
def check_suspend_complete(self, cookie):
|
|
server, suspend_runner = cookie
|
|
|
|
if not suspend_runner.started():
|
|
suspend_runner.start()
|
|
|
|
if suspend_runner.done():
|
|
if server.status == 'SUSPENDED':
|
|
return True
|
|
|
|
nova_utils.refresh_server(server)
|
|
LOG.debug('%(name)s check_suspend_complete status = %(status)s'
|
|
% {'name': self.name, 'status': server.status})
|
|
if server.status in list(nova_utils.deferred_server_statuses +
|
|
['ACTIVE']):
|
|
return server.status == 'SUSPENDED'
|
|
else:
|
|
exc = exception.Error(_('Suspend of server %(server)s failed '
|
|
'with unknown status: %(status)s') %
|
|
dict(server=server.name,
|
|
status=server.status))
|
|
raise exc
|
|
|
|
def handle_resume(self):
|
|
'''
|
|
Resume a server - note we do not wait for the ACTIVE state,
|
|
this is polled for by check_resume_complete in a similar way to the
|
|
create logic so we can take advantage of coroutines
|
|
'''
|
|
if self.resource_id is None:
|
|
raise exception.Error(_('Cannot resume %s, resource_id not set') %
|
|
self.name)
|
|
|
|
try:
|
|
server = self.nova().servers.get(self.resource_id)
|
|
except Exception as e:
|
|
if self.client_plugin().is_not_found(e):
|
|
raise exception.NotFound(_('Failed to find server %s') %
|
|
self.resource_id)
|
|
else:
|
|
raise
|
|
else:
|
|
LOG.debug('resuming server %s' % self.resource_id)
|
|
server.resume()
|
|
return server
|
|
|
|
def check_resume_complete(self, server):
|
|
return self._check_active(server)
|
|
|
|
|
|
class FlavorConstraint(constraints.BaseCustomConstraint):
|
|
|
|
expected_exceptions = (exception.FlavorMissing,)
|
|
|
|
def validate_with_client(self, client, value):
|
|
nova_client = client.client('nova')
|
|
nova_utils.get_flavor_id(nova_client, value)
|
|
|
|
|
|
def resource_mapping():
|
|
return {
|
|
'OS::Nova::Server': Server,
|
|
}
|