python/maas/maas/src/maasserver/forms/pods.py

pods.py
# Copyright 2017-2019 Canonical Ltd.  This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

"""Pod forms."""

__all__ = ["PodForm"]

from functools import partial

import crochet
from django import forms
from django.core.exceptions import ValidationError
from django.forms import (
    BooleanField,
    CharField,
    ChoiceField,
    IntegerField,
    ModelChoiceField,
)
import petname
from twisted.internet.defer import inlineCallbacks
from twisted.python.threadable import isInIOThread

from maasterver.clusterrpc import driver_parameters
from maasterver.clusterrpc.driver_parameters import (
    get_driver_parameters_from_json,
)
from maasterver.clusterrpc.pods import (
    compose_machine,
    discover_pod,
    get_best_discovered_result,
)
from maasterver.enum import BMC_TYPE, INTERFACE_TYPE, NODE_CREATION_TYPE
from maasterver.exceptions import PodProblem
from maasterver.forms import MAASModelForm
from maasterver.models import (
    BMC,
    BMCRoutableRackControllerRelationship,
    Domain,
    Interface,
    Machine,
    Node,
    Pod,
    PodStoragePool,
    RackController,
    ResourcePool,
    Tag,
    Zone,
)
from maasterver.node_constraint_filter_forms import (
    get_storage_constraints_from_string,
    interfaces_validator,
    LabeledConstraintMapField,
    nodes_by_interface,
    storage_validator,
)
from maasterver.rpc import getClientFromIdentifiers
from maasterver.utils.dns import validate_hostname
from maasterver.utils.forms import set_form_error
from maasterver.utils.orm import transactional
from maasterver.utils.threads import deferToDatabase
from provisioningserver.drivers import SETTING_SCOPE
from provisioningserver.drivers.pod import (
    Capabilities,
    InterfaceAttachType,
    KnownHostInterface,
    RequestedMachine,
    RequestedMachineBlockDevice,
    RequestedMachineInterface,
)
from provisioningserver.enum import MACVLAN_MODE, MACVLAN_MODE_CHOICES
from provisioningserver.logger import LegacyLogger
from provisioningserver.utils.network import get_ifname_for_label
from provisioningserver.utils.twisted import asynchronous

log = LegacyLogger()


def make_unique_hostname():
    """Returns a unique machine hostname."""
    while True:
        hostname = petname.Generate(2, "-")
        if Machine.objects.filter(hostname=hostname).exists():
            continue
        else:
            return hostname


