Files
SnarfCode/src/iac_reverse/scanner/harvester_plugin.py
2026-05-22 00:19:30 -04:00

459 lines
16 KiB
Python

"""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