# 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 base64
import functools
import operator
import time
import warnings

import iso8601

from openstack.cloud import _network_common
from openstack.cloud import _utils
from openstack.cloud import exc
from openstack.cloud import meta
from openstack.compute.v2 import server as _server
from openstack import exceptions
from openstack import resource
from openstack import utils
from openstack import warnings as os_warnings


_SERVER_FIELDS = (
    'accessIPv4',
    'accessIPv6',
    'addresses',
    'adminPass',
    'created',
    'description',
    'key_name',
    'metadata',
    'networks',
    'personality',
    'private_v4',
    'public_v4',
    'public_v6',
    'server_groups',
    'status',
    'updated',
    'user_id',
    'tags',
)


def _to_bool(value):
    if isinstance(value, str):
        if not value:
            return False
        prospective = value.lower().capitalize()
        return prospective == 'True'
    return bool(value)


def _pop_int(resource, key):
    return int(resource.pop(key, 0) or 0)


def _pop_or_get(resource, key, default, strict):
    if strict:
        return resource.pop(key, default)
    else:
        return resource.get(key, default)


class ComputeCloudMixin(_network_common.NetworkCommonCloudMixin):
    @property
    def _compute_region(self):
        # This is only used in exception messages. Can we get rid of it?
        return self.config.get_region_name('compute')

    def get_flavor_name(self, flavor_id):
        """Get the name of a flavor.

        :param flavor_id: ID of the flavor.
        :returns: The name of the flavor if a match if found, else None.
        """
        flavor = self.get_flavor(flavor_id, get_extra=False)
        if flavor:
            return flavor['name']
        return None

    def get_flavor_by_ram(self, ram, include=None, get_extra=True):
        """Get a flavor based on amount of RAM available.

        Finds the flavor with the least amount of RAM that is at least
        as much as the specified amount. If `include` is given, further
        filter based on matching flavor name.

        :param int ram: Minimum amount of RAM.
        :param string include: If given, will return a flavor whose name
            contains this string as a substring.
        :param get_extra:

        :returns: A compute ``Flavor`` object.
        :raises: :class:`~openstack.exceptions.SDKException` if no
            matching flavour could be found.
        """
        flavors = self.list_flavors(get_extra=get_extra)
        for flavor in sorted(flavors, key=operator.itemgetter('ram')):
            if flavor['ram'] >= ram and (
                not include or include in flavor['name']
            ):
                return flavor
        raise exceptions.SDKException(
            f"Could not find a flavor with {ram} and '{include}'"
        )

    def search_keypairs(self, name_or_id=None, filters=None):
        """Search keypairs.

        :param name_or_id:
        :param filters:
        :returns: A list of compute ``Keypair`` objects matching the search
            criteria.
        """
        keypairs = self.list_keypairs(
            filters=filters if isinstance(filters, dict) else None
        )
        return _utils._filter_list(keypairs, name_or_id, filters)

    def search_flavors(self, name_or_id=None, filters=None, get_extra=True):
        """Search flavors.

        :param name_or_id:
        :param flavors:
        :param get_extra:
        :returns: A list of compute ``Flavor`` objects matching the search
            criteria.
        """
        flavors = self.list_flavors(get_extra=get_extra)
        return _utils._filter_list(flavors, name_or_id, filters)

    def search_servers(
        self,
        name_or_id=None,
        filters=None,
        detailed=False,
        all_projects=False,
        bare=False,
    ):
        """Search servers.

        :param name_or_id:
        :param filters:
        :param detailed:
        :param all_projects:
        :param bare:
        :returns: A list of compute ``Server`` objects matching the search
            criteria.
        """
        servers = self.list_servers(
            detailed=detailed, all_projects=all_projects, bare=bare
        )
        return _utils._filter_list(servers, name_or_id, filters)

    def search_server_groups(self, name_or_id=None, filters=None):
        """Search server groups.

        :param name_or_id: Name or unique ID of the server group(s).
        :param filters: A dict containing additional filters to use.

        :returns: A list of compute ``ServerGroup`` objects matching the search
            criteria.
        :raises: :class:`~openstack.exceptions.SDKException` if something goes
            wrong during the OpenStack API call.
        """
        server_groups = self.list_server_groups()
        return _utils._filter_list(server_groups, name_or_id, filters)

    def list_keypairs(self, filters=None):
        """List all available keypairs.

        :param filters:
        :returns: A list of compute ``Keypair`` objects.
        """
        if not filters:
            filters = {}
        return list(self.compute.keypairs(**filters))

    def list_availability_zone_names(self, unavailable=False):
        """List names of availability zones.

        :param bool unavailable: Whether or not to include unavailable zones
            in the output. Defaults to False.
        :returns: A list of availability zone names, or an empty list if the
            list could not be fetched.
        """
        try:
            zones = self.compute.availability_zones()
            ret = []
            for zone in zones:
                if zone.state['available'] or unavailable:
                    ret.append(zone.name)
            return ret
        except exceptions.SDKException:
            self.log.debug(
                "Availability zone list could not be fetched", exc_info=True
            )
            return []

    def list_flavors(self, get_extra=False):
        """List all available flavors.

        :param get_extra: Whether or not to fetch extra specs for each flavor.
            Defaults to True. Default behavior value can be overridden in
            clouds.yaml by setting openstack.cloud.get_extra_specs to False.
        :returns: A list of compute ``Flavor`` objects.
        """
        return list(
            self.compute.flavors(details=True, get_extra_specs=get_extra)
        )

    def list_server_security_groups(self, server):
        """List all security groups associated with the given server.

        :returns: A list of security group dictionary objects.
        """

        # Don't even try if we're a cloud that doesn't have them
        if not self._has_secgroups():
            return []

        server = self.compute.get_server(server)

        server.fetch_security_groups(self.compute)

        return server.security_groups

    def _get_server_security_groups(self, server, security_groups):
        if not self._has_secgroups():
            raise exc.OpenStackCloudUnavailableFeature(
                "Unavailable feature: security groups"
            )

        if not isinstance(server, dict):
            server = self.get_server(server, bare=True)

            if server is None:
                self.log.debug('Server %s not found', server)
                return None, None

        if not isinstance(security_groups, (list, tuple)):
            security_groups = [security_groups]

        sec_group_objs = []

        for sg in security_groups:
            if not isinstance(sg, dict):
                sg = self.get_security_group(sg)

                if sg is None:
                    self.log.debug(
                        'Security group %s not found for adding', sg
                    )

                    return None, None

            sec_group_objs.append(sg)

        return server, sec_group_objs

    def add_server_security_groups(self, server, security_groups):
        """Add security groups to a server.

        Add existing security groups to an existing server. If the security
        groups are already present on the server this will continue unaffected.

        :returns: False if server or security groups are undefined, True
            otherwise.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server, security_groups = self._get_server_security_groups(
            server, security_groups
        )

        if not (server and security_groups):
            return False

        for sg in security_groups:
            self.compute.add_security_group_to_server(server, sg)

        return True

    def remove_server_security_groups(self, server, security_groups):
        """Remove security groups from a server

        Remove existing security groups from an existing server. If the
        security groups are not present on the server this will continue
        unaffected.

        :returns: False if server or security groups are undefined, True
            otherwise.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server, security_groups = self._get_server_security_groups(
            server, security_groups
        )

        if not (server and security_groups):
            return False

        ret = True

        for sg in security_groups:
            try:
                self.compute.remove_security_group_from_server(server, sg)

            except exceptions.NotFoundException:
                # NOTE(jamielennox): Is this ok? If we remove something that
                # isn't present should we just conclude job done or is that an
                # error? Nova returns ok if you try to add a group twice.
                self.log.debug(
                    "The security group %s was not present on server %s so "
                    "no action was performed",
                    sg.name,
                    server.name,
                )
                ret = False

        return ret

    def list_servers(
        self,
        detailed=False,
        all_projects=False,
        bare=False,
        filters=None,
    ):
        """List all available servers.

        :param detailed: Whether or not to add detailed additional information.
            Defaults to False.
        :param all_projects: Whether to list servers from all projects or just
            the current auth scoped project.
        :param bare: Whether to skip adding any additional information to the
            server record. Defaults to False, meaning the addresses dict will
            be populated as needed from neutron. Setting to True implies
            detailed = False.
        :param filters: Additional query parameters passed to the API server.
        :returns: A list of compute ``Server`` objects.
        """
        if not filters:
            filters = {}

        return [
            self._expand_server(server, detailed, bare)
            for server in self.compute.servers(
                all_projects=all_projects,
                **filters,
            )
        ]

    def list_server_groups(self):
        """List all available server groups.

        :returns: A list of compute ``ServerGroup`` objects.
        """
        return list(self.compute.server_groups())

    def get_compute_limits(self, name_or_id=None):
        """Get absolute compute limits for a project

        :param name_or_id: (optional) project name or ID to get limits for
            if different from the current project

        :returns: A compute
            :class:`~openstack.compute.v2.limits.Limits.AbsoluteLimits` object.
        :raises: :class:`~openstack.exceptions.SDKException` if it's not a
            valid project
        """
        params = {}
        if name_or_id:
            project = self.identity.find_project(
                name_or_id, ignore_missing=False
            )
            params['tenant_id'] = project.id
        return self.compute.get_limits(**params).absolute

    def get_keypair(self, name_or_id, filters=None):
        """Get a keypair by name or ID.

        :param name_or_id: Name or ID of the keypair.
        :param filters: A dictionary of meta data to use for further filtering.
            Elements of this dictionary may, themselves, be dictionaries.
            Example::

                {'last_name': 'Smith', 'other': {'gender': 'Female'}}

            OR
            A string containing a jmespath expression for further filtering.
            Example:: "[?last_name==`Smith`] | [?other.gender]==`Female`]"

        :returns: A compute ``Keypair`` object if found, else None.
        """
        return _utils._get_entity(self, 'keypair', name_or_id, filters)

    def get_flavor(self, name_or_id, filters=None, get_extra=True):
        """Get a flavor by name or ID.

        :param name_or_id: Name or ID of the flavor.
        :param filters: A dictionary of meta data to use for further filtering.
            Elements of this dictionary may, themselves, be dictionaries.
            Example::

                {'last_name': 'Smith', 'other': {'gender': 'Female'}}

            OR
            A string containing a jmespath expression for further filtering.
            Example:: "[?last_name==`Smith`] | [?other.gender]==`Female`]"

        :param get_extra: Whether or not the list_flavors call should get the
            extra flavor specs.
        :returns: A compute ``Flavor`` object if found, else None.
        """
        if not filters:
            filters = {}
        return self.compute.find_flavor(
            name_or_id,
            get_extra_specs=get_extra,
            ignore_missing=True,
            **filters,
        )

    def get_flavor_by_id(self, id, get_extra=False):
        """Get a flavor by ID

        :param id: ID of the flavor.
        :param get_extra: Whether or not the list_flavors call should get the
            extra flavor specs.
        :returns: A compute ``Flavor`` object if found, else None.
        """
        return self.compute.get_flavor(id, get_extra_specs=get_extra)

    def get_server_console(self, server, length=None):
        """Get the console log for a server.

        :param server: The server to fetch the console log for. Can be either
            a server dict or the Name or ID of the server.
        :param int length: The number of lines you would like to retrieve from
            the end of the log. (optional, defaults to all)

        :returns: A string containing the text of the console log or an
            empty string if the cloud does not support console logs.
        :raises: :class:`~openstack.exceptions.SDKException` if an invalid
            server argument is given or if something else unforseen happens
        """

        if not isinstance(server, dict):
            server = self.get_server(server, bare=True)

        if not server:
            raise exceptions.SDKException(
                "Console log requested for invalid server"
            )

        try:
            return self._get_server_console_output(server['id'], length)
        except exceptions.BadRequestException:
            return ""

    def _get_server_console_output(self, server_id, length=None):
        output = self.compute.get_server_console_output(
            server=server_id, length=length
        )
        if 'output' in output:
            return output['output']

    def get_server(
        self,
        name_or_id=None,
        filters=None,
        detailed=False,
        bare=False,
        all_projects=False,
    ):
        """Get a server by name or ID.

        :param name_or_id: Name or ID of the server.
        :param filters:
            A dictionary of meta data to use for further filtering. Elements
            of this dictionary may, themselves, be dictionaries. Example::

                {'last_name': 'Smith', 'other': {'gender': 'Female'}}

            OR
            A string containing a jmespath expression for further filtering.
            Example:: "[?last_name==`Smith`] | [?other.gender]==`Female`]"
        :param detailed: Whether or not to add detailed additional information.
            Defaults to False.
        :param bare: Whether to skip adding any additional information to the
            server record. Defaults to False, meaning the addresses dict will
            be populated as needed from neutron. Setting to True implies
            detailed = False.
        :param all_projects: Whether to get server from all projects or just
            the current auth scoped project.
        :returns: A compute ``Server`` object if found, else None.
        """
        searchfunc = functools.partial(
            self.search_servers,
            detailed=detailed,
            bare=True,
            all_projects=all_projects,
        )
        server = _utils._get_entity(self, searchfunc, name_or_id, filters)
        return self._expand_server(server, detailed, bare)

    def _expand_server(self, server, detailed, bare):
        if bare or not server:
            return server
        elif detailed:
            return meta.get_hostvars_from_server(self, server)
        else:
            return meta.add_server_interfaces(self, server)

    def get_server_by_id(self, id):
        """Get a server by ID.

        :param id: ID of the server.

        :returns: A compute ``Server`` object if found, else None.
        """
        try:
            server = self.compute.get_server(id)
            return meta.add_server_interfaces(self, server)
        except exceptions.NotFoundException:
            return None

    def get_server_group(self, name_or_id=None, filters=None):
        """Get a server group by name or ID.

        :param name_or_id: Name or ID of the server group.
        :param filters: A dictionary of meta data to use for further filtering.
            Elements of this dictionary may, themselves, be dictionaries.
            Example::

                {
                    'policy': 'affinity',
                }

            OR
            A string containing a jmespath expression for further filtering.
            Example:: "[?last_name==`Smith`] | [?other.gender]==`Female`]"

        :returns: A compute ``ServerGroup`` object if found, else None.
        """
        return _utils._get_entity(self, 'server_group', name_or_id, filters)

    def create_keypair(self, name, public_key=None):
        """Create a new keypair.

        :param name: Name of the keypair being created.
        :param public_key: Public key for the new keypair.

        :returns: The created compute ``Keypair`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        keypair = {
            'name': name,
        }
        if public_key:
            keypair['public_key'] = public_key
        return self.compute.create_keypair(**keypair)

    def delete_keypair(self, name):
        """Delete a keypair.

        :param name: Name of the keypair to delete.

        :returns: True if delete succeeded, False otherwise.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        try:
            self.compute.delete_keypair(name, ignore_missing=False)
        except exceptions.NotFoundException:
            self.log.debug("Keypair %s not found for deleting", name)
            return False
        return True

    def create_image_snapshot(
        self,
        name,
        server,
        wait=False,
        timeout=3600,
        **metadata,
    ):
        """Create an image by snapshotting an existing server.

        ..note::
            On most clouds this is a cold snapshot - meaning that the server in
            question will be shutdown before taking the snapshot. It is
            possible that it's a live snapshot - but there is no way to know as
            a user, so caveat emptor.

        :param name: Name of the image to be created
        :param server: Server name or ID or dict representing the server
            to be snapshotted
        :param wait: If true, waits for image to be created.
        :param timeout: Seconds to wait for image creation. None is forever.
        :param metadata: Metadata to give newly-created image entity

        :returns: The created image ``Image`` object.
        :raises: :class:`~openstack.exceptions.SDKException` if there are
            problems uploading
        """
        if not isinstance(server, dict):
            server_obj = self.get_server(server, bare=True)
            if not server_obj:
                raise exceptions.SDKException(
                    f"Server {server} could not be found and therefore "
                    f"could not be snapshotted."
                )
            server = server_obj
        image = self.compute.create_server_image(
            server, name=name, metadata=metadata, wait=wait, timeout=timeout
        )
        return image

    def get_server_id(self, name_or_id):
        """Get the ID of a server.

        :param name_or_id:
        :returns: The name of the server if found, else None.
        """
        server = self.get_server(name_or_id, bare=True)
        if server:
            return server['id']
        return None

    def get_server_private_ip(self, server):
        """Get the private IP of a server.

        :param server:
        :returns: The private IP of the server if set, else None.
        """
        return meta.get_server_private_ip(server, self)

    def get_server_public_ip(self, server):
        """Get the public IP of a server.

        :param server:
        :returns: The public IP of the server if set, else None.
        """
        return meta.get_server_external_ipv4(self, server)

    def get_server_meta(self, server):
        """Get the metadata for a server.

        :param server:
        :returns: The metadata for the server if found, else None.
        """
        # TODO(mordred) remove once ansible has moved to Inventory interface
        server_vars = meta.get_hostvars_from_server(self, server)
        groups = meta.get_groups_from_server(self, server, server_vars)
        return dict(server_vars=server_vars, groups=groups)

    @_utils.valid_kwargs(
        'meta',
        'files',
        'userdata',
        'description',
        'reservation_id',
        'return_raw',
        'min_count',
        'max_count',
        'security_groups',
        'key_name',
        'availability_zone',
        'block_device_mapping',
        'block_device_mapping_v2',
        'nics',
        'scheduler_hints',
        'config_drive',
        'admin_pass',
        'disk_config',
        'tags',
    )
    def create_server(
        self,
        name,
        image=None,
        flavor=None,
        auto_ip=True,
        ips=None,
        ip_pool=None,
        root_volume=None,
        terminate_volume=False,
        wait=False,
        timeout=180,
        reuse_ips=True,
        network=None,
        boot_from_volume=False,
        volume_size='50',
        boot_volume=None,
        volumes=None,
        nat_destination=None,
        group=None,
        **kwargs,
    ):
        """Create a virtual server instance.

        :param name: Something to name the server.
        :param image: Image dict, name or ID to boot with. image is required
            unless boot_volume is given.
        :param flavor: Flavor dict, name or ID to boot onto.
        :param auto_ip: Whether to take actions to find a routable IP for
            the server. (defaults to True)
        :param ips: List of IPs to attach to the server (defaults to None)
        :param ip_pool: Name of the network or floating IP pool to get an
            address from. (defaults to None)
        :param root_volume: Name or ID of a volume to boot from
            (defaults to None - deprecated, use boot_volume)
        :param boot_volume: Name or ID of a volume to boot from
            (defaults to None)
        :param terminate_volume: If booting from a volume, whether it should
            be deleted when the server is destroyed.  (defaults to False)
        :param volumes: (optional) A list of volumes to attach to the server
        :param meta: (optional) A dict of arbitrary key/value metadata to
            store for this server. Both keys and values must be <=255
            characters.
        :param files: (optional, deprecated) A dict of files to overwrite
            on the server upon boot. Keys are file names (i.e.
            ``/etc/passwd``) and values are the file contents (either as a
            string or as a file-like object). A maximum of five entries is
            allowed, and each file must be 10k or less.
        :param reservation_id: a UUID for the set of servers being requested.
        :param min_count: (optional extension) The minimum number of servers to
            launch.
        :param max_count: (optional extension) The maximum number of servers to
            launch.
        :param security_groups: A list of security group names
        :param userdata: user data to pass to be exposed by the metadata
            server this can be a file type object as well or a string.
        :param key_name: (optional extension) name of previously created
            keypair to inject into the instance.
        :param availability_zone: Name of the availability zone for instance
            placement.
        :param block_device_mapping: (optional) A dict of block
            device mappings for this server.
        :param block_device_mapping_v2: (optional) A dict of block
            device mappings for this server.
        :param nics:  (optional extension) an ordered list of nics to be
            added to this server, with information about connected networks,
            fixed IPs, port etc.
        :param scheduler_hints: (optional extension) arbitrary key-value pairs
            specified by the client to help boot an instance
        :param config_drive: (optional extension) value for config drive
            either boolean, or volume-id
        :param disk_config: (optional extension) control how the disk is
            partitioned when the server is created. possible values are 'AUTO'
            or 'MANUAL'.
        :param admin_pass: (optional extension) add a user supplied admin
            password.
        :param wait: (optional) Wait for the address to appear as assigned
            to the server. Defaults to False.
        :param timeout: (optional) Seconds to wait, defaults to 60.
            See the ``wait`` parameter.
        :param reuse_ips: (optional) Whether to attempt to reuse pre-existing
            floating ips should a floating IP be needed (defaults to True)
        :param network: (optional) Network dict or name or ID to attach the
            server to. Mutually exclusive with the nics parameter. Can also
            be a list of network names or IDs or network dicts.
        :param boot_from_volume: Whether to boot from volume. 'boot_volume'
            implies True, but boot_from_volume=True with no boot_volume is
            valid and will create a volume from the image and use that.
        :param volume_size: When booting an image from volume, how big should
            the created volume be? Defaults to 50.
        :param nat_destination: Which network should a created floating IP
            be attached to, if it's not possible to infer from the cloud's
            configuration. (Optional, defaults to None)
        :param group: ServerGroup dict, name or id to boot the server in.
            If a group is provided in both scheduler_hints and in the group
            param, the group param will win. (Optional, defaults to None)

        :returns: The created compute ``Server`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        # TODO(shade) Image is optional but flavor is not - yet flavor comes
        # after image in the argument list. Doh.
        if not flavor:
            raise TypeError(
                "create_server() missing 1 required argument: 'flavor'"
            )
        if not image and not boot_volume:
            raise TypeError(
                "create_server() requires either 'image' or 'boot_volume'"
            )

        # TODO(mordred) Add support for description starting in 2.19
        security_groups = kwargs.get('security_groups', [])
        if security_groups and not isinstance(kwargs['security_groups'], list):
            security_groups = [security_groups]
        if security_groups:
            kwargs['security_groups'] = []
            for sec_group in security_groups:
                kwargs['security_groups'].append(dict(name=sec_group))
        if 'userdata' in kwargs:
            user_data = kwargs.pop('userdata')
            if user_data:
                kwargs['user_data'] = self._encode_server_userdata(user_data)
        for desired, given in (
            ('OS-DCF:diskConfig', 'disk_config'),
            ('config_drive', 'config_drive'),
            ('key_name', 'key_name'),
            ('metadata', 'meta'),
            ('adminPass', 'admin_pass'),
        ):
            value = kwargs.pop(given, None)
            if value:
                kwargs[desired] = value

        if group:
            if isinstance(group, dict):
                if not isinstance(group, resource.Resource):
                    warnings.warn(
                        "Support for passing server group as a raw dict has "
                        "been deprecated for removal. Consider passing a "
                        "string name or ID or a ServerGroup object instead.",
                        os_warnings.RemovedInSDK60Warning,
                    )
                group_id = group['id']
            else:  # object
                group_obj = self.compute.find_server_group(
                    group, ignore_missing=False
                )
                group_id = group_obj['id']
            if 'scheduler_hints' not in kwargs:
                kwargs['scheduler_hints'] = {}
            kwargs['scheduler_hints']['group'] = group_id

        kwargs.setdefault('max_count', kwargs.get('max_count', 1))
        kwargs.setdefault('min_count', kwargs.get('min_count', 1))

        if 'nics' in kwargs and not isinstance(kwargs['nics'], list):
            if isinstance(kwargs['nics'], dict):
                # Be nice and help the user out
                kwargs['nics'] = [kwargs['nics']]
            else:
                raise exceptions.SDKException(
                    'nics parameter to create_server takes a list of dicts. '
                    'Got: {nics}'.format(nics=kwargs['nics'])
                )

        if network and ('nics' not in kwargs or not kwargs['nics']):
            nics = []
            if not isinstance(network, list):
                network = [network]
            for net in network:
                if isinstance(net, dict):
                    if not isinstance(net, resource.Resource):
                        warnings.warn(
                            "Support for passing network as a raw dict has "
                            "been deprecated for removal. Consider passing a "
                            "string name or ID or a Network object instead.",
                            os_warnings.RemovedInSDK60Warning,
                        )
                    network_id = net['id']
                else:
                    network_obj = self.network.find_network(
                        net, ignore_missing=False
                    )
                    network_id = network_obj['id']
                nics.append({'net-id': network_id})

            kwargs['nics'] = nics
        if not network and ('nics' not in kwargs or not kwargs['nics']):
            default_network = self.get_default_network()
            if default_network:
                kwargs['nics'] = [{'net-id': default_network['id']}]

        networks = []
        for nic in kwargs.pop('nics', []):
            net = {}
            if 'net-id' in nic:
                # TODO(mordred) Make sure this is in uuid format
                net['uuid'] = nic.pop('net-id')
                # If there's a net-id, ignore net-name
                nic.pop('net-name', None)
            elif 'net-name' in nic:
                net_name = nic.pop('net-name')
                nic_net = self.network.find_network(
                    net_name, ignore_missing=False
                )
                net['uuid'] = nic_net['id']
            for ip_key in ('v4-fixed-ip', 'v6-fixed-ip', 'fixed_ip'):
                fixed_ip = nic.pop(ip_key, None)
                if fixed_ip and net.get('fixed_ip'):
                    raise exceptions.SDKException(
                        "Only one of v4-fixed-ip, v6-fixed-ip or fixed_ip "
                        "may be given"
                    )
                if fixed_ip:
                    net['fixed_ip'] = fixed_ip
            for key in ('port', 'port-id'):
                if key in nic:
                    net['port'] = nic.pop(key)
            # A tag supported only in server microversion 2.32-2.36 or >= 2.42
            # Bumping the version to 2.42 to support the 'tag' implementation
            if 'tag' in nic:
                utils.require_microversion(self.compute, '2.42')
                net['tag'] = nic.pop('tag')
            if nic:
                raise exceptions.SDKException(
                    f"Additional unsupported keys given for server network "
                    f"creation: {nic.keys()}"
                )
            networks.append(net)
        if networks:
            kwargs['networks'] = networks
        else:
            # If user has not passed networks - let Nova try the best;
            # note earlier microversions expect this to be blank.
            if utils.supports_microversion(self.compute, '2.37'):
                kwargs['networks'] = 'auto'

        if image:
            if isinstance(image, dict):
                if not isinstance(image, resource.Resource):
                    warnings.warn(
                        "Support for passing image as a raw dict has "
                        "been deprecated for removal. Consider passing a "
                        "string name or ID or an Image object instead.",
                        os_warnings.RemovedInSDK60Warning,
                    )
                kwargs['imageRef'] = image['id']
            else:
                image_obj = self.image.find_image(image, ignore_missing=False)
                kwargs['imageRef'] = image_obj.id

        if isinstance(flavor, dict):
            if not isinstance(flavor, resource.Resource):
                warnings.warn(
                    "Support for passing flavor as a raw dict has "
                    "been deprecated for removal. Consider passing a "
                    "string name or ID or a Flavor object instead.",
                    os_warnings.RemovedInSDK60Warning,
                )
            kwargs['flavorRef'] = flavor['id']
        else:
            kwargs['flavorRef'] = self.get_flavor(flavor, get_extra=False).id

        if volumes is None:
            volumes = []

        # nova cli calls this boot_volume. Let's be the same
        if root_volume and not boot_volume:
            boot_volume = root_volume

        kwargs = self._get_boot_from_volume_kwargs(
            image=image,
            boot_from_volume=boot_from_volume,
            boot_volume=boot_volume,
            volume_size=str(volume_size),
            terminate_volume=terminate_volume,
            volumes=volumes,
            kwargs=kwargs,
        )

        kwargs['name'] = name

        server = self.compute.create_server(**kwargs)
        # TODO(mordred) We're only testing this in functional tests. We need
        # to add unit tests for this too.
        admin_pass = server.admin_password or kwargs.get('admin_pass')
        if not wait:
            server = self.compute.get_server(server.id)
            if server['status'] == 'ERROR':
                if (
                    'fault' in server
                    and server['fault'] is not None
                    and 'message' in server['fault']
                ):
                    raise exceptions.SDKException(
                        "Error in creating the server. "
                        "Compute service reports fault: {reason}".format(
                            reason=server['fault']['message']
                        ),
                        extra_data=dict(server=server),
                    )

                raise exceptions.SDKException(
                    "Error in creating the server "
                    "(no further information available)",
                    extra_data=dict(server=server),
                )

            server = meta.add_server_interfaces(self, server)

        else:
            server = self.wait_for_server(
                server,
                auto_ip=auto_ip,
                ips=ips,
                ip_pool=ip_pool,
                reuse=reuse_ips,
                timeout=timeout,
                nat_destination=nat_destination,
            )

        server.admin_password = admin_pass
        return server

    def _get_boot_from_volume_kwargs(
        self,
        image,
        boot_from_volume,
        boot_volume,
        volume_size,
        terminate_volume,
        volumes,
        kwargs,
    ):
        """Return block device mappings

        :param image: Image dict, name or id to boot with.

        """
        # TODO(mordred) We're only testing this in functional tests. We need
        # to add unit tests for this too.
        if boot_volume or boot_from_volume or volumes:
            kwargs.setdefault('block_device_mapping_v2', [])
        else:
            return kwargs

        # If we have boot_from_volume but no root volume, then we're
        # booting an image from volume
        if boot_volume:
            if isinstance(boot_volume, dict):
                if not isinstance(boot_volume, resource.Resource):
                    warnings.warn(
                        "Support for passing boot_volume as a raw dict has "
                        "been deprecated for removal. Consider passing a "
                        "string name or ID or a Volume object instead.",
                        os_warnings.RemovedInSDK60Warning,
                    )
                volume_id = boot_volume['id']
            else:
                volume = self.block_storage.find_volume(
                    boot_volume, ignore_missing=False
                )
                volume_id = volume['id']
            block_mapping = {
                'boot_index': '0',
                'delete_on_termination': terminate_volume,
                'destination_type': 'volume',
                'uuid': volume_id,
                'source_type': 'volume',
            }
            kwargs['block_device_mapping_v2'].append(block_mapping)
            kwargs['imageRef'] = ''
        elif boot_from_volume:
            if isinstance(image, dict):
                if not isinstance(image, resource.Resource):
                    warnings.warn(
                        "Support for passing image as a raw dict has "
                        "been deprecated for removal. Consider passing a "
                        "string name or ID or an Image object instead.",
                        os_warnings.RemovedInSDK60Warning,
                    )
                image_obj = image
            else:
                image_obj = self.image.find_image(image, ignore_missing=False)

            block_mapping = {
                'boot_index': '0',
                'delete_on_termination': terminate_volume,
                'destination_type': 'volume',
                'uuid': image_obj['id'],
                'source_type': 'image',
                'volume_size': volume_size,
            }
            kwargs['imageRef'] = ''
            kwargs['block_device_mapping_v2'].append(block_mapping)

        if volumes and kwargs['imageRef']:
            # If we're attaching volumes on boot but booting from an image,
            # we need to specify that in the BDM.
            block_mapping = {
                'boot_index': 0,
                'delete_on_termination': True,
                'destination_type': 'local',
                'source_type': 'image',
                'uuid': kwargs['imageRef'],
            }
            kwargs['block_device_mapping_v2'].append(block_mapping)

        for volume in volumes:
            if isinstance(volume, dict):
                if not isinstance(volume, resource.Resource):
                    warnings.warn(
                        "Support for passing volumes as a list of raw dicts "
                        "been deprecated for removal. Consider passing a list "
                        "of string name or ID or ServerGroup objects instead.",
                        os_warnings.RemovedInSDK60Warning,
                    )
                volume_id = volume['id']
            else:
                volume_obj = self.block_storage.find_volume(
                    volume, ignore_missing=False
                )
                volume_id = volume_obj['id']
            block_mapping = {
                'boot_index': '-1',
                'delete_on_termination': False,
                'destination_type': 'volume',
                'uuid': volume_id,
                'source_type': 'volume',
            }
            kwargs['block_device_mapping_v2'].append(block_mapping)
        return kwargs

    def wait_for_server(
        self,
        server,
        auto_ip=True,
        ips=None,
        ip_pool=None,
        reuse=True,
        timeout=180,
        nat_destination=None,
    ):
        """
        Wait for a server to reach ACTIVE status.
        """
        server_id = server['id']
        timeout_message = "Timeout waiting for the server to come up."
        start_time = time.time()

        for count in utils.iterate_timeout(
            timeout,
            timeout_message,
            wait=min(5, timeout),
        ):
            try:
                server = self.get_server(server_id)
            except Exception:
                continue
            if not server:
                continue

            # We have more work to do, but the details of that are
            # hidden from the user. So, calculate remaining timeout
            # and pass it down into the IP stack.
            remaining_timeout = timeout - int(time.time() - start_time)
            if remaining_timeout <= 0:
                raise exceptions.ResourceTimeout(timeout_message)

            server = self.get_active_server(
                server=server,
                reuse=reuse,
                auto_ip=auto_ip,
                ips=ips,
                ip_pool=ip_pool,
                wait=True,
                timeout=remaining_timeout,
                nat_destination=nat_destination,
            )

            if server is not None and server['status'] == 'ACTIVE':
                return server

    def get_active_server(
        self,
        server,
        auto_ip=True,
        ips=None,
        ip_pool=None,
        reuse=True,
        wait=False,
        timeout=180,
        nat_destination=None,
    ):
        if server['status'] == 'ERROR':
            if (
                'fault' in server
                and server['fault'] is not None
                and 'message' in server['fault']
            ):
                raise exceptions.SDKException(
                    "Error in creating the server. "
                    "Compute service reports fault: {reason}".format(
                        reason=server['fault']['message']
                    ),
                    extra_data=dict(server=server),
                )

            raise exceptions.SDKException(
                "Error in creating the server "
                "(no further information available)",
                extra_data=dict(server=server),
            )

        if server['status'] == 'ACTIVE':
            if 'addresses' in server and server['addresses']:
                return self.add_ips_to_server(
                    server,
                    auto_ip,
                    ips,
                    ip_pool,
                    reuse=reuse,
                    nat_destination=nat_destination,
                    wait=wait,
                    timeout=timeout,
                )

            self.log.debug(
                f'Server {server["id"]} reached ACTIVE state without '
                f'being allocated an IP address. Deleting server.',
            )
            try:
                self._delete_server(server=server, wait=wait, timeout=timeout)
            except Exception as e:
                raise exceptions.SDKException(
                    f'Server reached ACTIVE state without being '
                    f'allocated an IP address AND then could not '
                    f'be deleted: {e}',
                    extra_data=dict(server=server),
                )
            raise exceptions.SDKException(
                'Server reached ACTIVE state without being '
                'allocated an IP address.',
                extra_data=dict(server=server),
            )
        return None

    def rebuild_server(
        self,
        server_id,
        image_id,
        admin_pass=None,
        detailed=False,
        bare=False,
        wait=False,
        timeout=180,
    ):
        """Rebuild a server.

        :param server_id:
        :param image_id:
        :param admin_pass:
        :param detailed:
        :param bare:
        :param wait:
        :param timeout:
        :returns: A compute ``Server`` object.
        """
        kwargs = {}
        if image_id:
            kwargs['image'] = image_id
        if admin_pass:
            kwargs['admin_password'] = admin_pass

        server = self.compute.rebuild_server(server_id, **kwargs)
        if not wait:
            return self._expand_server(server, bare=bare, detailed=detailed)

        admin_pass = server.get('adminPass') or admin_pass
        server = self.compute.wait_for_server(server, wait=timeout)
        if server['status'] == 'ACTIVE':
            server.adminPass = admin_pass

        return self._expand_server(server, detailed=detailed, bare=bare)

    def set_server_metadata(self, name_or_id, metadata):
        """Set metadata in a server instance.

        :param str name_or_id: The name or ID of the server instance to update.
        :param dict metadata: A dictionary with the key=value pairs
            to set in the server instance. It only updates the key=value pairs
            provided. Existing ones will remain untouched.

        :returns: None
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server = self.get_server(name_or_id, bare=True)
        if not server:
            raise exceptions.SDKException(f'Invalid Server {name_or_id}')

        self.compute.set_server_metadata(server=server.id, **metadata)

    def delete_server_metadata(self, name_or_id, metadata_keys):
        """Delete metadata from a server instance.

        :param str name_or_id: The name or ID of the server instance
            to update.
        :param metadata_keys: A list with the keys to be deleted
            from the server instance.

        :returns: None
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server = self.get_server(name_or_id, bare=True)
        if not server:
            raise exceptions.SDKException(f'Invalid Server {name_or_id}')

        self.compute.delete_server_metadata(
            server=server.id, keys=metadata_keys
        )

    def delete_server(
        self,
        name_or_id,
        wait=False,
        timeout=180,
        delete_ips=False,
        delete_ip_retry=1,
    ):
        """Delete a server instance.

        :param name_or_id: name or ID of the server to delete
        :param bool wait: If true, waits for server to be deleted.
        :param int timeout: Seconds to wait for server deletion.
        :param bool delete_ips: If true, deletes any floating IPs
            associated with the instance.
        :param int delete_ip_retry: Number of times to retry deleting
            any floating ips, should the first try be unsuccessful.

        :returns: True if delete succeeded, False otherwise if the
            server does not exist.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        # If delete_ips is True, we need the server to not be bare.
        server = self.compute.find_server(name_or_id, ignore_missing=True)
        if not server:
            return False

        # This portion of the code is intentionally left as a separate
        # private method in order to avoid an unnecessary API call to get
        # a server we already have.
        return self._delete_server(
            server,
            wait=wait,
            timeout=timeout,
            delete_ips=delete_ips,
            delete_ip_retry=delete_ip_retry,
        )

    def _delete_server_floating_ips(self, server, delete_ip_retry):
        # Does the server have floating ips in its
        # addresses dict? If not, skip this.
        server_floats = meta.find_nova_interfaces(
            server['addresses'], ext_tag='floating'
        )
        for fip in server_floats:
            try:
                ip = self.get_floating_ip(
                    id=None, filters={'floating_ip_address': fip['addr']}
                )
            except exceptions.NotFoundException:
                # We're deleting. If it doesn't exist - awesome
                # NOTE(mordred) If the cloud is a nova FIP cloud but
                #               floating_ip_source is set to neutron, this
                #               can lead to a FIP leak.
                continue
            if not ip:
                continue
            deleted = self.delete_floating_ip(ip['id'], retry=delete_ip_retry)
            if not deleted:
                raise exceptions.SDKException(
                    "Tried to delete floating ip {floating_ip} "
                    "associated with server {id} but there was "
                    "an error deleting it. Not deleting server.".format(
                        floating_ip=ip['floating_ip_address'], id=server['id']
                    )
                )

    def _delete_server(
        self,
        server,
        wait=False,
        timeout=180,
        delete_ips=False,
        delete_ip_retry=1,
    ):
        if not server:
            return False

        if delete_ips and self._has_floating_ips() and server['addresses']:
            self._delete_server_floating_ips(server, delete_ip_retry)

        try:
            self.compute.delete_server(server)
        except exceptions.NotFoundException:
            return False
        except Exception:
            raise

        if not wait:
            return True

        if not isinstance(server, _server.Server):
            # We might come here with Munch object (at the moment).
            # If this is the case - convert it into real server to be able to
            # use wait_for_delete
            server = _server.Server(id=server['id'])
        self.compute.wait_for_delete(server, wait=timeout)

        return True

    @_utils.valid_kwargs('name', 'description')
    def update_server(self, name_or_id, detailed=False, bare=False, **kwargs):
        """Update a server.

        :param name_or_id: Name of the server to be updated.
        :param detailed: Whether or not to add detailed additional information.
            Defaults to False.
        :param bare: Whether to skip adding any additional information to the
            server record. Defaults to False, meaning the addresses dict will
            be populated as needed from neutron. Setting to True implies
            detailed = False.
        :param name: New name for the server
        :param description: New description for the server

        :returns: The updated compute ``Server`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server = self.compute.find_server(name_or_id, ignore_missing=False)
        server = self.compute.update_server(server, **kwargs)

        return self._expand_server(server, bare=bare, detailed=detailed)

    def create_server_group(self, name, policies=None, policy=None):
        """Create a new server group.

        :param name: Name of the server group being created
        :param policies: List of policies for the server group.

        :returns: The created compute ``ServerGroup`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        sg_attrs = {'name': name}
        if policies:
            sg_attrs['policies'] = policies
        if policy:
            sg_attrs['policy'] = policy
        return self.compute.create_server_group(**sg_attrs)

    def delete_server_group(self, name_or_id):
        """Delete a server group.

        :param name_or_id: Name or ID of the server group to delete

        :returns: True if delete succeeded, False otherwise
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        server_group = self.get_server_group(name_or_id)
        if not server_group:
            self.log.debug(
                "Server group %s not found for deleting", name_or_id
            )
            return False

        self.compute.delete_server_group(server_group, ignore_missing=False)
        return True

    def create_flavor(
        self,
        name,
        ram,
        vcpus,
        disk,
        description=None,
        flavorid="auto",
        ephemeral=0,
        swap=0,
        rxtx_factor=1.0,
        is_public=True,
    ):
        """Create a new flavor.

        :param name: Descriptive name of the flavor
        :param ram: Memory in MB for the flavor
        :param vcpus: Number of VCPUs for the flavor
        :param disk: Size of local disk in GB
        :param description: Description of the flavor
        :param flavorid: ID for the flavor (optional)
        :param ephemeral: Ephemeral space size in GB
        :param swap: Swap space in MB
        :param rxtx_factor: RX/TX factor
        :param is_public: Make flavor accessible to the public

        :returns: The created compute ``Flavor`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        attrs = {
            'disk': disk,
            'ephemeral': ephemeral,
            'id': flavorid,
            'is_public': is_public,
            'name': name,
            'ram': ram,
            'rxtx_factor': rxtx_factor,
            'swap': swap,
            'vcpus': vcpus,
            'description': description,
        }
        if flavorid == 'auto':
            attrs['id'] = None

        return self.compute.create_flavor(**attrs)

    def delete_flavor(self, name_or_id):
        """Delete a flavor

        :param name_or_id: ID or name of the flavor to delete.

        :returns: True if delete succeeded, False otherwise.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        try:
            flavor = self.compute.find_flavor(name_or_id, ignore_missing=True)
            if not flavor:
                self.log.debug("Flavor %s not found for deleting", name_or_id)
                return False
            self.compute.delete_flavor(flavor)
            return True
        except exceptions.SDKException:
            raise exceptions.SDKException(
                f"Unable to delete flavor {name_or_id}"
            )

    def set_flavor_specs(self, flavor_id, extra_specs):
        """Add extra specs to a flavor

        :param string flavor_id: ID of the flavor to update.
        :param dict extra_specs: Dictionary of key-value pairs.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        :raises: :class:`~openstack.exceptions.BadRequestException` if flavor
            ID is not found.
        """
        self.compute.create_flavor_extra_specs(flavor_id, extra_specs)

    def unset_flavor_specs(self, flavor_id, keys):
        """Delete extra specs from a flavor

        :param string flavor_id: ID of the flavor to update.
        :param keys: List of spec keys to delete.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        :raises: :class:`~openstack.exceptions.BadRequestException` if flavor
            ID is not found.
        """
        for key in keys:
            self.compute.delete_flavor_extra_specs_property(flavor_id, key)

    def add_flavor_access(self, flavor_id, project_id):
        """Grant access to a private flavor for a project/tenant.

        :param string flavor_id: ID of the private flavor.
        :param string project_id: ID of the project/tenant.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        self.compute.flavor_add_tenant_access(flavor_id, project_id)

    def remove_flavor_access(self, flavor_id, project_id):
        """Revoke access from a private flavor for a project/tenant.

        :param string flavor_id: ID of the private flavor.
        :param string project_id: ID of the project/tenant.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        self.compute.flavor_remove_tenant_access(flavor_id, project_id)

    def list_flavor_access(self, flavor_id):
        """List access from a private flavor for a project/tenant.

        :param string flavor_id: ID of the private flavor.

        :returns: List of dicts with flavor_id and tenant_id attributes.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        return self.compute.get_flavor_access(flavor_id)

    def list_hypervisors(self, filters=None):
        """List all hypervisors

        :param filters:
        :returns: A list of compute ``Hypervisor`` objects.
        """
        if not filters:
            filters = {}

        return list(self.compute.hypervisors(details=True, **filters))

    def search_aggregates(self, name_or_id=None, filters=None):
        """Seach host aggregates.

        :param name: aggregate name or id.
        :param filters: a dict containing additional filters to use.

        :returns: A list of compute ``Aggregate`` objects matching the search
            criteria.
        :raises: :class:`~openstack.exceptions.SDKException` if something goes
            wrong during the OpenStack API call.
        """
        aggregates = self.list_aggregates()
        return _utils._filter_list(aggregates, name_or_id, filters)

    def list_aggregates(self, filters=None):
        """List all available host aggregates.

        :returns: A list of compute ``Aggregate`` objects.
        """
        if not filters:
            filters = {}

        return self.compute.aggregates(**filters)

    def get_aggregate(self, name_or_id, filters=None):
        """Get an aggregate by name or ID.

        :param name_or_id: Name or ID of the aggregate.
        :param dict filters:
            A dictionary of meta data to use for further filtering. Elements
            of this dictionary may, themselves, be dictionaries. Example::

                {
                    'availability_zone': 'nova',
                    'metadata': {'cpu_allocation_ratio': '1.0'},
                }

        :returns: An aggregate dict or None if no matching aggregate is
            found.
        """
        return self.compute.find_aggregate(name_or_id, ignore_missing=True)

    def create_aggregate(self, name, availability_zone=None):
        """Create a new host aggregate.

        :param name: Name of the host aggregate being created
        :param availability_zone: Availability zone to assign hosts

        :returns: The created compute ``Aggregate`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        return self.compute.create_aggregate(
            name=name, availability_zone=availability_zone
        )

    @_utils.valid_kwargs('name', 'availability_zone')
    def update_aggregate(self, name_or_id, **kwargs):
        """Update a host aggregate.

        :param name_or_id: Name or ID of the aggregate being updated.
        :param name: New aggregate name
        :param availability_zone: Availability zone to assign to hosts

        :returns: The updated compute ``Aggregate`` object.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        aggregate = self.get_aggregate(name_or_id)
        return self.compute.update_aggregate(aggregate, **kwargs)

    def delete_aggregate(self, name_or_id):
        """Delete a host aggregate.

        :param name_or_id: Name or ID of the host aggregate to delete.

        :returns: True if delete succeeded, False otherwise.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        if isinstance(name_or_id, (str, bytes)) and not name_or_id.isdigit():
            aggregate = self.get_aggregate(name_or_id)
            if not aggregate:
                self.log.debug(
                    "Aggregate %s not found for deleting", name_or_id
                )
                return False
            name_or_id = aggregate.id
        try:
            self.compute.delete_aggregate(name_or_id, ignore_missing=False)
            return True
        except exceptions.NotFoundException:
            self.log.debug("Aggregate %s not found for deleting", name_or_id)
            return False

    def set_aggregate_metadata(self, name_or_id, metadata):
        """Set aggregate metadata, replacing the existing metadata.

        :param name_or_id: Name of the host aggregate to update
        :param metadata: Dict containing metadata to replace (Use
            {'key': None} to remove a key)

        :returns: a dict representing the new host aggregate.
        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        aggregate = self.get_aggregate(name_or_id)
        if not aggregate:
            raise exceptions.SDKException(
                f"Host aggregate {name_or_id} not found."
            )

        return self.compute.set_aggregate_metadata(aggregate, metadata)

    def add_host_to_aggregate(self, name_or_id, host_name):
        """Add a host to an aggregate.

        :param name_or_id: Name or ID of the host aggregate.
        :param host_name: Host to add.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        aggregate = self.get_aggregate(name_or_id)
        if not aggregate:
            raise exceptions.SDKException(
                f"Host aggregate {name_or_id} not found."
            )

        return self.compute.add_host_to_aggregate(aggregate, host_name)

    def remove_host_from_aggregate(self, name_or_id, host_name):
        """Remove a host from an aggregate.

        :param name_or_id: Name or ID of the host aggregate.
        :param host_name: Host to remove.

        :raises: :class:`~openstack.exceptions.SDKException` on operation
            error.
        """
        aggregate = self.get_aggregate(name_or_id)
        if not aggregate:
            raise exceptions.SDKException(
                f"Host aggregate {name_or_id} not found."
            )

        return self.compute.remove_host_from_aggregate(aggregate, host_name)

    def set_compute_quotas(self, name_or_id, **kwargs):
        """Set a quota in a project

        :param name_or_id: project name or id
        :param kwargs: key/value pairs of quota name and quota value

        :raises: :class:`~openstack.exceptions.SDKException` if the resource to
            set the quota does not exist.
        """
        project = self.identity.find_project(name_or_id, ignore_missing=False)
        kwargs['force'] = True
        self.compute.update_quota_set(project=project, **kwargs)

    def get_compute_quotas(self, name_or_id):
        """Get quota for a project

        :param name_or_id: project name or id

        :returns: A compute ``QuotaSet`` object if found, else None.
        :raises: :class:`~openstack.exceptions.SDKException` if it's not a
            valid project
        """
        proj = self.identity.find_project(name_or_id, ignore_missing=False)
        return self.compute.get_quota_set(proj)

    def delete_compute_quotas(self, name_or_id):
        """Delete quota for a project

        :param name_or_id: project name or id

        :raises: :class:`~openstack.exceptions.SDKException` if it's not a
            valid project or the nova client call failed
        """
        proj = self.identity.find_project(name_or_id, ignore_missing=False)
        self.compute.revert_quota_set(proj)

    def get_compute_usage(self, name_or_id, start=None, end=None):
        """Get usage for a specific project

        :param name_or_id: project name or id
        :param start: :class:`datetime.datetime` or string. Start date in UTC
            Defaults to 2010-07-06T12:00:00Z (the date the OpenStack project
            was started)
        :param end: :class:`datetime.datetime` or string. End date in UTC.
            Defaults to now

        :returns: A :class:`~openstack.compute.v2.usage.Usage` object
        :raises: :class:`~openstack.exceptions.SDKException` if it's not a
            valid project
        """

        def parse_date(date):
            try:
                return iso8601.parse_date(date)
            except iso8601.iso8601.ParseError:
                # Yes. This is an exception mask. However,iso8601 is an
                # implementation detail - and the error message is actually
                # less informative.
                raise exceptions.SDKException(
                    f"Date given, {date}, is invalid. Please pass in a date "
                    f"string in ISO 8601 format (YYYY-MM-DDTHH:MM:SS)"
                )

        if isinstance(start, str):
            start = parse_date(start)
        if isinstance(end, str):
            end = parse_date(end)

        project = self.identity.find_project(name_or_id, ignore_missing=False)

        return self.compute.get_usage(project, start, end)

    def _encode_server_userdata(self, userdata):
        if hasattr(userdata, 'read'):
            userdata = userdata.read()

        if not isinstance(userdata, bytes):
            # If the userdata passed in is bytes, just send it unmodified
            if not isinstance(userdata, str):
                raise TypeError(f"{type(userdata)} can't be encoded")
            # If it's not bytes, make it bytes
            userdata = userdata.encode('utf-8', 'strict')

        # Once we have base64 bytes, make them into a utf-8 string for REST
        return base64.b64encode(userdata).decode('utf-8')

    def get_openstack_vars(self, server):
        return meta.get_hostvars_from_server(self, server)

    def _expand_server_vars(self, server):
        # Used by nodepool
        # TODO(mordred) remove after these make it into what we
        # actually want the API to be.
        return meta.expand_server_vars(self, server)

    def _remove_novaclient_artifacts(self, item):
        # Remove novaclient artifacts
        item.pop('links', None)
        item.pop('NAME_ATTR', None)
        item.pop('HUMAN_ID', None)
        item.pop('human_id', None)
        item.pop('request_ids', None)
        item.pop('x_openstack_request_ids', None)

    def _normalize_server(self, server):
        ret = utils.Munch()
        # Copy incoming server because of shared dicts in unittests
        # Wrap the copy in munch so that sub-dicts are properly munched
        server = utils.Munch(server)

        self._remove_novaclient_artifacts(server)

        ret['id'] = server.pop('id')
        ret['name'] = server.pop('name')

        server['flavor'].pop('links', None)
        ret['flavor'] = server.pop('flavor')
        # From original_names from sdk
        server.pop('flavorRef', None)

        # OpenStack can return image as a string when you've booted
        # from volume
        image = server.pop('image', None)
        if str(image) != image:
            image = utils.Munch(id=image['id'])

        ret['image'] = image
        # From original_names from sdk
        server.pop('imageRef', None)
        # From original_names from sdk
        ret['block_device_mapping'] = server.pop('block_device_mapping_v2', {})

        project_id = server.pop('tenant_id', '')
        project_id = server.pop('project_id', project_id)

        az = _pop_or_get(
            server, 'OS-EXT-AZ:availability_zone', None, self.strict_mode
        )
        # the server resource has this already, but it's missing az info
        # from the resource.
        # TODO(mordred) create_server is still normalizing servers that aren't
        # from the resource layer.
        ret['location'] = server.pop(
            'location',
            self._get_current_location(project_id=project_id, zone=az),
        )

        # Ensure volumes is always in the server dict, even if empty
        ret['volumes'] = _pop_or_get(
            server,
            'os-extended-volumes:volumes_attached',
            [],
            self.strict_mode,
        )

        config_drive = server.pop(
            'has_config_drive', server.pop('config_drive', False)
        )
        ret['has_config_drive'] = _to_bool(config_drive)

        host_id = server.pop('hostId', server.pop('host_id', None))
        ret['host_id'] = host_id

        ret['progress'] = _pop_int(server, 'progress')

        # Leave these in so that the general properties handling works
        ret['disk_config'] = _pop_or_get(
            server, 'OS-DCF:diskConfig', None, self.strict_mode
        )
        for key in (
            'OS-EXT-STS:power_state',
            'OS-EXT-STS:task_state',
            'OS-EXT-STS:vm_state',
            'OS-SRV-USG:launched_at',
            'OS-SRV-USG:terminated_at',
            'OS-EXT-SRV-ATTR:hypervisor_hostname',
            'OS-EXT-SRV-ATTR:instance_name',
            'OS-EXT-SRV-ATTR:user_data',
            'OS-EXT-SRV-ATTR:host',
            'OS-EXT-SRV-ATTR:hostname',
            'OS-EXT-SRV-ATTR:kernel_id',
            'OS-EXT-SRV-ATTR:launch_index',
            'OS-EXT-SRV-ATTR:ramdisk_id',
            'OS-EXT-SRV-ATTR:reservation_id',
            'OS-EXT-SRV-ATTR:root_device_name',
            'OS-SCH-HNT:scheduler_hints',
        ):
            short_key = key.split(':')[1]
            ret[short_key] = _pop_or_get(server, key, None, self.strict_mode)

        # Protect against security_groups being None
        ret['security_groups'] = server.pop('security_groups', None) or []

        # NOTE(mnaser): The Nova API returns the creation date in `created`
        #               however the Shade contract returns `created_at` for
        #               all resources.
        ret['created_at'] = server.get('created')

        for field in _SERVER_FIELDS:
            ret[field] = server.pop(field, None)
        if not ret['networks']:
            ret['networks'] = {}

        ret['interface_ip'] = ''

        ret['properties'] = server.copy()

        # Backwards compat
        if not self.strict_mode:
            ret['hostId'] = host_id
            ret['config_drive'] = config_drive
            ret['project_id'] = project_id
            ret['tenant_id'] = project_id
            # TODO(efried): This is hardcoded to 'compute' because this method
            # should only ever be used by the compute proxy. (That said, it
            # doesn't appear to be used at all, so can we get rid of it?)
            ret['region'] = self.config.get_region_name('compute')
            ret['cloud'] = self.config.name
            ret['az'] = az
            for key, val in ret['properties'].items():
                ret.setdefault(key, val)
        return ret