clast PodForm(MAASModelForm):
    clast Meta:
        model = Pod
        fields = [
            "name",
            "tags",
            "zone",
            "pool",
            "cpu_over_commit_ratio",
            "memory_over_commit_ratio",
            "default_storage_pool",
            "default_macvlan_mode",
        ]

    name = forms.CharField(
        label="Name", required=False, help_text="The name of the pod"
    )

    zone = forms.ModelChoiceField(
        label="Physical zone",
        required=False,
        initial=Zone.objects.get_default_zone,
        queryset=Zone.objects.all(),
        to_field_name="name",
    )

    pool = forms.ModelChoiceField(
        label="Default pool of created machines",
        required=False,
        initial=lambda: ResourcePool.objects.get_default_resource_pool().name,
        queryset=ResourcePool.objects.all(),
        to_field_name="name",
    )

    cpu_over_commit_ratio = forms.FloatField(
        label="CPU overcommit ratio",
        initial=1,
        required=False,
        min_value=0,
        max_value=10,
    )

    memory_over_commit_ratio = forms.FloatField(
        label="Memory overcommit ratio",
        initial=1,
        required=False,
        min_value=0,
        max_value=10,
    )

    default_storage_pool = forms.ModelChoiceField(
        label="Default storage pool",
        required=False,
        queryset=PodStoragePool.objects.none(),
        to_field_name="pool_id",
    )

    default_macvlan_mode = forms.ChoiceField(
        label="Default MACVLAN mode",
        required=False,
        choices=MACVLAN_MODE_CHOICES,
        initial=MACVLAN_MODE_CHOICES[0],
    )

    def __init__(
        self, data=None, instance=None, request=None, user=None, **kwargs
    ):
        self.is_new = instance is None
        self.request = request
        self.user = user
        super(PodForm, self).__init__(data=data, instance=instance, **kwargs)
        if data is None:
            data = {}
        type_value = data.get("type", self.initial.get("type"))

        self.drivers_orig = driver_parameters.get_all_power_types()
        self.drivers = {
            driver["name"]: driver
            for driver in self.drivers_orig
            if driver["driver_type"] == "pod"
        }
        if len(self.drivers) == 0:
            type_value = ""
        elif type_value not in self.drivers:
            type_value = (
                "" if self.instance is None else self.instance.power_type
            )
        choices = [
            (name, driver["description"])
            for name, driver in self.drivers.items()
        ]
        self.fields["type"] = forms.ChoiceField(
            required=True, choices=choices, initial=type_value
        )
        if not self.is_new:
            if self.instance.power_type != "":
                self.initial["type"] = self.instance.power_type
        if instance is not None:
            self.initial["zone"] = instance.zone.name
            self.initial["pool"] = instance.pool.name
            self.fields[
                "default_storage_pool"
            ].queryset = instance.storage_pools.all()
            if instance.default_storage_pool:
                self.initial[
                    "default_storage_pool"
                ] = instance.default_storage_pool.pool_id

    def _clean_fields(self):
        """Override to dynamically add fields based on the value of `type`
        field."""
        # Process the built-in fields first.
        super(PodForm, self)._clean_fields()
        # If no errors then we re-process with the fields required by the
        # selected type for the pod.
        if len(self.errors) == 0:
            driver_fields = get_driver_parameters_from_json(
                self.drivers_orig, scope=SETTING_SCOPE.BMC
            )
            self.param_fields = driver_fields[
                self.cleaned_data["type"]
            ].field_dict
            self.fields.update(self.param_fields)
            if not self.is_new:
                for key, value in self.instance.power_parameters.items():
                    if key not in self.data:
                        self.data[key] = value
            super(PodForm, self)._clean_fields()

    def clean(self):
        cleaned_data = super(PodForm, self).clean()
        if len(self.drivers) == 0:
            set_form_error(
                self,
                "type",
                "No rack controllers are connected, unable to validate.",
            )
        elif (
            not self.is_new
            and self.instance.power_type != self.cleaned_data.get("type")
        ):
            set_form_error(
                self,
                "type",
                "Cannot change the type of a pod. Delete and re-create the "
                "pod with a different type.",
            )
        return cleaned_data

    def save(self, *args, **kwargs):
        """Persist the pod into the database."""

        def check_for_duplicate(power_type, power_parameters):
            # When the Pod is new try to get a BMC of the same type and
            # parameters to convert the BMC to a new Pod. When the Pod is not
            # new the form will use the already existing pod instance to update
            # those fields. If updating the fields causes a duplicate BMC then
            # a validation erorr will be raised from the model level.
            if self.is_new:
                bmc = BMC.objects.filter(
                    power_type=power_type, power_parameters=power_parameters
                ).first()
                if bmc is not None:
                    if bmc.bmc_type == BMC_TYPE.BMC:
                        # Convert the BMC to a Pod and set as the instance for
                        # the PodForm.
                        bmc.bmc_type = BMC_TYPE.POD
                        bmc.pool = (
                            ResourcePool.objects.get_default_resource_pool()
                        )
                        return bmc.as_pod()
                    else:
                        # Pod already exists with the same power_type and
                        # parameters.
                        raise ValidationError(
                            "Pod %s with type and "
                            "parameters already exist." % bmc.name
                        )

        def update_obj(existing_obj):
            if existing_obj is not None:
                self.instance = existing_obj
            self.instance = super(PodForm, self).save(commit=False)
            self.instance.power_type = power_type
            self.instance.power_parameters = power_parameters
            # Add tag for pod console logging with
            # appropriate kernel parameters.
            tag, _ = Tag.objects.get_or_create(
                name="pod-console-logging",
                kernel_opts="console=tty1 console=ttyS0",
            )
            # Add this tag to the pod.  This only adds the tag
            # if it is not present on the pod.
            self.instance.add_tag(tag.name)
            return self.instance

        power_type = self.cleaned_data["type"]
        # Set power_parameters to the generated param_fields.
        power_parameters = {
            param_name: self.cleaned_data[param_name]
            for param_name in self.param_fields.keys()
            if param_name in self.cleaned_data
        }

        if isInIOThread():
            # Running in twisted reactor, do the work inside the reactor.
            d = deferToDatabase(
                transactional(check_for_duplicate),
                power_type,
                power_parameters,
            )
            d.addCallback(partial(deferToDatabase, transactional(update_obj)))
            d.addCallback(lambda _: self.discover_and_sync_pod())
            return d
        else:
            # Perform the actions inside the executing thread.
            existing_obj = check_for_duplicate(power_type, power_parameters)
            if existing_obj is not None:
                self.instance = existing_obj
            self.instance = update_obj(self.instance)
            return self.discover_and_sync_pod()

    def discover_and_sync_pod(self):
        """Discover and sync the pod information."""

        def update_db(result):
            discovered_pod, discovered = result

            # When called with an instance that has no name, be sure to set
            # it before going any further. If this is a new instance this will
            # also create it in the database.
            if not self.instance.name:
                self.instance.set_random_name()
            if self.request is not None:
                user = self.request.user
            else:
                user = self.user
            self.instance.sync(discovered_pod, user)

            # Save which rack controllers can route and which cannot.
            discovered_rack_ids = [
                rack_id for rack_id, _ in discovered[0].items()
            ]
            for rack_controller in RackController.objects.all():
                routable = rack_controller.system_id in discovered_rack_ids
                bmc_route_model = BMCRoutableRackControllerRelationship
                relation, created = bmc_route_model.objects.get_or_create(
                    bmc=self.instance.as_bmc(),
                    rack_controller=rack_controller,
                    defaults={"routable": routable},
                )
                if not created and relation.routable != routable:
                    relation.routable = routable
                    relation.save()
            return self.instance

        if isInIOThread():
            # Running in twisted reactor, do the work inside the reactor.
            d = discover_pod(
                self.instance.power_type,
                self.instance.power_parameters,
                pod_id=self.instance.id,
                name=self.instance.name,
            )
            d.addCallback(
                lambda discovered: (
                    get_best_discovered_result(discovered),
                    discovered,
                )
            )

            def catch_no_racks(result):
                discovered_pod, discovered = result
                if discovered_pod is None:
                    raise PodProblem(
                        "Unable to start the pod discovery process. "
                        "No rack controllers connected."
                    )
                return discovered_pod, discovered

            def wrap_errors(failure):
                if failure.check(PodProblem):
                    return failure
                else:
                    log.err(failure, "Failed to discover pod.")
                    raise PodProblem(str(failure.value))

            d.addCallback(catch_no_racks)
            d.addCallback(partial(deferToDatabase, transactional(update_db)))
            d.addErrback(wrap_errors)
            return d
        else:
            # Perform the actions inside the executing thread.
            try:
                discovered = discover_pod(
                    self.instance.power_type,
                    self.instance.power_parameters,
                    pod_id=self.instance.id,
                    name=self.instance.name,
                )
            except Exception as exc:
                raise PodProblem(str(exc)) from exc

            # Use the first discovered pod object. All other objects are
            # ignored. The other rack controllers that also provided a result
            # can route to the pod.
            try:
                discovered_pod = get_best_discovered_result(discovered)
            except Exception as error:
                raise PodProblem(str(error))
            if discovered_pod is None:
                raise PodProblem(
                    "Unable to start the pod discovery process. "
                    "No rack controllers connected."
                )
            return update_db((discovered_pod, discovered))


