# Copyright 2016 Huawei Technologies Co.,LTD. # All Rights Reserved. # # 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 datetime from oslo_log import log from oslo_utils import netutils import pecan from pecan import rest import six from six.moves import http_client import wsme from wsme import types as wtypes from mogan.api.controllers import base from mogan.api.controllers import link from mogan.api.controllers.v1.schemas import floating_ips as fip_schemas from mogan.api.controllers.v1.schemas import interfaces as interface_schemas from mogan.api.controllers.v1.schemas import remote_consoles as console_schemas from mogan.api.controllers.v1.schemas import servers as server_schemas from mogan.api.controllers.v1 import types from mogan.api.controllers.v1 import utils as api_utils from mogan.api import expose from mogan.api import validation from mogan.common import exception from mogan.common.i18n import _ from mogan.common import policy from mogan.common import states from mogan import network from mogan import objects import re _DEFAULT_SERVER_RETURN_FIELDS = ('uuid', 'name', 'description', 'status', 'power_state') _ONLY_ADMIN_VISIBLE_SEVER_FIELDS = ('node', 'affinity_zone',) LOG = log.getLogger(__name__) class ServerControllerBase(rest.RestController): _resource = None # This _resource is used for authorization. def _get_resource(self, uuid, *args, **kwargs): self._resource = objects.Server.get(pecan.request.context, uuid) return self._resource class ServerStatesController(ServerControllerBase): _custom_actions = { 'power': ['PUT'], 'lock': ['PUT'], 'provision': ['PUT'], } @policy.authorize_wsgi("mogan:server", "set_power_state") @expose.expose(None, types.uuid, wtypes.text, status_code=http_client.ACCEPTED) def power(self, server_uuid, target): """Set the power state of the server. :param server_uuid: the UUID of a server. :param target: the desired target to change power state, on, off or reboot. :raises Conflict (HTTP 409): if a power operation is already in progress. :raises BadRequest (HTTP 400): if the requested target state is not valid or if the server is in CLEANING state. """ if target not in ["on", "off", "reboot", "soft_off", "soft_reboot"]: # ironic will throw InvalidStateRequested raise exception.InvalidActionParameterValue( value=target, action="power", server=server_uuid) db_server = self._resource or self._get_resource(server_uuid) pecan.request.engine_api.power( pecan.request.context, db_server, target) # At present we do not catch the Exception from ironicclient. # Such as Conflict and BadRequest. # varify provision_state, if server is being cleaned, # don't change power state? # Set the HTTP Location Header, user can get the power_state # by locaton. url_args = '/'.join([server_uuid, 'states']) pecan.response.location = link.build_url('servers', url_args) @policy.authorize_wsgi("mogan:server", "set_lock_state") @expose.expose(None, types.uuid, types.boolean, status_code=http_client.ACCEPTED) def lock(self, server_uuid, target): """Set the lock state of the server. :param server_uuid: the UUID of a server. :param target: the desired target to change lock state, true or false """ db_server = self._resource or self._get_resource(server_uuid) context = pecan.request.context # Target is True, means lock a server if target: pecan.request.engine_api.lock(context, db_server) # Else, unlock the server else: # Try to unlock a server with non-admin or non-owner if not pecan.request.engine_api.is_expected_locked_by( context, db_server): raise exception.Forbidden() pecan.request.engine_api.unlock(context, db_server) @policy.authorize_wsgi("mogan:server", "set_provision_state") @expose.expose(None, types.uuid, wtypes.text, types.uuid, types.boolean, status_code=http_client.ACCEPTED) def provision(self, server_uuid, target, image_uuid=None, preserve_ephemeral=None): """Asynchronous trigger the provisioning of the server. This will set the target provision state of the server, and a background task will begin which actually applies the state change. This call will return a 202 (Accepted) indicating the request was accepted and is in progress; the client should continue to GET the status of this server to observe the status of the requested action. :param server_uuid: UUID of a server. :param target: The desired provision state of the server or verb. :param image_uuid: UUID of the image rebuilt with. :param preserve_ephemeral: whether preserve the ephemeral parition. """ # Currently we only support rebuild target if target not in (states.REBUILD,): raise exception.InvalidActionParameterValue( value=target, action="provision", server=server_uuid) db_server = self._resource or self._get_resource(server_uuid) if target == states.REBUILD: pecan.request.engine_api.rebuild(pecan.request.context, db_server, image_uuid, preserve_ephemeral) # Set the HTTP Location Header url_args = '/'.join([server_uuid, 'states']) pecan.response.location = link.build_url('servers', url_args) class FloatingIPController(ServerControllerBase): """REST controller for Server floatingips.""" def __init__(self, *args, **kwargs): super(FloatingIPController, self).__init__(*args, **kwargs) self.network_api = network.API() @policy.authorize_wsgi("mogan:server", "associate_floatingip") @expose.expose(None, types.uuid, body=types.jsontype, status_code=http_client.NO_CONTENT) def post(self, server_uuid, floatingip): """Add(Associate) Floating Ip. :param server_uuid: UUID of a server. :param floatingip: The floating IP within the request body. """ validation.check_schema(floatingip, fip_schemas.add_floating_ip) server = self._resource or self._get_resource(server_uuid) address = floatingip['address'] server_nics = server.nics if not server_nics: msg = _('No ports associated to server') raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) fixed_address = None nic_to_associate = None if 'fixed_address' in floatingip: fixed_address = floatingip['fixed_address'] for nic in server_nics: for port_address in nic.fixed_ips: if port_address['ip_address'] == fixed_address: nic_to_associate = nic break else: continue break else: msg = _('Specified fixed address not assigned to server') raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) if nic_to_associate and nic_to_associate.floating_ip: msg = _('The specified fixed ip has already been associated with ' 'a floating ip.') raise wsme.exc.ClientSideError( msg, status_code=http_client.CONFLICT) if not fixed_address: for nic in server_nics: if nic.floating_ip: continue for port_address in nic.fixed_ips: if netutils.is_valid_ipv4(port_address['ip_address']): fixed_address = port_address['ip_address'] nic_to_associate = nic break else: continue break else: msg = _('Unable to associate floating IP %(address)s ' 'to any fixed IPs for server %(id)s. ' 'Server has no fixed IPv4 addresses to ' 'associate or all fixed ips have already been ' 'associated with floating ips.') % ( {'address': address, 'id': server.uuid}) raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) if len(server_nics) > 1: LOG.warning('multiple ports exist, using the first ' 'IPv4 fixed_ip: %s', fixed_address) try: self.network_api.associate_floating_ip( pecan.request.context, floating_address=address, port_id=nic.port_id, fixed_address=fixed_address) except (exception.FloatingIpNotFoundForAddress, exception.Forbidden) as e: six.reraise(type(e), e) except Exception as e: msg = _('Unable to associate floating IP %(address)s to ' 'fixed IP %(fixed_address)s for server %(id)s. ' 'Error: %(error)s') % ({'address': address, 'fixed_address': fixed_address, 'id': server.uuid, 'error': e}) LOG.exception(msg) raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) nic_to_associate.floating_ip = address nic_to_associate.save(pecan.request.context) @policy.authorize_wsgi("mogan:server", "disassociate_floatingip") @expose.expose(None, types.uuid, wtypes.text, status_code=http_client.NO_CONTENT) def delete(self, server_uuid, address): """Dissociate floating_ip from a server. :param server_uuid: UUID of a server. :param floatingip: The floating IP within the request body. """ if not netutils.is_valid_ipv4(address): msg = "Invalid IP address %s" % address raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) # get the floating ip object floating_ip = self.network_api.get_floating_ip_by_address( pecan.request.context, address) # get the associated server object (if any) try: server_id =\ self.network_api.get_server_id_by_floating_address( pecan.request.context, address) except (exception.FloatingIpNotFoundForAddress, exception.FloatingIpMultipleFoundForAddress) as e: six.reraise(type(e), e) # disassociate if associated if (floating_ip.get('port_id') and server_id == server_uuid): self.network_api.disassociate_floating_ip(pecan.request.context, address) server = self._resource or self._get_resource(server_uuid) for nic in server.nics: if nic.floating_ip == address: nic.floating_ip = None nic.save(pecan.request.context) else: msg = _("Floating IP %(address)s is not associated with server " "%(id)s.") % {'address': address, 'id': server_uuid} raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) class InterfaceController(ServerControllerBase): def __init__(self, *args, **kwargs): super(InterfaceController, self).__init__(*args, **kwargs) @policy.authorize_wsgi("mogan:server", "attach_interface") @expose.expose(None, types.uuid, body=types.jsontype, status_code=http_client.NO_CONTENT) def post(self, server_uuid, interface): """Attach Interface. :param server_uuid: UUID of a server. :param interface: The Baremetal Network ID within the request body. """ validation.check_schema(interface, interface_schemas.attach_interface) port_id = interface.get('port_id', None) net_id = interface.get('net_id', None) server = self._resource or self._get_resource(server_uuid) pecan.request.engine_api.attach_interface(pecan.request.context, server, net_id, port_id) @policy.authorize_wsgi("mogan:server", "detach_interface") @expose.expose(None, types.uuid, types.uuid, status_code=http_client.NO_CONTENT) def delete(self, server_uuid, port_id): """Detach Interface :param server_uuid: UUID of a server. :param port_id: The Port ID within the request body. """ server = self._resource or self._get_resource(server_uuid) server_nics = server.nics if port_id not in [nic.port_id for nic in server_nics]: raise exception.InterfaceNotFoundForServer(server=server_uuid) pecan.request.engine_api.detach_interface(pecan.request.context, server, port_id) class ServerNetworks(base.APIBase): """API representation of the networks of a server.""" nics = types.jsontype """The instance nics information of the server""" def __init__(self, **kwargs): self.fields = ['nics'] ret_nics = api_utils.show_nics(kwargs.get('nics') or []) super(ServerNetworks, self).__init__(nics=ret_nics) class ServerNetworksController(ServerControllerBase): """REST controller for Server networks.""" floatingips = FloatingIPController() """Expose floatingip as a sub-element of networks""" interfaces = InterfaceController() """Expose interface as a sub-element of networks""" @policy.authorize_wsgi("mogan:server", "get_networks") @expose.expose(ServerNetworks, types.uuid) def get(self, server_uuid): """List the networks info of the server. :param server_uuid: the UUID of a server. """ db_server = self._resource or self._get_resource(server_uuid) return ServerNetworks(nics=db_server.nics.as_list_of_dict()) class Server(base.APIBase): """API representation of a server. This class enforces type checking and value constraints, and converts between the internal object model and the API representation of a server. """ uuid = types.uuid """The UUID of the server""" name = wsme.wsattr(wtypes.text, mandatory=True) """The name of the server""" description = wtypes.text """The description of the server""" project_id = types.uuid """The project UUID of the server""" user_id = types.uuid """The user UUID of the server""" status = wtypes.text """The status of the server""" power_state = wtypes.text """The power state of the server""" availability_zone = wtypes.text """The availability zone of the server""" flavor_uuid = types.uuid """The server type UUID of the server""" image_uuid = types.uuid """The image UUID of the server""" addresses = types.jsontype """The addresses of the server""" links = wsme.wsattr([link.Link], readonly=True) """A list containing a self link""" launched_at = datetime.datetime """The UTC date and time of the server launched""" metadata = {wtypes.text: types.jsontype} """The meta data of the server""" fault = {wtypes.text: types.jsontype} """The fault of the server""" node = wtypes.text """The backend node of the server""" affinity_zone = wtypes.text """The affinity zone of the server""" key_name = wtypes.text """The ssh key name of the server""" partitions = types.jsontype """The partitions of the server""" locked = types.boolean """Represent the current lock state of the server""" def __init__(self, **kwargs): super(Server, self).__init__(**kwargs) self.fields = [] for field in objects.Server.fields: if field == 'nics': addresses = api_utils.show_addresses(kwargs.get('nics') or []) setattr(self, 'addresses', addresses) continue if field == 'fault': if kwargs.get('status') != 'error': setattr(self, field, wtypes.Unset) continue if field in _ONLY_ADMIN_VISIBLE_SEVER_FIELDS: if not pecan.request.context.is_admin: setattr(self, field, wtypes.Unset) continue if field == 'locked_by': setattr(self, 'locked', bool(kwargs.get('locked_by'))) continue # Skip fields we do not expose. if not hasattr(self, field): continue self.fields.append(field) setattr(self, field, kwargs.get(field, wtypes.Unset)) @classmethod def convert_with_links(cls, server_data, fields=None): server = Server(**server_data) server_uuid = server.uuid if fields is not None: server.unset_fields_except(fields) url = pecan.request.public_url server.links = [link.Link.make_link('self', url, 'servers', server_uuid), link.Link.make_link('bookmark', url, 'servers', server_uuid, bookmark=True) ] return server class ServerPatchType(types.JsonPatchType): _api_base = Server @staticmethod def internal_attrs(): defaults = types.JsonPatchType.internal_attrs() return defaults + ['/project_id', '/user_id', '/status', '/power_state', '/availability_zone', '/flavor_uuid', '/image_uuid', '/addresses', '/launched_at', '/affinity_zone', '/key_name', '/partitions', '/fault', '/node', '/locked'] class ServerCollection(base.APIBase): """API representation of a collection of server.""" servers = [Server] """A list containing server objects""" @staticmethod def convert_with_links(servers_data, fields=None): collection = ServerCollection() collection.servers = [Server.convert_with_links(server, fields) for server in servers_data] return collection class ServerConsole(base.APIBase): """API representation of the console of a server.""" protocol = wtypes.text """The protocol of the console""" type = wtypes.text """The type of the console""" url = wtypes.text """The url of the console""" @classmethod def sample(cls): sample = cls( protocol='serial', type='shellinabox', url='http://example.com/?token=' 'b4f5cb4a-8b01-40ea-ae46-67f0db4969b3') return sample class ServerRemoteConsoleController(ServerControllerBase): """REST controller for Server.""" @policy.authorize_wsgi("mogan:server", "create_remote_console") @expose.expose(ServerConsole, types.uuid, body=types.jsontype, status_code=http_client.CREATED) def post(self, server_uuid, remote_console): """Get the serial console info of the server. :param server_uuid: the UUID of a server. :param remote_console: request body includes console type and protocol. """ validation.check_schema( remote_console, console_schemas.create_console) server_obj = self._resource or self._get_resource(server_uuid) protocol = remote_console['protocol'] console_type = remote_console['type'] # Only serial console is supported now if protocol == 'serial': console_url = pecan.request.engine_api.get_serial_console( pecan.request.context, server_obj, console_type) return ServerConsole(protocol=protocol, type=console_type, url=console_url['url']) class ServerController(ServerControllerBase): """REST controller for Server.""" states = ServerStatesController() """Expose the state controller action as a sub-element of servers""" networks = ServerNetworksController() """Expose the network controller action as a sub-element of servers""" remote_consoles = ServerRemoteConsoleController() """Expose the console controller of servers""" _custom_actions = { 'detail': ['GET'] } def _get_server_collection(self, name=None, status=None, flavor_uuid=None, flavor_name=None, image_uuid=None, ip=None, limit=None, marker=None, sort_key=None, sort_dir=None, all_tenants=None, fields=None): context = pecan.request.context project_only = True if context.is_admin and all_tenants: project_only = False limit = api_utils.validate_limit(limit) marker_obj = None if marker: marker_obj = objects.Server.get(pecan.request.context, marker) filters = {} if name: filters['name'] = name if status: filters['status'] = status if flavor_uuid: filters['flavor_uuid'] = flavor_uuid if flavor_name: filters['flavor_name'] = flavor_name if image_uuid: filters['image_uuid'] = image_uuid servers = objects.Server.list(pecan.request.context, limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir, project_only=project_only, filters=filters) if ip: servers = self._ip_filter(servers, ip) servers_data = [server.as_dict() for server in servers] return ServerCollection.convert_with_links(servers_data, fields=fields) @staticmethod def _ip_filter(servers, ip): ip = ip.replace('.', '\.') ip_obj = re.compile(ip) def _match_server(server): nw_info = server.nics for vif in nw_info: for fixed_ip in vif.fixed_ips: address = fixed_ip.get('ip_address') if not address: continue if ip_obj.match(address): return True return False return filter(_match_server, servers) @expose.expose(ServerCollection, wtypes.text, wtypes.text, types.uuid, wtypes.text, types.uuid, wtypes.text, int, types.uuid, types.listtype, wtypes.text, wtypes.text, types.boolean) def get_all(self, name=None, status=None, flavor_uuid=None, flavor_name=None, image_uuid=None, ip=None, limit=None, marker=None, fields=None, sort_key='id', sort_dir='asc', all_tenants=None): """Retrieve a list of server. :param name: Optional, to get the servers with this name :param status: Optional, to get the servers in this status :param flavor_uuid: Optional, to get the servers built with this flavor. :param flavor_name: Optional, to get the servers built with this flavor. :param image_uuid: Optional, to get the servers built with this image. :param ip: Optional, to get the servers with this ip address. :param limit: Optional, to determinate the maximum number of servers to return. :param marker: Optional, to display a list of servers after this marker. :param fields: Optional, a list with a specified set of fields of the resource to be returned. :param sort_key: Optional, to sort the returned server list by this specified key value. :param sort_dir: Optional, to return a list of servers with this sort direction. :param all_tenants: Optional, allows administrators to see the servers owned by all tenants, otherwise only the servers associated with the calling tenant are included in the response. """ if fields is None: fields = _DEFAULT_SERVER_RETURN_FIELDS return self._get_server_collection(name, status, flavor_uuid, flavor_name, image_uuid, ip, limit, marker, sort_key, sort_dir, all_tenants=all_tenants, fields=fields) @policy.authorize_wsgi("mogan:server", "get") @expose.expose(Server, types.uuid, types.listtype) def get_one(self, server_uuid, fields=None): """Retrieve information about the given server. :param server_uuid: UUID of a server. :param fields: Optional, a list with a specified set of fields of the resource to be returned. """ db_server = self._resource or self._get_resource(server_uuid) server_data = db_server.as_dict() return Server.convert_with_links(server_data, fields=fields) @expose.expose(ServerCollection, wtypes.text, wtypes.text, types.uuid, wtypes.text, types.uuid, wtypes.text, int, types.uuid, wtypes.text, wtypes.text, types.boolean) def detail(self, name=None, status=None, flavor_uuid=None, flavor_name=None, image_uuid=None, ip=None, limit=None, marker=None, sort_key='id', sort_dir='asc', all_tenants=None): """Retrieve detail of a list of servers. # /detail should only work against collections :param name: Optional, to get the servers with this name :param status: Optional, to get the servers in this status :param flavor_uuid: Optional, to get the servers built with this flavor. :param flavor_name: Optional, to get the servers built with this flavor. :param image_uuid: Optional, to get the servers built with this image. :param ip: Optional, to get the servers with this ip address. :param limit: Optional, to determinate the maximum number of servers to return. :param marker: Optional, to display a list of servers after this marker. :param sort_key: Optional, to sort the returned server list by this specified key value. :param sort_dir: Optional, to return a list of servers with this sort direction. :param all_tenants: Optional, allows administrators to see the servers owned by all tenants, otherwise only the servers associated with the calling tenant are included in the response. """ cdict = pecan.request.context.to_policy_values() policy.authorize('mogan:server:get', cdict, cdict) parent = pecan.request.path.split('/')[:-1][-1] if parent != "servers": raise exception.NotFound() return self._get_server_collection(name, status, flavor_uuid, flavor_name, image_uuid, ip, limit, marker, sort_key, sort_dir, all_tenants=all_tenants) @policy.authorize_wsgi("mogan:server", "create", False) @expose.expose(Server, body=types.jsontype, status_code=http_client.CREATED) def post(self, server): """Create a new server. :param server: a server within the request body. """ validation.check_schema(server, server_schemas.create_server) scheduler_hints = server.get('scheduler_hints', {}) server = server.get('server') min_count = server.get('min_count', 1) max_count = server.get('max_count', min_count) if min_count > max_count: msg = _('min_count must be <= max_count') raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) requested_networks = server.pop('networks', None) flavor_uuid = server.get('flavor_uuid') image_uuid = server.get('image_uuid') user_data = server.get('user_data') key_name = server.get('key_name') partitions = server.get('partitions') personality = server.pop('personality', None) password = self._get_server_admin_password(server) injected_files = [] if personality: for item in personality: injected_files.append((item['path'], item['contents'])) flavor = objects.Flavor.get(pecan.request.context, flavor_uuid) if flavor.disabled: raise exception.FlavorDisabled(flavor_id=flavor.uuid) servers = pecan.request.engine_api.create( pecan.request.context, flavor, image_uuid=image_uuid, name=server.get('name'), description=server.get('description'), availability_zone=server.get('availability_zone'), metadata=server.get('metadata'), requested_networks=requested_networks, user_data=user_data, injected_files=injected_files, admin_password=password, key_name=key_name, min_count=min_count, max_count=max_count, partitions=partitions, scheduler_hints=scheduler_hints) # Set the HTTP Location Header for the first server. pecan.response.location = link.build_url('server', servers[0].uuid) return Server.convert_with_links(servers[0]) def _get_server_admin_password(self, server): """Determine the admin password for a server on creation.""" if 'adminPass' in server: password = server['adminPass'] else: password = api_utils.generate_password() return password @policy.authorize_wsgi("mogan:server", "update") @wsme.validate(types.uuid, [ServerPatchType]) @expose.expose(Server, types.uuid, body=[ServerPatchType]) def patch(self, server_uuid, patch): """Update a server. :param server_uuid: UUID of a server. :param patch: a json PATCH document to apply to this server. """ db_server = self._resource or self._get_resource(server_uuid) try: server = Server( **api_utils.apply_jsonpatch(db_server.as_dict(), patch)) except api_utils.JSONPATCH_EXCEPTIONS as e: raise exception.PatchError(patch=patch, reason=e) # Update only the fields that have changed for field in objects.Server.fields: if field == 'nics': continue try: patch_val = getattr(server, field) except AttributeError: # Ignore fields that aren't exposed in the API continue if patch_val == wtypes.Unset: patch_val = None if db_server[field] != patch_val: db_server[field] = patch_val db_server.save() return Server.convert_with_links(db_server.as_dict()) @policy.authorize_wsgi("mogan:server", "delete") @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT) def delete(self, server_uuid): """Delete a server. :param server_uuid: UUID of a server. """ db_server = self._resource or self._get_resource(server_uuid) pecan.request.engine_api.delete(pecan.request.context, db_server)