Created IAC reverse generator
This commit is contained in:
458
src/iac_reverse/scanner/harvester_plugin.py
Normal file
458
src/iac_reverse/scanner/harvester_plugin.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user