"""Harvester provider plugin for HCI infrastructure discovery. Uses the Kubernetes Python client to interact with Harvester's K8s-based API, discovering virtual machines, volumes, images, and networks via custom resources. """ import logging from typing import Callable from kubernetes import client, config from kubernetes.client.rest import ApiException from iac_reverse.models import ( CpuArchitecture, DiscoveredResource, PlatformCategory, ProviderType, ScanProgress, ScanResult, ) from iac_reverse.plugin_base import ProviderPlugin from iac_reverse.scanner.scanner import AuthenticationError logger = logging.getLogger(__name__) # Harvester CRD API groups and versions HARVESTER_API_GROUP = "kubevirt.io" HARVESTER_VM_VERSION = "v1" HARVESTER_VM_PLURAL = "virtualmachines" HARVESTER_CDI_GROUP = "cdi.kubevirt.io" HARVESTER_CDI_VERSION = "v1beta1" HARVESTER_VOLUME_PLURAL = "datavolumes" HARVESTER_IMAGE_GROUP = "harvesterhci.io" HARVESTER_IMAGE_VERSION = "v1beta1" HARVESTER_IMAGE_PLURAL = "virtualmachineimages" HARVESTER_NETWORK_GROUP = "k8s.cni.cncf.io" HARVESTER_NETWORK_VERSION = "v1" HARVESTER_NETWORK_PLURAL = "network-attachment-definitions" # Default namespace for Harvester resources DEFAULT_NAMESPACE = "default" class HarvesterPlugin(ProviderPlugin): """Provider plugin for SUSE Harvester HCI platform. Harvester runs on top of Kubernetes and exposes its resources as CRDs. This plugin uses the kubernetes Python client to authenticate via kubeconfig and discover VMs, volumes, images, and networks. Expected credentials: kubeconfig_path: Path to the kubeconfig file for the Harvester cluster. context: (optional) Kubernetes context name to use. """ def __init__(self) -> None: self._api_client: client.ApiClient | None = None self._custom_api: client.CustomObjectsApi | None = None self._core_api: client.CoreV1Api | None = None self._kubeconfig_path: str | None = None self._context: str | None = None def authenticate(self, credentials: dict[str, str]) -> None: """Authenticate with the Harvester cluster via kubeconfig. Args: credentials: Must contain 'kubeconfig_path'. May contain 'context'. Raises: AuthenticationError: If kubeconfig cannot be loaded or is invalid. """ kubeconfig_path = credentials.get("kubeconfig_path") if not kubeconfig_path: raise AuthenticationError( provider_name="harvester", reason="'kubeconfig_path' is required in credentials", ) context = credentials.get("context") or None self._kubeconfig_path = kubeconfig_path self._context = context try: self._api_client = config.new_client_from_config( config_file=kubeconfig_path, context=context, ) self._custom_api = client.CustomObjectsApi(self._api_client) self._core_api = client.CoreV1Api(self._api_client) except Exception as exc: raise AuthenticationError( provider_name="harvester", reason=f"Failed to load kubeconfig: {exc}", ) from exc def get_platform_category(self) -> PlatformCategory: """Return HCI platform category for Harvester.""" return PlatformCategory.HCI def list_endpoints(self) -> list[str]: """Return the Harvester cluster API endpoint. Extracts the server URL from the loaded kubeconfig. """ if self._api_client is None: return [] host = self._api_client.configuration.host or "" return [host] if host else [] def list_supported_resource_types(self) -> list[str]: """Return resource types supported by the Harvester plugin.""" return [ "harvester_virtualmachine", "harvester_volume", "harvester_image", "harvester_network", ] def detect_architecture(self, endpoint: str) -> CpuArchitecture: """Detect CPU architecture from Harvester cluster node info. Queries the Kubernetes node list and inspects the architecture label. Harvester typically runs on AMD64 (Dell PowerEdge servers). Args: endpoint: The cluster API endpoint (used for logging context). Returns: CpuArchitecture detected from node info. """ if self._core_api is None: return CpuArchitecture.AMD64 try: nodes = self._core_api.list_node() if nodes.items: node = nodes.items[0] arch = node.status.node_info.architecture arch_lower = arch.lower() if arch else "" if arch_lower in ("arm64", "aarch64"): return CpuArchitecture.AARCH64 elif arch_lower == "arm": return CpuArchitecture.ARM else: return CpuArchitecture.AMD64 except ApiException as exc: logger.warning( "Failed to detect architecture from node info for %s: %s", endpoint, exc, ) return CpuArchitecture.AMD64 def discover_resources( self, endpoints: list[str], resource_types: list[str], progress_callback: Callable[[ScanProgress], None], ) -> ScanResult: """Discover Harvester resources via Kubernetes CRDs. Enumerates VMs, volumes, images, and networks from the Harvester cluster. Args: endpoints: List of cluster API endpoints. resource_types: Resource types to discover. progress_callback: Callback for progress updates. Returns: ScanResult with discovered resources. """ resources: list[DiscoveredResource] = [] warnings: list[str] = [] errors: list[str] = [] endpoint = endpoints[0] if endpoints else "" architecture = self.detect_architecture(endpoint) total_types = len(resource_types) completed = 0 discovery_map = { "harvester_virtualmachine": self._discover_vms, "harvester_volume": self._discover_volumes, "harvester_image": self._discover_images, "harvester_network": self._discover_networks, } for resource_type in resource_types: progress_callback( ScanProgress( current_resource_type=resource_type, resources_discovered=len(resources), resource_types_completed=completed, total_resource_types=total_types, ) ) discover_fn = discovery_map.get(resource_type) if discover_fn is None: warnings.append(f"Unknown resource type: {resource_type}") completed += 1 continue try: discovered = discover_fn(endpoint, architecture) resources.extend(discovered) except ApiException as exc: error_msg = ( f"Failed to discover {resource_type}: " f"HTTP {exc.status} - {exc.reason}" ) errors.append(error_msg) logger.error(error_msg) except Exception as exc: error_msg = f"Failed to discover {resource_type}: {exc}" errors.append(error_msg) logger.error(error_msg) completed += 1 # Final progress update progress_callback( ScanProgress( current_resource_type="", resources_discovered=len(resources), resource_types_completed=total_types, total_resource_types=total_types, ) ) return ScanResult( resources=resources, warnings=warnings, errors=errors, scan_timestamp="", profile_hash="", ) # ------------------------------------------------------------------ # Private discovery methods # ------------------------------------------------------------------ def _discover_vms( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover Harvester virtual machines via kubevirt.io CRD.""" items = self._list_cluster_custom_objects( group=HARVESTER_API_GROUP, version=HARVESTER_VM_VERSION, plural=HARVESTER_VM_PLURAL, ) resources = [] for item in items: metadata = item.get("metadata", {}) spec = item.get("spec", {}) name = metadata.get("name", "unknown") namespace = metadata.get("namespace", DEFAULT_NAMESPACE) uid = metadata.get("uid", f"{namespace}/{name}") resources.append( DiscoveredResource( resource_type="harvester_virtualmachine", unique_id=uid, name=name, provider=ProviderType.HARVESTER, platform_category=PlatformCategory.HCI, architecture=architecture, endpoint=endpoint, attributes={ "namespace": namespace, "running": spec.get("running", False), "spec": spec, "labels": metadata.get("labels", {}), "annotations": metadata.get("annotations", {}), }, raw_references=self._extract_vm_references(spec), ) ) return resources def _discover_volumes( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover Harvester data volumes via cdi.kubevirt.io CRD.""" items = self._list_cluster_custom_objects( group=HARVESTER_CDI_GROUP, version=HARVESTER_CDI_VERSION, plural=HARVESTER_VOLUME_PLURAL, ) resources = [] for item in items: metadata = item.get("metadata", {}) spec = item.get("spec", {}) name = metadata.get("name", "unknown") namespace = metadata.get("namespace", DEFAULT_NAMESPACE) uid = metadata.get("uid", f"{namespace}/{name}") resources.append( DiscoveredResource( resource_type="harvester_volume", unique_id=uid, name=name, provider=ProviderType.HARVESTER, platform_category=PlatformCategory.HCI, architecture=architecture, endpoint=endpoint, attributes={ "namespace": namespace, "spec": spec, "labels": metadata.get("labels", {}), }, raw_references=[], ) ) return resources def _discover_images( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover Harvester VM images via harvesterhci.io CRD.""" items = self._list_cluster_custom_objects( group=HARVESTER_IMAGE_GROUP, version=HARVESTER_IMAGE_VERSION, plural=HARVESTER_IMAGE_PLURAL, ) resources = [] for item in items: metadata = item.get("metadata", {}) spec = item.get("spec", {}) name = metadata.get("name", "unknown") namespace = metadata.get("namespace", DEFAULT_NAMESPACE) uid = metadata.get("uid", f"{namespace}/{name}") resources.append( DiscoveredResource( resource_type="harvester_image", unique_id=uid, name=name, provider=ProviderType.HARVESTER, platform_category=PlatformCategory.HCI, architecture=architecture, endpoint=endpoint, attributes={ "namespace": namespace, "display_name": spec.get("displayName", name), "url": spec.get("url", ""), "spec": spec, "labels": metadata.get("labels", {}), }, raw_references=[], ) ) return resources def _discover_networks( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover Harvester networks via k8s.cni.cncf.io CRD.""" items = self._list_cluster_custom_objects( group=HARVESTER_NETWORK_GROUP, version=HARVESTER_NETWORK_VERSION, plural=HARVESTER_NETWORK_PLURAL, ) resources = [] for item in items: metadata = item.get("metadata", {}) spec = item.get("spec", {}) name = metadata.get("name", "unknown") namespace = metadata.get("namespace", DEFAULT_NAMESPACE) uid = metadata.get("uid", f"{namespace}/{name}") resources.append( DiscoveredResource( resource_type="harvester_network", unique_id=uid, name=name, provider=ProviderType.HARVESTER, platform_category=PlatformCategory.HCI, architecture=architecture, endpoint=endpoint, attributes={ "namespace": namespace, "config": spec.get("config", ""), "labels": metadata.get("labels", {}), }, raw_references=[], ) ) return resources def _list_cluster_custom_objects( self, group: str, version: str, plural: str ) -> list[dict]: """List all custom objects across all namespaces. Args: group: API group (e.g., 'kubevirt.io'). version: API version (e.g., 'v1'). plural: Resource plural name (e.g., 'virtualmachines'). Returns: List of resource items as dicts. """ if self._custom_api is None: return [] result = self._custom_api.list_cluster_custom_object( group=group, version=version, plural=plural, ) return result.get("items", []) @staticmethod def _extract_vm_references(spec: dict) -> list[str]: """Extract resource references from a VM spec. Looks for volume and network references in the VM template spec. """ references: list[str] = [] template = spec.get("template", {}) template_spec = template.get("spec", {}) # Extract volume references volumes = template_spec.get("volumes", []) for volume in volumes: if "dataVolume" in volume: dv_name = volume["dataVolume"].get("name", "") if dv_name: references.append(f"volume:{dv_name}") if "persistentVolumeClaim" in volume: pvc_name = volume["persistentVolumeClaim"].get("claimName", "") if pvc_name: references.append(f"volume:{pvc_name}") # Extract network references networks = template_spec.get("networks", []) for network in networks: if "multus" in network: net_name = network["multus"].get("networkName", "") if net_name: references.append(f"network:{net_name}") return references