def get_known_host_interfaces(host: Node) -> list:
    """Given the specified host node, calculates each KnownHostInterface.

    :return: a list of KnownHostInterface objects for the specified Node.
    """
    interfaces = host.interface_set.all()
    result = []
    for interface in interfaces:
        ifname = interface.name
        if interface.type == INTERFACE_TYPE.BRIDGE:
            attach_type = InterfaceAttachType.BRIDGE
        else:
            attach_type = InterfaceAttachType.MACVLAN
        dhcp_enabled = False
        vlan = interface.vlan
        if vlan is not None:
            if vlan.dhcp_on:
                dhcp_enabled = True
            elif vlan.relay_vlan is not None:
                if vlan.relay_vlan.dhcp_on:
                    dhcp_enabled = True
        result.append(
            KnownHostInterface(
                ifname=ifname,
                attach_type=attach_type,
                dhcp_enabled=dhcp_enabled,
            )
        )
    return result


clast ComposeMachineForm(forms.Form):
    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        if self.request is None:
            raise ValueError("'request' kwargs is required.")
        self.pod = kwargs.pop("pod", None)
        if self.pod is None:
            raise ValueError("'pod' kwargs is required.")
        super(ComposeMachineForm, self).__init__(*args, **kwargs)

        # Build the fields based on the pod and current pod hints.
        self.fields["cores"] = IntegerField(
            min_value=1, max_value=self.pod.hints.cores, required=False
        )
        self.initial["cores"] = 1
        self.fields["memory"] = IntegerField(
            min_value=1024, max_value=self.pod.hints.memory, required=False
        )
        self.initial["memory"] = 1024
        self.fields["architecture"] = ChoiceField(
            choices=[(arch, arch) for arch in self.pod.architectures],
            required=False,
        )
        self.initial["architecture"] = self.pod.architectures[0]
        if self.pod.hints.cpu_speed > 0:
            self.fields["cpu_speed"] = IntegerField(
                min_value=300,
                max_value=self.pod.hints.cpu_speed,
                required=False,
            )
        else:
            self.fields["cpu_speed"] = IntegerField(
                min_value=300, required=False
            )

        def duplicated_hostname(hostname):
            if Node.objects.filter(hostname=hostname).exists():
                raise ValidationError(
                    'Node with hostname "%s" already exists' % hostname
                )

        self.fields["hostname"] = CharField(
            required=False, validators=[duplicated_hostname, validate_hostname]
        )
        self.initial["hostname"] = make_unique_hostname()
        self.fields["domain"] = ModelChoiceField(
            required=False, queryset=Domain.objects.all()
        )
        self.initial["domain"] = Domain.objects.get_default_domain()
        self.fields["zone"] = ModelChoiceField(
            required=False, queryset=Zone.objects.all()
        )
        self.initial["zone"] = Zone.objects.get_default_zone()
        self.fields["pool"] = ModelChoiceField(
            required=False, queryset=ResourcePool.objects.all()
        )
        self.initial["pool"] = self.pod.pool
        self.fields["storage"] = CharField(
            validators=[storage_validator], required=False
        )
        self.initial["storage"] = "root:8(local)"
        self.fields["interfaces"] = LabeledConstraintMapField(
            validators=[interfaces_validator],
            label="Interface constraints",
            required=False,
        )
        self.initial["interfaces"] = None
        self.fields["skip_commissioning"] = BooleanField(required=False)
        self.initial["skip_commissioning"] = False
        self.allocated_ips = {}

    def get_value_for(self, field):
        """Get the value for `field`. Use initial data if missing or set to
        `None` in the cleaned_data."""
        value = self.cleaned_data.get(field)
        if value:
            return value
        elif field in self.initial:
            return self.initial[field]
        else:
            return None

    def get_requested_machine(self, known_host_interfaces):
        """Return the `RequestedMachine`."""
        block_devices = []

        storage_constraints = get_storage_constraints_from_string(
            self.get_value_for("storage")
        )
        for _, size, tags in storage_constraints:
            if tags is None:
                tags = []
            block_devices.append(
                RequestedMachineBlockDevice(size=size, tags=tags)
            )
        interfaces_label_map = self.get_value_for("interfaces")
        if interfaces_label_map is not None:
            requested_machine_interfaces = self._get_requested_machine_interfaces_via_constraints(
                interfaces_label_map
            )
        else:
            requested_machine_interfaces = [RequestedMachineInterface()]

        return RequestedMachine(
            hostname=self.get_value_for("hostname"),
            architecture=self.get_value_for("architecture"),
            cores=self.get_value_for("cores"),
            memory=self.get_value_for("memory"),
            cpu_speed=self.get_value_for("cpu_speed"),
            block_devices=block_devices,
            interfaces=requested_machine_interfaces,
            known_host_interfaces=known_host_interfaces,
        )

    def _pick_interface(self, interfaces):
        bridge_interfaces = interfaces.filter(
            type=INTERFACE_TYPE.BRIDGE
        ).order_by("-id")
        bond_interfaces = interfaces.filter(type=INTERFACE_TYPE.BOND).order_by(
            "-id"
        )
        bridge_interface = list(bridge_interfaces[:1])
        if bridge_interface:
            return bridge_interface[0]
        bond_interface = list(bond_interfaces[:1])
        if bond_interface:
            return bond_interface[0]
        return interfaces[0]

    def _get_requested_machine_interfaces_via_constraints(
        self, interfaces_label_map
    ):
        requested_machine_interfaces = []
        if self.pod.host is None:
            raise ValidationError(
                "Pod must be on a known host if interfaces are specified."
            )
        result = nodes_by_interface(
            interfaces_label_map,
            preconfigured=False,
            include_filter=dict(node__id=self.pod.host.id),
        )
        pod_node_id = self.pod.host.id
        if pod_node_id not in result.node_ids:
            raise ValidationError(
                "This pod does not match the specified networks."
            )
        has_bootable_vlan = False
        for label in result.label_map.keys():
            # YYY: We might want to use the "deepest" interface in the
            # heirarchy, to ensure we get a bridge or bond (if configured)
            # rather than its parent. Since child interfaces will be created
            # after their parents, this is a good approximation.
            # Search for bridges, followed by bonds, and finally with the
            # remaining interfaces.
            interface_ids = result.label_map[label][pod_node_id]
            interfaces = Interface.objects.filter(
                id__in=interface_ids
            ).order_by("-id")
            interface = self._pick_interface(interfaces)

            # Check to see if we have a bootable VLAN,
            # we need at least one.
            if interface.has_bootable_vlan():
                has_bootable_vlan = True
            rmi = self.get_requested_machine_interface_by_interface(interface)
            # Set the requested interface name and IP addresses.
            rmi.ifname = get_ifname_for_label(label)
            rmi.requested_ips = result.allocated_ips.get(label, [])
            rmi.ip_mode = result.ip_modes.get(label, None)
            requested_machine_interfaces.append(rmi)
        if not has_bootable_vlan:
            raise ValidationError(
                "MAAS DHCP must be enabled on at least one VLAN attached "
                "to the specified interfaces."
            )
        return requested_machine_interfaces

    def get_requested_machine_interface_by_interface(self, interface):
        if interface.type == INTERFACE_TYPE.BRIDGE:
            rmi = RequestedMachineInterface(
                attach_name=interface.name,
                attach_type=InterfaceAttachType.BRIDGE,
            )
        else:
            attach_options = self.pod.default_macvlan_mode
            if not attach_options:
                # Default macvlan mode is 'bridge' if not specified, since that
                # provides the best chance for connectivity.
                attach_options = MACVLAN_MODE.BRIDGE
            rmi = RequestedMachineInterface(
                attach_name=interface.name,
                attach_type=InterfaceAttachType.MACVLAN,
                attach_options=attach_options,
            )
        return rmi

    def save(self):
        """Prevent from usage."""
        raise AttributeError("Use `compose` instead of `save`.")

    def compose(
        self,
        timeout=120,
        creation_type=NODE_CREATION_TYPE.MANUAL,
        skip_commissioning=None,
    ):
        """Compose the machine.

        Internal operation of this form is asynchronous. It will block the
        calling thread until the asynchronous operation is complete. Adjust
        `timeout` to minimize the maximum wait for the asynchronous operation.
        """

        if skip_commissioning is None:
            skip_commissioning = self.get_value_for("skip_commissioning")

        def db_work(client):
            # Check overcommit ratios.
            over_commit_message = self.pod.check_over_commit_ratios(
                requested_cores=self.get_value_for("cores"),
                requested_memory=self.get_value_for("memory"),
            )
            if over_commit_message:
                raise PodProblem(
                    "Unable to compose KVM instance in '%s'. %s"
                    % (self.pod.name, over_commit_message)
                )

            # Update the default storage pool.
            if self.pod.default_storage_pool is not None:
                power_parameters[
                    "default_storage_pool_id"
                ] = self.pod.default_storage_pool.pool_id

            # Find the pod's known host interfaces.
            if self.pod.host is not None:
                interfaces = get_known_host_interfaces(self.pod.host)
            else:
                interfaces = []

            return client, interfaces

        def create_and_sync(result):
            requested_machine, result = result
            discovered_machine, pod_hints = result
            created_machine = self.pod.create_machine(
                discovered_machine,
                self.request.user,
                skip_commissioning=skip_commissioning,
                creation_type=creation_type,
                interfaces=self.get_value_for("interfaces"),
                requested_machine=requested_machine,
                domain=self.get_value_for("domain"),
                pool=self.get_value_for("pool"),
                zone=self.get_value_for("zone"),
            )
            self.pod.sync_hints(pod_hints)
            return created_machine

        @inlineCallbacks
        def async_compose_machine(
            result, power_type, power_paramaters, **kwargs
        ):
            client, result = result
            requested_machine = yield deferToDatabase(
                self.get_requested_machine, result
            )
            result = yield compose_machine(
                client,
                power_type,
                power_paramaters,
                requested_machine,
                **kwargs
            )
            return requested_machine, result

        power_parameters = self.pod.power_parameters.copy()

        if isInIOThread():
            # Running under the twisted reactor, before the work from inside.
            d = deferToDatabase(transactional(self.pod.get_client_identifiers))
            d.addCallback(getClientFromIdentifiers)
            d.addCallback(partial(deferToDatabase, transactional(db_work)))
            d.addCallback(
                async_compose_machine,
                self.pod.power_type,
                power_parameters,
                pod_id=self.pod.id,
                name=self.pod.name,
            )
            d.addCallback(
                partial(deferToDatabase, transactional(create_and_sync))
            )
            return d
        else:
            # Running outside of reactor. Do the work inside and then finish
            # the work outside.
            @asynchronous
            def wrap_compose_machine(
                client_idents, pod_type, parameters, request, pod_id, name
            ):
                """Wrapper to get the client."""
                d = getClientFromIdentifiers(client_idents)
                d.addCallback(
                    compose_machine,
                    pod_type,
                    parameters,
                    request,
                    pod_id=pod_id,
                    name=name,
                )
                return d

            _, result = db_work(None)
            try:
                requested_machine = self.get_requested_machine(result)
                result = wrap_compose_machine(
                    self.pod.get_client_identifiers(),
                    self.pod.power_type,
                    power_parameters,
                    requested_machine,
                    pod_id=self.pod.id,
                    name=self.pod.name,
                ).wait(timeout)
            except crochet.TimeoutError:
                raise PodProblem(
                    "Unable to compose a machine because '%s' driver "
                    "timed out after %d seconds."
                    % (self.pod.power_type, timeout)
                )
            return create_and_sync((requested_machine, result))


