From a94eda613f8609218efdd5253bc033aea33d6ffd Mon Sep 17 00:00:00 2001 From: Felix Matouschek Date: Thu, 27 Jun 2024 13:32:20 +0200 Subject: [PATCH] feat(inventory): Support stopped VMs This enables the inventory to list stopped VMs by refactoring the add_from_namespace method. The method is now looking up the related VM and VMIs for a host and adds all status attributes of both objects to the host's vars. To distinguish the origin of vars an appropriate prefix (vm_ or vmi_) is applied to their name. The inventory now supports hosts comprising just a VM (stopped), just a VMI or both. This changes some of the hostvars previously reported by the inventory plugin: Dropped: - object_type - cluster_name Now prefixed with vm_ or vmi_ according to the origin: - labels - annotations - resource_version - uid Signed-off-by: Felix Matouschek --- plugins/inventory/kubevirt.py | 317 +++++++++++++++++----------------- 1 file changed, 162 insertions(+), 155 deletions(-) diff --git a/plugins/inventory/kubevirt.py b/plugins/inventory/kubevirt.py index 6bf7796..6573a21 100644 --- a/plugins/inventory/kubevirt.py +++ b/plugins/inventory/kubevirt.py @@ -10,13 +10,13 @@ __metaclass__ = type DOCUMENTATION = """ name: kubevirt -short_description: Inventory source for KubeVirt VirtualMachines +short_description: Inventory source for KubeVirt VirtualMachines and VirtualMachineInstances author: - "KubeVirt.io Project (!UNKNOWN)" description: -- Fetch running C(VirtualMachineInstances) for one or more namespaces with an optional label selector. +- Fetch virtual machines from one or more namespaces with an optional label selector. - Groups by cluster name, namespace and labels. - Uses the M(kubernetes.core.kubectl) connection plugin to access the Kubernetes cluster. - Uses V(*.kubevirt.[yml|yaml]) YAML configuration file to set parameter values. @@ -94,14 +94,14 @@ options: aliases: [ verify_ssl ] namespaces: description: - - List of namespaces. If not specified, will fetch all C(VirtualMachineInstances) for all namespaces + - List of namespaces. If not specified, will fetch virtual machines from all namespaces the user is authorized to access. label_selector: description: - - Define a label selector to select a subset of the fetched C(VirtualMachineInstances). + - Define a label selector to select a subset of the fetched virtual machines. network_name: description: - - In case multiple networks are attached to a C(VirtualMachineInstance), define which interface should + - In case multiple networks are attached to a virtual machine, define which interface should be returned as primary IP address. aliases: [ interface_name ] kube_secondary_dns: @@ -111,18 +111,18 @@ options: default: False use_service: description: - - Enable the use of C(Services) to establish an SSH connection to the C(VirtualMachine). + - Enable the use of C(Services) to establish an SSH connection to a virtual machine. - Services are only used if no O(connections.network_name) was provided. type: bool default: True create_groups: description: - - Enable the creation of groups from labels on C(VirtualMachines). + - Enable the creation of groups from labels on C(VirtualMachines) and C(VirtualMachineInstances). type: bool default: False base_domain: description: - - Override the base domain used to construct host names of C(VirtualMachines). Used in case of + - Override the base domain used to construct host names. Used in case of C(kubesecondarydns) or C(Services) of type C(NodePort) if O(connections.append_base_domain) is set. append_base_domain: description: @@ -143,21 +143,21 @@ requirements: EXAMPLES = """ # Filename must end with kubevirt.[yml|yaml] -- name: Authenticate with token and return all VirtualMachineInstances for all accessible namespaces +- name: Authenticate with token and return all virtual machines from all accessible namespaces plugin: kubevirt.core.kubevirt connections: - host: https://192.168.64.4:8443 api_key: xxxxxxxxxxxxxxxx validate_certs: false -- name: Use default ~/.kube/config and return VirtualMachineInstances from namespace testing connected to network bridge-network +- name: Use default ~/.kube/config and return virtual machines from namespace testing connected to network bridge-network plugin: kubevirt.core.kubevirt connections: - namespaces: - testing network_name: bridge-network -- name: Use default ~/.kube/config and return VirtualMachineInstances from namespace testing with label app=test +- name: Use default ~/.kube/config and return virtual machines from namespace testing with label app=test plugin: kubevirt.core.kubevirt connections: - namespaces: @@ -173,6 +173,7 @@ EXAMPLES = """ from dataclasses import dataclass from json import loads +from re import compile as re_compile from typing import ( Any, Dict, @@ -266,6 +267,9 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): NAME = "kubevirt.core.kubevirt" + # Used to convert camel case variable names into snake case + snake_case_pattern = re_compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") + @staticmethod def get_default_host_name(host: str) -> str: """ @@ -294,6 +298,14 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): return f"{exc.status} Reason: {exc.reason}" + @staticmethod + def format_var_name(name: str) -> str: + """ + format_var_name formats a CamelCase variable name into a snake_case name + suitable for use as a inventory variable name. + """ + return InventoryModule.snake_case_pattern.sub("_", name).lower() + @staticmethod def get_host_from_service(service: Dict, node_name: Optional[str]) -> Optional[str]: """ @@ -572,10 +584,17 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): populate_inventory_from_namespace adds groups and hosts from a namespace to the inventory. """ - vmi_list = self.get_vmis_for_namespace(client, namespace, opts) + vms = { + vm.metadata.name: vm + for vm in self.get_vms_for_namespace(client, namespace, opts) + } + vmis = { + vmi.metadata.name: vmi + for vmi in self.get_vmis_for_namespace(client, namespace, opts) + } - if not vmi_list.items: - # Return early if no VMIs were found to avoid adding empty groups. + if not vms and not vmis: + # Return early if no VMs and VMIs were found to avoid adding empty groups. return services = self.get_ssh_services_for_namespace(client, namespace) @@ -587,149 +606,137 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): self.inventory.add_group(namespace_group) self.inventory.add_child(name, namespace_group) - for vmi in vmi_list.items: - if not (vmi.status and vmi.status.interfaces): + # Add found VMs and optionally enhance with VMI data + for name, vm in vms.items(): + hostname = self.add_host(vm, opts.host_format, namespace_group) + self.set_vars_from_vm(hostname, vm, opts) + if name in vmis: + self.set_vars_from_vmi(hostname, vmis[name], services, opts) + self.set_composable_vars(hostname) + + # Add remaining VMIs without VM + for name, vmi in vmis.items(): + if name in vms: continue + hostname = self.add_host(vmi, opts.host_format, namespace_group) + self.set_vars_from_vmi(hostname, vmi, services, opts) + self.set_composable_vars(hostname) - # Find interface by its name: - if opts.network_name is None: - interface = vmi.status.interfaces[0] - else: - interface = next( - (i for i in vmi.status.interfaces if i.name == opts.network_name), - None, + def add_host( + self, obj: ResourceField, host_format: str, namespace_group: str + ) -> str: + """ + add_hosts adds a host to the inventory. + """ + hostname = host_format.format( + namespace=obj.metadata.namespace, + name=obj.metadata.name, + uid=obj.metadata.uid, + ) + self.inventory.add_host(hostname) + self.inventory.add_child(namespace_group, hostname) + + return hostname + + def set_vars_from_vm( + self, hostname: str, vm: ResourceField, opts: InventoryOptions + ) -> None: + """ + set_vars_from_vm sets inventory variables from a VM prefixed with vm_. + """ + self.set_common_vars(hostname, "vm", vm, opts) + + def set_vars_from_vmi( + self, hostname: str, vmi: ResourceField, services: Dict, opts: InventoryOptions + ) -> None: + """ + set_vars_from_vmi sets inventory variables from a VMI prefixed with vmi_ and + looks up the interface to set ansible_host and ansible_port. + """ + self.set_common_vars(hostname, "vmi", vmi, opts) + + if opts.network_name is None: + # Use first interface + interface = vmi.status.interfaces[0] if vmi.status.interfaces else None + else: + # Find interface by its name + interface = next( + (i for i in vmi.status.interfaces if i.name == opts.network_name), + None, + ) + + # If interface is not found or IP address is not reported skip this VMI + if interface is None or interface.ipAddress is None: + return + + # Set up the connection + service = None + if self.is_windows( + {} if not vmi.status.guestOSInfo else vmi.status.guestOSInfo.to_dict(), + {} if not vmi.metadata.annotations else vmi.metadata.annotations.to_dict(), + ): + self.inventory.set_variable(hostname, "ansible_connection", "winrm") + else: + service = services.get(vmi.metadata.labels.get(LABEL_KUBEVIRT_IO_DOMAIN)) + self.set_ansible_host_and_port( + vmi, + hostname, + interface.ipAddress, + service, + opts, + ) + + def set_common_vars( + self, hostname: str, prefix: str, obj: ResourceField, opts: InventoryOptions + ): + """ + set_common_vars sets common inventory variables from VMs or VMIs. + """ + # Add hostvars from metadata + if metadata := obj.metadata: + if metadata.annotations: + self.inventory.set_variable( + hostname, f"{prefix}_annotations", metadata.annotations.to_dict() ) - - # If interface is not found or IP address is not reported skip this VM: - if interface is None or interface.ipAddress is None: - continue - - vmi_name = opts.host_format.format( - namespace=vmi.metadata.namespace, - name=vmi.metadata.name, - uid=vmi.metadata.uid, - ) - vmi_annotations = ( - {} - if not vmi.metadata.annotations - else vmi.metadata.annotations.to_dict() - ) - vmi_labels = ( - {} if not vmi.metadata.labels else vmi.metadata.labels.to_dict() - ) - - # Add vmi to the namespace group - self.inventory.add_host(vmi_name) - self.inventory.add_child(namespace_group, vmi_name) - - # Create label groups and add vmi to it if enabled - if vmi.metadata.labels and opts.create_groups: - # Create a group for each label_value - vmi_groups = [] - for key, value in vmi.metadata.labels.items(): - group_name = self._sanitize_group_name(f"label_{key}_{value}") - if group_name not in vmi_groups: - vmi_groups.append(group_name) - # Add vmi to each label_value group - for group in vmi_groups: - self.inventory.add_group(group) - self.inventory.add_child(group, vmi_name) - - # Add hostvars from metadata - self.inventory.set_variable(vmi_name, "object_type", "vmi") - self.inventory.set_variable(vmi_name, "labels", vmi_labels) - self.inventory.set_variable(vmi_name, "annotations", vmi_annotations) - self.inventory.set_variable( - vmi_name, "cluster_name", vmi.metadata.clusterName - ) - self.inventory.set_variable( - vmi_name, "resource_version", vmi.metadata.resourceVersion - ) - self.inventory.set_variable(vmi_name, "uid", vmi.metadata.uid) - - # Add hostvars from status - vmi_active_pods = ( - {} if not vmi.status.activePods else vmi.status.activePods.to_dict() - ) - self.inventory.set_variable(vmi_name, "vmi_active_pods", vmi_active_pods) - vmi_conditions = ( - [] - if not vmi.status.conditions - else [c.to_dict() for c in vmi.status.conditions] - ) - self.inventory.set_variable(vmi_name, "vmi_conditions", vmi_conditions) - vmi_guest_os_info = ( - {} if not vmi.status.guestOSInfo else vmi.status.guestOSInfo.to_dict() - ) - self.inventory.set_variable( - vmi_name, "vmi_guest_os_info", vmi_guest_os_info - ) - vmi_interfaces = ( - [] - if not vmi.status.interfaces - else [i.to_dict() for i in vmi.status.interfaces] - ) - self.inventory.set_variable(vmi_name, "vmi_interfaces", vmi_interfaces) - self.inventory.set_variable( - vmi_name, - "vmi_launcher_container_image_version", - vmi.status.launcherContainerImageVersion, - ) - self.inventory.set_variable( - vmi_name, "vmi_migration_method", vmi.status.migrationMethod - ) - self.inventory.set_variable( - vmi_name, "vmi_migration_transport", vmi.status.migrationTransport - ) - self.inventory.set_variable(vmi_name, "vmi_node_name", vmi.status.nodeName) - self.inventory.set_variable(vmi_name, "vmi_phase", vmi.status.phase) - vmi_phase_transition_timestamps = ( - [] - if not vmi.status.phaseTransitionTimestamps - else [p.to_dict() for p in vmi.status.phaseTransitionTimestamps] - ) - self.inventory.set_variable( - vmi_name, - "vmi_phase_transition_timestamps", - vmi_phase_transition_timestamps, - ) - self.inventory.set_variable(vmi_name, "vmi_qos_class", vmi.status.qosClass) - self.inventory.set_variable( - vmi_name, - "vmi_virtual_machine_revision_name", - vmi.status.virtualMachineRevisionName, - ) - vmi_volume_status = ( - [] - if not vmi.status.volumeStatus - else [v.to_dict() for v in vmi.status.volumeStatus] - ) - self.inventory.set_variable( - vmi_name, "vmi_volume_status", vmi_volume_status - ) - - # Set up the connection - service = None - if self.is_windows(vmi_guest_os_info, vmi_annotations): - self.inventory.set_variable(vmi_name, "ansible_connection", "winrm") - else: - service = services.get( - vmi.metadata.labels.get(LABEL_KUBEVIRT_IO_DOMAIN) + if metadata.labels: + self.inventory.set_variable( + hostname, f"{prefix}_labels", metadata.labels.to_dict() ) - self.set_ansible_host_and_port( - vmi, - vmi_name, - interface.ipAddress, - service, - opts, - ) + # Create label groups and add vm to it if enabled + if opts.create_groups: + self.set_groups_from_labels(hostname, metadata.labels) + if metadata.resourceVersion: + self.inventory.set_variable( + hostname, f"{prefix}_resource_version", metadata.resourceVersion + ) + if metadata.uid: + self.inventory.set_variable(hostname, f"{prefix}_uid", metadata.uid) - self.set_composable_vars(vmi_name) + # Add hostvars from status + if obj.status: + for key, value in obj.status.to_dict().items(): + name = self.format_var_name(key) + self.inventory.set_variable(hostname, f"{prefix}_{name}", value) + + def set_groups_from_labels(self, hostname: str, labels: ResourceField) -> None: + """ + set_groups_from_labels adds groups for each label of a VM or VMI and + adds the host to each group. + """ + groups = [] + for key, value in labels.to_dict().items(): + group_name = self._sanitize_group_name(f"label_{key}_{value}") + if group_name not in groups: + groups.append(group_name) + # Add host to each label_value group + for group in groups: + self.inventory.add_group(group) + self.inventory.add_child(group, hostname) def set_ansible_host_and_port( self, vmi: ResourceField, - vmi_name: str, + hostname: str, ip_address: str, service: Optional[Dict], opts: InventoryOptions, @@ -764,22 +771,22 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): if ansible_host is None: ansible_host = ip_address - self.inventory.set_variable(vmi_name, "ansible_host", ansible_host) - self.inventory.set_variable(vmi_name, "ansible_port", ansible_port) + self.inventory.set_variable(hostname, "ansible_host", ansible_host) + self.inventory.set_variable(hostname, "ansible_port", ansible_port) - def set_composable_vars(self, vmi_name: str) -> None: + def set_composable_vars(self, hostname: str) -> None: """ set_composable_vars sets vars per https://docs.ansible.com/ansible/latest/dev_guide/developing_inventory.html """ - host_vars = self.inventory.get_host(vmi_name).get_vars() + hostvars = self.inventory.get_host(hostname).get_vars() strict = self.get_option("strict") self._set_composite_vars( - self.get_option("compose"), host_vars, vmi_name, strict=True + self.get_option("compose"), hostvars, hostname, strict=True ) self._add_host_to_composed_groups( - self.get_option("groups"), host_vars, vmi_name, strict=strict + self.get_option("groups"), hostvars, hostname, strict=strict ) self._add_host_to_keyed_groups( - self.get_option("keyed_groups"), host_vars, vmi_name, strict=strict + self.get_option("keyed_groups"), hostvars, hostname, strict=strict )