459 lines
16 KiB
Python
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
|