clast ComposeMachineForPodsForm(forms.Form):
    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        if self.request is None:
            raise ValueError("'request' kwargs is required.")
        self.pods = kwargs.pop("pods", None)
        if self.pods is None:
            raise ValueError("'pods' kwargs is required.")
        super(ComposeMachineForPodsForm, self).__init__(*args, **kwargs)
        self.pod_forms = [
            ComposeMachineForm(request=self.request, data=self.data, pod=pod)
            for pod in self.pods
        ]

    def save(self):
        """Prevent from usage."""
        raise AttributeError("Use `compose` instead of `save`.")

    def compose(self):
        """Composed machine from available pod."""
        non_commit_forms = [
            form
            for form in self.valid_pod_forms
            if Capabilities.OVER_COMMIT not in form.pod.capabilities
        ]
        commit_forms = [
            form
            for form in self.valid_pod_forms
            if Capabilities.OVER_COMMIT in form.pod.capabilities
        ]
        # YYY - we need a way to get errors back from these forms for debugging
        # First, try to compose a machine from non-commitable pods.
        for form in non_commit_forms:
            try:
                return form.compose(
                    skip_commissioning=True,
                    creation_type=NODE_CREATION_TYPE.DYNAMIC,
                )
            except Exception:
                continue
        # Second, try to compose a machine from commitable pods
        for form in commit_forms:
            try:
                return form.compose(
                    skip_commissioning=True,
                    creation_type=NODE_CREATION_TYPE.DYNAMIC,
                )
            except Exception:
                continue
        # No machine found.
        return None

    def clean(self):
        self.valid_pod_forms = [
            pod_form for pod_form in self.pod_forms if pod_form.is_valid()
        ]
        if len(self.valid_pod_forms) == 0:
            self.add_error(
                "__all__", "No current pod resources match constraints."
            )