Created IAC reverse generator
This commit is contained in:
45
src/iac_reverse/scanner/__init__.py
Normal file
45
src/iac_reverse/scanner/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Scanner module for infrastructure discovery."""
|
||||
|
||||
from iac_reverse.scanner.bare_metal_plugin import BareMetalPlugin
|
||||
from iac_reverse.scanner.docker_swarm_plugin import DockerSwarmPlugin
|
||||
from iac_reverse.scanner.harvester_plugin import HarvesterPlugin
|
||||
from iac_reverse.scanner.kubernetes_plugin import KubernetesPlugin
|
||||
from iac_reverse.scanner.multi_provider_scanner import (
|
||||
MultiProviderScanner,
|
||||
MultiProviderScanResult,
|
||||
ProviderFailure,
|
||||
ProviderScanEntry,
|
||||
)
|
||||
from iac_reverse.scanner.scanner import (
|
||||
AuthenticationError,
|
||||
ConnectionLostError,
|
||||
Scanner,
|
||||
ScanTimeoutError,
|
||||
)
|
||||
from iac_reverse.scanner.synology_plugin import SynologyPlugin
|
||||
from iac_reverse.scanner.windows_plugin import (
|
||||
InsufficientPrivilegesError,
|
||||
WindowsDiscoveryPlugin,
|
||||
WinRMNotEnabledError,
|
||||
WMIQueryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthenticationError",
|
||||
"BareMetalPlugin",
|
||||
"ConnectionLostError",
|
||||
"DockerSwarmPlugin",
|
||||
"HarvesterPlugin",
|
||||
"InsufficientPrivilegesError",
|
||||
"KubernetesPlugin",
|
||||
"MultiProviderScanner",
|
||||
"MultiProviderScanResult",
|
||||
"ProviderFailure",
|
||||
"ProviderScanEntry",
|
||||
"Scanner",
|
||||
"ScanTimeoutError",
|
||||
"SynologyPlugin",
|
||||
"WindowsDiscoveryPlugin",
|
||||
"WinRMNotEnabledError",
|
||||
"WMIQueryError",
|
||||
]
|
||||
BIN
src/iac_reverse/scanner/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/iac_reverse/scanner/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/iac_reverse/scanner/__pycache__/scanner.cpython-313.pyc
Normal file
BIN
src/iac_reverse/scanner/__pycache__/scanner.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
497
src/iac_reverse/scanner/bare_metal_plugin.py
Normal file
497
src/iac_reverse/scanner/bare_metal_plugin.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""Bare Metal provider plugin using Redfish/IPMI API.
|
||||
|
||||
Discovers hardware inventory, BMC configurations, network interfaces,
|
||||
and RAID configurations from physical servers via the Redfish REST API
|
||||
(standard BMC management interface).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class BareMetalPlugin(ProviderPlugin):
|
||||
"""Provider plugin for bare metal servers using Redfish/IPMI API.
|
||||
|
||||
Connects to a server's BMC (Baseboard Management Controller) via the
|
||||
Redfish REST API to discover hardware inventory, BMC configuration,
|
||||
network interfaces, and RAID configurations.
|
||||
|
||||
Expected credentials dict keys:
|
||||
host: BMC hostname or IP address (required)
|
||||
username: BMC username (required)
|
||||
password: BMC password (required)
|
||||
port: BMC port (optional, default 443)
|
||||
use_ssl: Whether to use HTTPS (optional, default "true")
|
||||
"""
|
||||
|
||||
SUPPORTED_RESOURCE_TYPES = [
|
||||
"bare_metal_hardware",
|
||||
"bare_metal_bmc_config",
|
||||
"bare_metal_network_interface",
|
||||
"bare_metal_raid_config",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: requests.Session | None = None
|
||||
self._base_url: str = ""
|
||||
self._host: str = ""
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
"""Authenticate with the BMC via Redfish session creation.
|
||||
|
||||
Args:
|
||||
credentials: Dict with keys: host, username, password,
|
||||
and optionally port (default 443) and use_ssl (default "true").
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If connection or login fails.
|
||||
"""
|
||||
host = credentials.get("host", "")
|
||||
username = credentials.get("username", "")
|
||||
password = credentials.get("password", "")
|
||||
port = credentials.get("port", "443")
|
||||
use_ssl = credentials.get("use_ssl", "true").lower() == "true"
|
||||
|
||||
if not host or not username or not password:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason="Missing required credentials: host, username, and password are required",
|
||||
)
|
||||
|
||||
scheme = "https" if use_ssl else "http"
|
||||
self._base_url = f"{scheme}://{host}:{port}"
|
||||
self._host = host
|
||||
|
||||
session = requests.Session()
|
||||
session.verify = False # BMC certs are typically self-signed
|
||||
session.headers.update({
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
# Attempt Redfish session-based authentication
|
||||
session_url = f"{self._base_url}/redfish/v1/SessionService/Sessions"
|
||||
payload = {"UserName": username, "Password": password}
|
||||
|
||||
try:
|
||||
response = session.post(session_url, json=payload, timeout=30)
|
||||
if response.status_code in (200, 201):
|
||||
# Extract session token from response headers
|
||||
token = response.headers.get("X-Auth-Token", "")
|
||||
if token:
|
||||
session.headers["X-Auth-Token"] = token
|
||||
elif response.status_code == 401:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason="Invalid credentials (HTTP 401)",
|
||||
)
|
||||
else:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason=f"Unexpected response status {response.status_code}",
|
||||
)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason=f"Cannot connect to BMC at {self._base_url}: {exc}",
|
||||
) from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason=f"Connection to BMC timed out: {exc}",
|
||||
) from exc
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
provider_name="bare_metal",
|
||||
reason=f"Unexpected error during authentication: {exc}",
|
||||
) from exc
|
||||
|
||||
self._session = session
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
"""Return PlatformCategory.BARE_METAL."""
|
||||
return PlatformCategory.BARE_METAL
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
"""Return the BMC host as the single endpoint."""
|
||||
return [self._host] if self._host else []
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
"""Return supported bare metal resource types."""
|
||||
return list(self.SUPPORTED_RESOURCE_TYPES)
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
"""Detect CPU architecture from Redfish system hardware info.
|
||||
|
||||
Queries /redfish/v1/Systems/1/Processors to determine the
|
||||
processor architecture.
|
||||
|
||||
Args:
|
||||
endpoint: The BMC host address.
|
||||
|
||||
Returns:
|
||||
CpuArchitecture enum value based on processor info.
|
||||
"""
|
||||
if self._session is None:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
processors_url = f"{self._base_url}/redfish/v1/Systems/1/Processors"
|
||||
try:
|
||||
response = self._session.get(processors_url, timeout=30)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("Members", [])
|
||||
if members:
|
||||
# Query first processor for architecture details
|
||||
proc_uri = members[0].get("@odata.id", "")
|
||||
if proc_uri:
|
||||
proc_url = f"{self._base_url}{proc_uri}"
|
||||
proc_response = self._session.get(proc_url, timeout=30)
|
||||
if proc_response.status_code == 200:
|
||||
proc_data = proc_response.json()
|
||||
return self._parse_architecture(proc_data)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to detect architecture: %s", exc)
|
||||
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback: Callable[[ScanProgress], None],
|
||||
) -> ScanResult:
|
||||
"""Discover bare metal resources via Redfish API.
|
||||
|
||||
Args:
|
||||
endpoints: List of BMC host addresses to scan.
|
||||
resource_types: Resource types to discover.
|
||||
progress_callback: Progress reporting callback.
|
||||
|
||||
Returns:
|
||||
ScanResult with discovered resources.
|
||||
"""
|
||||
resources: list[DiscoveredResource] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
total_types = len(resource_types)
|
||||
types_completed = 0
|
||||
|
||||
for endpoint in endpoints:
|
||||
architecture = self.detect_architecture(endpoint)
|
||||
|
||||
for resource_type in resource_types:
|
||||
try:
|
||||
discovered = self._discover_resource_type(
|
||||
endpoint, resource_type, architecture
|
||||
)
|
||||
resources.extend(discovered)
|
||||
except Exception as exc:
|
||||
error_msg = (
|
||||
f"Error discovering {resource_type} on {endpoint}: {exc}"
|
||||
)
|
||||
errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
types_completed += 1
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=resource_type,
|
||||
resources_discovered=len(resources),
|
||||
resource_types_completed=types_completed,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _discover_resource_type(
|
||||
self,
|
||||
endpoint: str,
|
||||
resource_type: str,
|
||||
architecture: CpuArchitecture,
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Dispatch discovery to the appropriate handler."""
|
||||
handlers = {
|
||||
"bare_metal_hardware": self._discover_hardware,
|
||||
"bare_metal_bmc_config": self._discover_bmc_config,
|
||||
"bare_metal_network_interface": self._discover_network_interfaces,
|
||||
"bare_metal_raid_config": self._discover_raid_config,
|
||||
}
|
||||
handler = handlers.get(resource_type)
|
||||
if handler is None:
|
||||
return []
|
||||
return handler(endpoint, architecture)
|
||||
|
||||
def _discover_hardware(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover hardware inventory via /redfish/v1/Systems/1."""
|
||||
if self._session is None:
|
||||
return []
|
||||
|
||||
url = f"{self._base_url}/redfish/v1/Systems/1"
|
||||
try:
|
||||
response = self._session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to discover hardware: %s", exc)
|
||||
return []
|
||||
|
||||
system_id = data.get("Id", "System.1")
|
||||
return [
|
||||
DiscoveredResource(
|
||||
resource_type="bare_metal_hardware",
|
||||
unique_id=f"{endpoint}:{system_id}",
|
||||
name=data.get("Name", f"System {system_id}"),
|
||||
provider=ProviderType.BARE_METAL,
|
||||
platform_category=PlatformCategory.BARE_METAL,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"manufacturer": data.get("Manufacturer", ""),
|
||||
"model": data.get("Model", ""),
|
||||
"serial_number": data.get("SerialNumber", ""),
|
||||
"sku": data.get("SKU", ""),
|
||||
"bios_version": data.get("BiosVersion", ""),
|
||||
"total_memory_gib": data.get("MemorySummary", {}).get(
|
||||
"TotalSystemMemoryGiB", 0
|
||||
),
|
||||
"processor_count": data.get("ProcessorSummary", {}).get(
|
||||
"Count", 0
|
||||
),
|
||||
"processor_model": data.get("ProcessorSummary", {}).get(
|
||||
"Model", ""
|
||||
),
|
||||
"power_state": data.get("PowerState", ""),
|
||||
"status": data.get("Status", {}),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _discover_bmc_config(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover BMC configuration via /redfish/v1/Managers/1."""
|
||||
if self._session is None:
|
||||
return []
|
||||
|
||||
url = f"{self._base_url}/redfish/v1/Managers/1"
|
||||
try:
|
||||
response = self._session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to discover BMC config: %s", exc)
|
||||
return []
|
||||
|
||||
manager_id = data.get("Id", "BMC.1")
|
||||
return [
|
||||
DiscoveredResource(
|
||||
resource_type="bare_metal_bmc_config",
|
||||
unique_id=f"{endpoint}:{manager_id}",
|
||||
name=data.get("Name", f"BMC {manager_id}"),
|
||||
provider=ProviderType.BARE_METAL,
|
||||
platform_category=PlatformCategory.BARE_METAL,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"manager_type": data.get("ManagerType", ""),
|
||||
"firmware_version": data.get("FirmwareVersion", ""),
|
||||
"model": data.get("Model", ""),
|
||||
"status": data.get("Status", {}),
|
||||
"uuid": data.get("UUID", ""),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _discover_network_interfaces(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover network interfaces via /redfish/v1/Systems/1/EthernetInterfaces."""
|
||||
if self._session is None:
|
||||
return []
|
||||
|
||||
url = f"{self._base_url}/redfish/v1/Systems/1/EthernetInterfaces"
|
||||
try:
|
||||
response = self._session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to discover network interfaces: %s", exc)
|
||||
return []
|
||||
|
||||
resources: list[DiscoveredResource] = []
|
||||
for member in data.get("Members", []):
|
||||
nic_uri = member.get("@odata.id", "")
|
||||
if not nic_uri:
|
||||
continue
|
||||
|
||||
try:
|
||||
nic_url = f"{self._base_url}{nic_uri}"
|
||||
nic_response = self._session.get(nic_url, timeout=30)
|
||||
if nic_response.status_code != 200:
|
||||
continue
|
||||
nic_data = nic_response.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get NIC details at %s: %s", nic_uri, exc)
|
||||
continue
|
||||
|
||||
nic_id = nic_data.get("Id", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="bare_metal_network_interface",
|
||||
unique_id=f"{endpoint}:{nic_id}",
|
||||
name=nic_data.get("Name", f"NIC {nic_id}"),
|
||||
provider=ProviderType.BARE_METAL,
|
||||
platform_category=PlatformCategory.BARE_METAL,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"mac_address": nic_data.get("MACAddress", ""),
|
||||
"speed_mbps": nic_data.get("SpeedMbps", 0),
|
||||
"status": nic_data.get("Status", {}),
|
||||
"ipv4_addresses": nic_data.get("IPv4Addresses", []),
|
||||
"ipv6_addresses": nic_data.get("IPv6Addresses", []),
|
||||
"vlan": nic_data.get("VLAN", {}),
|
||||
"link_status": nic_data.get("LinkStatus", ""),
|
||||
"auto_neg": nic_data.get("AutoNeg", False),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_raid_config(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover RAID configuration via /redfish/v1/Systems/1/Storage."""
|
||||
if self._session is None:
|
||||
return []
|
||||
|
||||
url = f"{self._base_url}/redfish/v1/Systems/1/Storage"
|
||||
try:
|
||||
response = self._session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to discover RAID config: %s", exc)
|
||||
return []
|
||||
|
||||
resources: list[DiscoveredResource] = []
|
||||
for member in data.get("Members", []):
|
||||
storage_uri = member.get("@odata.id", "")
|
||||
if not storage_uri:
|
||||
continue
|
||||
|
||||
try:
|
||||
storage_url = f"{self._base_url}{storage_uri}"
|
||||
storage_response = self._session.get(storage_url, timeout=30)
|
||||
if storage_response.status_code != 200:
|
||||
continue
|
||||
storage_data = storage_response.json()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to get storage details at %s: %s", storage_uri, exc
|
||||
)
|
||||
continue
|
||||
|
||||
storage_id = storage_data.get("Id", "")
|
||||
drives = []
|
||||
for drive in storage_data.get("Drives", []):
|
||||
drive_uri = drive.get("@odata.id", "")
|
||||
if drive_uri:
|
||||
drives.append(drive_uri)
|
||||
|
||||
volumes = []
|
||||
volumes_link = storage_data.get("Volumes", {}).get("@odata.id", "")
|
||||
if volumes_link:
|
||||
try:
|
||||
vol_url = f"{self._base_url}{volumes_link}"
|
||||
vol_response = self._session.get(vol_url, timeout=30)
|
||||
if vol_response.status_code == 200:
|
||||
vol_data = vol_response.json()
|
||||
for vol_member in vol_data.get("Members", []):
|
||||
vol_uri = vol_member.get("@odata.id", "")
|
||||
if vol_uri:
|
||||
volumes.append(vol_uri)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get volumes: %s", exc)
|
||||
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="bare_metal_raid_config",
|
||||
unique_id=f"{endpoint}:{storage_id}",
|
||||
name=storage_data.get("Name", f"Storage {storage_id}"),
|
||||
provider=ProviderType.BARE_METAL,
|
||||
platform_category=PlatformCategory.BARE_METAL,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"storage_controllers": [
|
||||
ctrl.get("Name", "")
|
||||
for ctrl in storage_data.get(
|
||||
"StorageControllers", []
|
||||
)
|
||||
],
|
||||
"drive_count": len(drives),
|
||||
"drives": drives,
|
||||
"volumes": volumes,
|
||||
"status": storage_data.get("Status", {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
@staticmethod
|
||||
def _parse_architecture(proc_data: dict) -> CpuArchitecture:
|
||||
"""Parse CPU architecture from Redfish processor data.
|
||||
|
||||
Examines InstructionSet and Model fields to determine architecture.
|
||||
"""
|
||||
instruction_set = proc_data.get("InstructionSet", "").lower()
|
||||
model = proc_data.get("Model", "").lower()
|
||||
|
||||
if "aarch64" in instruction_set or "arm" in instruction_set:
|
||||
return CpuArchitecture.AARCH64
|
||||
if "arm" in model:
|
||||
if "64" in model or "aarch64" in model or "v8" in model:
|
||||
return CpuArchitecture.AARCH64
|
||||
return CpuArchitecture.ARM
|
||||
|
||||
# Default to AMD64 for x86/x86_64/IA-32e
|
||||
return CpuArchitecture.AMD64
|
||||
433
src/iac_reverse/scanner/docker_swarm_plugin.py
Normal file
433
src/iac_reverse/scanner/docker_swarm_plugin.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""Docker Swarm provider plugin.
|
||||
|
||||
Discovers services, networks, volumes, configs, and secrets from a Docker Swarm
|
||||
cluster using the docker-sdk-python library.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
import docker
|
||||
from docker.tls import TLSConfig
|
||||
|
||||
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__)
|
||||
|
||||
# Resource types supported by this plugin
|
||||
SUPPORTED_RESOURCE_TYPES = [
|
||||
"docker_service",
|
||||
"docker_network",
|
||||
"docker_volume",
|
||||
"docker_config",
|
||||
"docker_secret",
|
||||
]
|
||||
|
||||
# Mapping from Docker platform architecture strings to CpuArchitecture enum
|
||||
_ARCH_MAP: dict[str, CpuArchitecture] = {
|
||||
"x86_64": CpuArchitecture.AMD64,
|
||||
"amd64": CpuArchitecture.AMD64,
|
||||
"aarch64": CpuArchitecture.AARCH64,
|
||||
"arm64": CpuArchitecture.AARCH64,
|
||||
"armv7l": CpuArchitecture.ARM,
|
||||
"armhf": CpuArchitecture.ARM,
|
||||
"arm": CpuArchitecture.ARM,
|
||||
}
|
||||
|
||||
|
||||
class DockerSwarmPlugin(ProviderPlugin):
|
||||
"""Provider plugin for Docker Swarm infrastructure discovery.
|
||||
|
||||
Connects to a Docker daemon (in Swarm mode) and enumerates services,
|
||||
networks, volumes, configs, and secrets.
|
||||
|
||||
Expected credentials dict keys:
|
||||
- host: Docker daemon URL (e.g., "tcp://192.168.1.10:2376")
|
||||
- tls_verify: (optional) "true" or "false" to enable TLS verification
|
||||
- cert_path: (optional) path to TLS certificates directory
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: Optional[docker.DockerClient] = None
|
||||
self._host: str = ""
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
"""Connect to the Docker daemon using the provided credentials.
|
||||
|
||||
Args:
|
||||
credentials: Dict with keys 'host' (required), 'tls_verify' (optional),
|
||||
and 'cert_path' (optional).
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If connection to the Docker daemon fails.
|
||||
"""
|
||||
host = credentials.get("host", "")
|
||||
if not host:
|
||||
raise AuthenticationError(
|
||||
provider_name="docker_swarm",
|
||||
reason="'host' is required in credentials",
|
||||
)
|
||||
|
||||
tls_verify = credentials.get("tls_verify", "").lower() == "true"
|
||||
cert_path = credentials.get("cert_path")
|
||||
|
||||
tls_config: Optional[TLSConfig] = None
|
||||
if tls_verify or cert_path:
|
||||
tls_config = TLSConfig(
|
||||
verify=tls_verify,
|
||||
client_cert=(
|
||||
(f"{cert_path}/cert.pem", f"{cert_path}/key.pem")
|
||||
if cert_path
|
||||
else None
|
||||
),
|
||||
ca_cert=f"{cert_path}/ca.pem" if cert_path else None,
|
||||
)
|
||||
|
||||
try:
|
||||
self._client = docker.DockerClient(
|
||||
base_url=host,
|
||||
tls=tls_config if tls_config else False,
|
||||
)
|
||||
# Verify connection by pinging the daemon
|
||||
self._client.ping()
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
provider_name="docker_swarm",
|
||||
reason=str(exc),
|
||||
) from exc
|
||||
|
||||
self._host = host
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
"""Return CONTAINER_ORCHESTRATION platform category."""
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
"""Return the Docker daemon host as the single endpoint."""
|
||||
if self._host:
|
||||
return [self._host]
|
||||
return []
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
"""Return supported Docker Swarm resource types."""
|
||||
return list(SUPPORTED_RESOURCE_TYPES)
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
"""Detect CPU architecture from Docker node info.
|
||||
|
||||
Queries the Docker daemon's system info to determine the architecture
|
||||
of the Swarm node.
|
||||
|
||||
Args:
|
||||
endpoint: The Docker daemon endpoint (used for context).
|
||||
|
||||
Returns:
|
||||
CpuArchitecture enum value detected from node info.
|
||||
"""
|
||||
if self._client is None:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
try:
|
||||
info = self._client.info()
|
||||
arch_str = info.get("Architecture", "x86_64").lower()
|
||||
return _ARCH_MAP.get(arch_str, CpuArchitecture.AMD64)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to detect architecture for endpoint %s, defaulting to AMD64",
|
||||
endpoint,
|
||||
)
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback: Callable[[ScanProgress], None],
|
||||
) -> ScanResult:
|
||||
"""Discover Docker Swarm resources.
|
||||
|
||||
Enumerates services, networks, volumes, configs, and secrets
|
||||
based on the requested resource_types.
|
||||
|
||||
Args:
|
||||
endpoints: List of Docker daemon 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] = []
|
||||
|
||||
if self._client is None:
|
||||
return ScanResult(
|
||||
resources=[],
|
||||
warnings=[],
|
||||
errors=["Not authenticated. Call authenticate() first."],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
endpoint = endpoints[0] if endpoints else self._host
|
||||
architecture = self.detect_architecture(endpoint)
|
||||
total_types = len(resource_types)
|
||||
|
||||
discovery_methods = {
|
||||
"docker_service": self._discover_services,
|
||||
"docker_network": self._discover_networks,
|
||||
"docker_volume": self._discover_volumes,
|
||||
"docker_config": self._discover_configs,
|
||||
"docker_secret": self._discover_secrets,
|
||||
}
|
||||
|
||||
for idx, resource_type in enumerate(resource_types):
|
||||
method = discovery_methods.get(resource_type)
|
||||
if method is None:
|
||||
warnings.append(f"Unknown resource type: {resource_type}")
|
||||
continue
|
||||
|
||||
try:
|
||||
discovered = method(endpoint, architecture)
|
||||
resources.extend(discovered)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error discovering {resource_type}: {exc}"
|
||||
errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=resource_type,
|
||||
resources_discovered=len(resources),
|
||||
resource_types_completed=idx + 1,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private discovery methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _discover_services(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Docker Swarm services."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
services = self._client.services.list()
|
||||
|
||||
for svc in services:
|
||||
attrs = svc.attrs
|
||||
spec = attrs.get("Spec", {})
|
||||
task_template = spec.get("TaskTemplate", {})
|
||||
container_spec = task_template.get("ContainerSpec", {})
|
||||
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="docker_service",
|
||||
unique_id=attrs.get("ID", ""),
|
||||
name=spec.get("Name", ""),
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"image": container_spec.get("Image", ""),
|
||||
"replicas": spec.get("Mode", {})
|
||||
.get("Replicated", {})
|
||||
.get("Replicas", 1),
|
||||
"labels": spec.get("Labels", {}),
|
||||
},
|
||||
raw_references=self._extract_service_references(spec),
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_networks(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Docker networks."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
networks = self._client.networks.list()
|
||||
|
||||
for net in networks:
|
||||
attrs = net.attrs
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="docker_network",
|
||||
unique_id=attrs.get("Id", ""),
|
||||
name=attrs.get("Name", ""),
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"driver": attrs.get("Driver", ""),
|
||||
"scope": attrs.get("Scope", ""),
|
||||
"attachable": attrs.get("Attachable", False),
|
||||
"ingress": attrs.get("Ingress", False),
|
||||
"labels": attrs.get("Labels", {}),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_volumes(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Docker volumes."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
volumes = self._client.volumes.list()
|
||||
|
||||
for vol in volumes:
|
||||
attrs = vol.attrs
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="docker_volume",
|
||||
unique_id=attrs.get("Name", ""),
|
||||
name=attrs.get("Name", ""),
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"driver": attrs.get("Driver", ""),
|
||||
"mountpoint": attrs.get("Mountpoint", ""),
|
||||
"labels": attrs.get("Labels", {}),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_configs(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Docker configs (metadata only, no data content)."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
configs = self._client.configs.list()
|
||||
|
||||
for cfg in configs:
|
||||
attrs = cfg.attrs
|
||||
spec = attrs.get("Spec", {})
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="docker_config",
|
||||
unique_id=attrs.get("ID", ""),
|
||||
name=spec.get("Name", ""),
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"labels": spec.get("Labels", {}),
|
||||
"created_at": attrs.get("CreatedAt", ""),
|
||||
"updated_at": attrs.get("UpdatedAt", ""),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_secrets(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Docker secrets (metadata only, no secret data)."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
secrets = self._client.secrets.list()
|
||||
|
||||
for secret in secrets:
|
||||
attrs = secret.attrs
|
||||
spec = attrs.get("Spec", {})
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="docker_secret",
|
||||
unique_id=attrs.get("ID", ""),
|
||||
name=spec.get("Name", ""),
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"labels": spec.get("Labels", {}),
|
||||
"created_at": attrs.get("CreatedAt", ""),
|
||||
"updated_at": attrs.get("UpdatedAt", ""),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
@staticmethod
|
||||
def _extract_service_references(spec: dict) -> list[str]:
|
||||
"""Extract resource references from a service spec.
|
||||
|
||||
Looks for network attachments, volume mounts, config references,
|
||||
and secret references.
|
||||
"""
|
||||
refs: list[str] = []
|
||||
|
||||
# Network references
|
||||
networks = spec.get("TaskTemplate", {}).get("Networks", [])
|
||||
for net in networks:
|
||||
target = net.get("Target", "")
|
||||
if target:
|
||||
refs.append(f"network:{target}")
|
||||
|
||||
# Volume mount references
|
||||
mounts = (
|
||||
spec.get("TaskTemplate", {})
|
||||
.get("ContainerSpec", {})
|
||||
.get("Mounts", [])
|
||||
)
|
||||
for mount in mounts:
|
||||
source = mount.get("Source", "")
|
||||
if source:
|
||||
refs.append(f"volume:{source}")
|
||||
|
||||
# Config references
|
||||
configs = (
|
||||
spec.get("TaskTemplate", {})
|
||||
.get("ContainerSpec", {})
|
||||
.get("Configs", [])
|
||||
)
|
||||
for cfg in configs:
|
||||
config_id = cfg.get("ConfigID", "")
|
||||
if config_id:
|
||||
refs.append(f"config:{config_id}")
|
||||
|
||||
# Secret references
|
||||
secrets = (
|
||||
spec.get("TaskTemplate", {})
|
||||
.get("ContainerSpec", {})
|
||||
.get("Secrets", [])
|
||||
)
|
||||
for secret in secrets:
|
||||
secret_id = secret.get("SecretID", "")
|
||||
if secret_id:
|
||||
refs.append(f"secret:{secret_id}")
|
||||
|
||||
return refs
|
||||
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
|
||||
454
src/iac_reverse/scanner/kubernetes_plugin.py
Normal file
454
src/iac_reverse/scanner/kubernetes_plugin.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""Kubernetes provider plugin for infrastructure discovery.
|
||||
|
||||
Uses the official kubernetes-client library to discover deployments, services,
|
||||
ingresses, config maps, persistent volumes, and namespaces from a Kubernetes
|
||||
cluster. Detects CPU architecture from node labels.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from kubernetes import client, config
|
||||
|
||||
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__)
|
||||
|
||||
# Mapping from kubernetes.io/arch label values to CpuArchitecture enum
|
||||
_ARCH_LABEL_MAP: dict[str, CpuArchitecture] = {
|
||||
"amd64": CpuArchitecture.AMD64,
|
||||
"arm": CpuArchitecture.ARM,
|
||||
"arm64": CpuArchitecture.AARCH64,
|
||||
"aarch64": CpuArchitecture.AARCH64,
|
||||
}
|
||||
|
||||
_SUPPORTED_RESOURCE_TYPES = [
|
||||
"kubernetes_deployment",
|
||||
"kubernetes_service",
|
||||
"kubernetes_ingress",
|
||||
"kubernetes_config_map",
|
||||
"kubernetes_persistent_volume",
|
||||
"kubernetes_namespace",
|
||||
]
|
||||
|
||||
|
||||
class KubernetesPlugin(ProviderPlugin):
|
||||
"""Kubernetes provider plugin using the official kubernetes-client.
|
||||
|
||||
Authenticates via kubeconfig file and discovers cluster resources
|
||||
including deployments, services, ingresses, config maps, persistent
|
||||
volumes, and namespaces.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api_client: client.ApiClient | None = None
|
||||
self._core_v1: client.CoreV1Api | None = None
|
||||
self._apps_v1: client.AppsV1Api | None = None
|
||||
self._networking_v1: client.NetworkingV1Api | None = None
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
"""Load kubeconfig and initialize Kubernetes API clients.
|
||||
|
||||
Args:
|
||||
credentials: Dict with keys:
|
||||
- kubeconfig_path: Path to the kubeconfig file (required)
|
||||
- context: Kubernetes context name (optional)
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If kubeconfig cannot be loaded.
|
||||
"""
|
||||
kubeconfig_path = credentials.get("kubeconfig_path")
|
||||
if not kubeconfig_path:
|
||||
raise AuthenticationError(
|
||||
provider_name="kubernetes",
|
||||
reason="kubeconfig_path is required in credentials",
|
||||
)
|
||||
|
||||
context = credentials.get("context") or None
|
||||
|
||||
try:
|
||||
config.load_kube_config(
|
||||
config_file=kubeconfig_path,
|
||||
context=context,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
provider_name="kubernetes",
|
||||
reason=f"Failed to load kubeconfig from '{kubeconfig_path}': {exc}",
|
||||
) from exc
|
||||
|
||||
self._api_client = client.ApiClient()
|
||||
self._core_v1 = client.CoreV1Api(self._api_client)
|
||||
self._apps_v1 = client.AppsV1Api(self._api_client)
|
||||
self._networking_v1 = client.NetworkingV1Api(self._api_client)
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
"""Return CONTAINER_ORCHESTRATION platform category."""
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
"""Return node addresses as endpoints.
|
||||
|
||||
Returns:
|
||||
List of node internal IP addresses or hostnames.
|
||||
"""
|
||||
if self._core_v1 is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
nodes = self._core_v1.list_node()
|
||||
endpoints: list[str] = []
|
||||
for node in nodes.items:
|
||||
if node.status and node.status.addresses:
|
||||
for addr in node.status.addresses:
|
||||
if addr.type == "InternalIP":
|
||||
endpoints.append(addr.address)
|
||||
break
|
||||
else:
|
||||
# Fallback to first address
|
||||
endpoints.append(node.status.addresses[0].address)
|
||||
return endpoints
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to list node endpoints: %s", exc)
|
||||
return []
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
"""Return all Kubernetes resource types this plugin can discover."""
|
||||
return list(_SUPPORTED_RESOURCE_TYPES)
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
"""Detect CPU architecture from node labels.
|
||||
|
||||
Queries node labels for 'kubernetes.io/arch' to determine the
|
||||
CPU architecture. Falls back to AMD64 if the label is not found.
|
||||
|
||||
Args:
|
||||
endpoint: Node IP address or hostname to query.
|
||||
|
||||
Returns:
|
||||
CpuArchitecture enum value for the node.
|
||||
"""
|
||||
if self._core_v1 is None:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
try:
|
||||
nodes = self._core_v1.list_node()
|
||||
for node in nodes.items:
|
||||
# Match node by address
|
||||
if node.status and node.status.addresses:
|
||||
node_addresses = [
|
||||
addr.address for addr in node.status.addresses
|
||||
]
|
||||
if endpoint in node_addresses:
|
||||
labels = node.metadata.labels or {}
|
||||
arch_label = labels.get(
|
||||
"kubernetes.io/arch",
|
||||
labels.get("beta.kubernetes.io/arch", "amd64"),
|
||||
)
|
||||
return _ARCH_LABEL_MAP.get(
|
||||
arch_label, CpuArchitecture.AMD64
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to detect architecture for endpoint '%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 Kubernetes resources across all namespaces.
|
||||
|
||||
Args:
|
||||
endpoints: List of node addresses (used for architecture detection).
|
||||
resource_types: List of resource type strings to discover.
|
||||
progress_callback: Callable for progress updates.
|
||||
|
||||
Returns:
|
||||
ScanResult with all discovered resources.
|
||||
"""
|
||||
resources: list[DiscoveredResource] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# Determine architecture from first endpoint
|
||||
architecture = CpuArchitecture.AMD64
|
||||
if endpoints:
|
||||
architecture = self.detect_architecture(endpoints[0])
|
||||
|
||||
endpoint_str = endpoints[0] if endpoints else "cluster"
|
||||
total_types = len(resource_types)
|
||||
|
||||
for idx, resource_type in enumerate(resource_types):
|
||||
try:
|
||||
discovered = self._discover_resource_type(
|
||||
resource_type, architecture, endpoint_str
|
||||
)
|
||||
resources.extend(discovered)
|
||||
except Exception as exc:
|
||||
error_msg = (
|
||||
f"Error discovering {resource_type}: {exc}"
|
||||
)
|
||||
errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=resource_type,
|
||||
resources_discovered=len(resources),
|
||||
resource_types_completed=idx + 1,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
def _discover_resource_type(
|
||||
self,
|
||||
resource_type: str,
|
||||
architecture: CpuArchitecture,
|
||||
endpoint: str,
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover resources of a specific type.
|
||||
|
||||
Args:
|
||||
resource_type: The resource type string to discover.
|
||||
architecture: Detected CPU architecture.
|
||||
endpoint: Endpoint string for the resource.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredResource objects.
|
||||
"""
|
||||
dispatch = {
|
||||
"kubernetes_deployment": self._discover_deployments,
|
||||
"kubernetes_service": self._discover_services,
|
||||
"kubernetes_ingress": self._discover_ingresses,
|
||||
"kubernetes_config_map": self._discover_config_maps,
|
||||
"kubernetes_persistent_volume": self._discover_persistent_volumes,
|
||||
"kubernetes_namespace": self._discover_namespaces,
|
||||
}
|
||||
|
||||
handler = dispatch.get(resource_type)
|
||||
if handler is None:
|
||||
return []
|
||||
|
||||
return handler(architecture, endpoint)
|
||||
|
||||
def _discover_deployments(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all deployments across namespaces."""
|
||||
results: list[DiscoveredResource] = []
|
||||
deployments = self._apps_v1.list_deployment_for_all_namespaces()
|
||||
|
||||
for dep in deployments.items:
|
||||
name = dep.metadata.name
|
||||
namespace = dep.metadata.namespace
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id=f"{namespace}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"namespace": namespace,
|
||||
"replicas": dep.spec.replicas if dep.spec else None,
|
||||
"labels": dict(dep.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[
|
||||
f"kubernetes_namespace:{namespace}",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _discover_services(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all services across namespaces."""
|
||||
results: list[DiscoveredResource] = []
|
||||
services = self._core_v1.list_service_for_all_namespaces()
|
||||
|
||||
for svc in services.items:
|
||||
name = svc.metadata.name
|
||||
namespace = svc.metadata.namespace
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id=f"{namespace}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"namespace": namespace,
|
||||
"type": svc.spec.type if svc.spec else None,
|
||||
"cluster_ip": svc.spec.cluster_ip if svc.spec else None,
|
||||
"labels": dict(svc.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[
|
||||
f"kubernetes_namespace:{namespace}",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _discover_ingresses(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all ingresses across namespaces."""
|
||||
results: list[DiscoveredResource] = []
|
||||
ingresses = self._networking_v1.list_ingress_for_all_namespaces()
|
||||
|
||||
for ing in ingresses.items:
|
||||
name = ing.metadata.name
|
||||
namespace = ing.metadata.namespace
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_ingress",
|
||||
unique_id=f"{namespace}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"namespace": namespace,
|
||||
"labels": dict(ing.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[
|
||||
f"kubernetes_namespace:{namespace}",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _discover_config_maps(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all config maps across namespaces."""
|
||||
results: list[DiscoveredResource] = []
|
||||
config_maps = self._core_v1.list_config_map_for_all_namespaces()
|
||||
|
||||
for cm in config_maps.items:
|
||||
name = cm.metadata.name
|
||||
namespace = cm.metadata.namespace
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_config_map",
|
||||
unique_id=f"{namespace}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"namespace": namespace,
|
||||
"data_keys": list((cm.data or {}).keys()),
|
||||
"labels": dict(cm.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[
|
||||
f"kubernetes_namespace:{namespace}",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _discover_persistent_volumes(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all persistent volumes (cluster-scoped)."""
|
||||
results: list[DiscoveredResource] = []
|
||||
pvs = self._core_v1.list_persistent_volume()
|
||||
|
||||
for pv in pvs.items:
|
||||
name = pv.metadata.name
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_persistent_volume",
|
||||
unique_id=name,
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"capacity": (
|
||||
dict(pv.spec.capacity)
|
||||
if pv.spec and pv.spec.capacity
|
||||
else {}
|
||||
),
|
||||
"access_modes": (
|
||||
list(pv.spec.access_modes)
|
||||
if pv.spec and pv.spec.access_modes
|
||||
else []
|
||||
),
|
||||
"storage_class": (
|
||||
pv.spec.storage_class_name if pv.spec else None
|
||||
),
|
||||
"labels": dict(pv.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _discover_namespaces(
|
||||
self, architecture: CpuArchitecture, endpoint: str
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover all namespaces."""
|
||||
results: list[DiscoveredResource] = []
|
||||
namespaces = self._core_v1.list_namespace()
|
||||
|
||||
for ns in namespaces.items:
|
||||
name = ns.metadata.name
|
||||
results.append(
|
||||
DiscoveredResource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id=name,
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"status": (
|
||||
ns.status.phase if ns.status else None
|
||||
),
|
||||
"labels": dict(ns.metadata.labels or {}),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
140
src/iac_reverse/scanner/multi_provider_scanner.py
Normal file
140
src/iac_reverse/scanner/multi_provider_scanner.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Multi-provider scanner for infrastructure discovery.
|
||||
|
||||
Coordinates scanning across multiple providers independently, handling
|
||||
partial failures gracefully. If one provider fails, scanning continues
|
||||
for all remaining providers. Successfully discovered resources are
|
||||
collected into a unified inventory, and failed providers are reported
|
||||
with error details.
|
||||
|
||||
Implements Requirement 5.5: IF one or more Provider scans fail during a
|
||||
multi-provider scan, THEN THE Scanner SHALL complete scanning for all
|
||||
remaining Providers, include successfully discovered Resources in the
|
||||
inventory, and report which Providers failed along with the corresponding
|
||||
error details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
from iac_reverse.models import (
|
||||
DiscoveredResource,
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
from iac_reverse.scanner.scanner import Scanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderFailure:
|
||||
"""Details about a provider that failed during multi-provider scanning."""
|
||||
|
||||
provider_name: str
|
||||
error_type: str
|
||||
error_message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiProviderScanResult:
|
||||
"""Result of scanning across multiple providers.
|
||||
|
||||
Contains all successfully discovered resources from providers that
|
||||
completed scanning, plus details about any providers that failed.
|
||||
"""
|
||||
|
||||
resources: list[DiscoveredResource] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
failed_providers: list[ProviderFailure] = field(default_factory=list)
|
||||
successful_providers: list[str] = field(default_factory=list)
|
||||
scan_timestamp: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderScanEntry:
|
||||
"""A pairing of a ScanProfile with its corresponding ProviderPlugin."""
|
||||
|
||||
profile: ScanProfile
|
||||
plugin: ProviderPlugin
|
||||
|
||||
|
||||
class MultiProviderScanner:
|
||||
"""Orchestrates infrastructure discovery across multiple providers.
|
||||
|
||||
Scans each provider independently. If one provider fails (auth error,
|
||||
connection error, etc.), continues with remaining providers. Collects
|
||||
all successfully discovered resources into a unified inventory and
|
||||
reports which providers failed and why.
|
||||
"""
|
||||
|
||||
def __init__(self, entries: list[ProviderScanEntry]):
|
||||
"""Initialize with a list of provider scan entries.
|
||||
|
||||
Args:
|
||||
entries: List of ProviderScanEntry, each pairing a ScanProfile
|
||||
with its corresponding ProviderPlugin.
|
||||
"""
|
||||
self.entries = entries
|
||||
|
||||
def scan(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[ScanProgress], None]] = None,
|
||||
) -> MultiProviderScanResult:
|
||||
"""Execute scans across all configured providers.
|
||||
|
||||
Each provider is scanned independently. If a provider fails for
|
||||
any reason (authentication, connection, timeout, validation, etc.),
|
||||
the error is recorded and scanning continues with remaining providers.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable invoked with ScanProgress
|
||||
updates from each provider scan.
|
||||
|
||||
Returns:
|
||||
MultiProviderScanResult containing all successfully discovered
|
||||
resources and details about any failed providers.
|
||||
"""
|
||||
result = MultiProviderScanResult(
|
||||
scan_timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
for entry in self.entries:
|
||||
provider_name = entry.profile.provider.value
|
||||
try:
|
||||
scanner = Scanner(entry.profile, entry.plugin)
|
||||
scan_result = scanner.scan(progress_callback=progress_callback)
|
||||
|
||||
# Collect successful resources
|
||||
result.resources.extend(scan_result.resources)
|
||||
result.warnings.extend(scan_result.warnings)
|
||||
result.errors.extend(scan_result.errors)
|
||||
result.successful_providers.append(provider_name)
|
||||
|
||||
logger.info(
|
||||
"Provider '%s' scan completed: %d resources discovered",
|
||||
provider_name,
|
||||
len(scan_result.resources),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Record the failure and continue with remaining providers
|
||||
failure = ProviderFailure(
|
||||
provider_name=provider_name,
|
||||
error_type=type(exc).__name__,
|
||||
error_message=str(exc),
|
||||
)
|
||||
result.failed_providers.append(failure)
|
||||
|
||||
logger.warning(
|
||||
"Provider '%s' scan failed (%s): %s",
|
||||
provider_name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
|
||||
return result
|
||||
287
src/iac_reverse/scanner/scanner.py
Normal file
287
src/iac_reverse/scanner/scanner.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Scanner orchestrator for infrastructure discovery.
|
||||
|
||||
Coordinates provider plugins to discover infrastructure resources,
|
||||
handling authentication, retries, progress reporting, and error recovery.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
from iac_reverse.models import (
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication with a provider fails."""
|
||||
|
||||
def __init__(self, provider_name: str, reason: str):
|
||||
self.provider_name = provider_name
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Authentication failed for provider '{provider_name}': {reason}"
|
||||
)
|
||||
|
||||
|
||||
class ConnectionLostError(Exception):
|
||||
"""Raised when the provider connection is lost during a scan."""
|
||||
|
||||
def __init__(self, partial_result: ScanResult):
|
||||
self.partial_result = partial_result
|
||||
super().__init__("Connection lost during scan; partial results available")
|
||||
|
||||
|
||||
class ScanTimeoutError(Exception):
|
||||
"""Raised when a scan operation exceeds the allowed timeout."""
|
||||
|
||||
def __init__(self, message: str = "Scan operation timed out"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner Orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default constants
|
||||
CONNECTION_TIMEOUT_SECONDS = 30
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_BACKOFF_SECONDS = 1.0
|
||||
|
||||
|
||||
class Scanner:
|
||||
"""Orchestrates infrastructure discovery using a provider plugin.
|
||||
|
||||
Accepts a ScanProfile and an optional ProviderPlugin instance.
|
||||
Handles authentication, progress reporting, retry logic with
|
||||
exponential backoff, and graceful degradation on errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile: ScanProfile,
|
||||
plugin: Optional[ProviderPlugin] = None,
|
||||
):
|
||||
self.profile = profile
|
||||
self.plugin = plugin
|
||||
|
||||
def scan(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[ScanProgress], None]] = None,
|
||||
) -> ScanResult:
|
||||
"""Execute a full infrastructure scan.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable invoked per resource type
|
||||
completion with a ScanProgress update.
|
||||
|
||||
Returns:
|
||||
ScanResult containing discovered resources, warnings, and errors.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication with the provider fails.
|
||||
ScanTimeoutError: If the connection attempt exceeds 30 seconds.
|
||||
ValueError: If the scan profile is invalid.
|
||||
"""
|
||||
# 1. Validate the scan profile (critical fields only)
|
||||
validation_errors = self._validate_profile()
|
||||
if validation_errors:
|
||||
raise ValueError(
|
||||
f"Invalid scan profile: {'; '.join(validation_errors)}"
|
||||
)
|
||||
|
||||
if self.plugin is None:
|
||||
raise ValueError("No provider plugin configured for scanning")
|
||||
|
||||
# 2. Authenticate with the provider (30 second timeout)
|
||||
self._authenticate()
|
||||
|
||||
# 3. Determine resource types to scan
|
||||
supported_types = self.plugin.list_supported_resource_types()
|
||||
resource_types, warnings = self._resolve_resource_types(supported_types)
|
||||
|
||||
# 4. Determine endpoints
|
||||
endpoints = self.profile.endpoints or self.plugin.list_endpoints()
|
||||
|
||||
# 5. Discover resources with retry logic
|
||||
scan_result = self._discover_with_retries(
|
||||
endpoints=endpoints,
|
||||
resource_types=resource_types,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Merge any warnings from unsupported resource type filtering
|
||||
scan_result.warnings = warnings + scan_result.warnings
|
||||
|
||||
# Set metadata
|
||||
scan_result.scan_timestamp = datetime.now(timezone.utc).isoformat()
|
||||
scan_result.profile_hash = self._compute_profile_hash()
|
||||
|
||||
return scan_result
|
||||
|
||||
def _authenticate(self) -> None:
|
||||
"""Authenticate with the provider plugin, enforcing a 30s timeout."""
|
||||
provider_name = self.profile.provider.value
|
||||
start_time = time.monotonic()
|
||||
|
||||
try:
|
||||
self.plugin.authenticate(self.profile.credentials)
|
||||
except Exception as exc:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= CONNECTION_TIMEOUT_SECONDS:
|
||||
raise ScanTimeoutError(
|
||||
f"Authentication with provider '{provider_name}' "
|
||||
f"timed out after {CONNECTION_TIMEOUT_SECONDS} seconds"
|
||||
)
|
||||
# Wrap any auth exception in our AuthenticationError
|
||||
if isinstance(exc, AuthenticationError):
|
||||
raise
|
||||
raise AuthenticationError(
|
||||
provider_name=provider_name,
|
||||
reason=str(exc),
|
||||
) from exc
|
||||
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= CONNECTION_TIMEOUT_SECONDS:
|
||||
raise ScanTimeoutError(
|
||||
f"Authentication with provider '{provider_name}' "
|
||||
f"timed out after {CONNECTION_TIMEOUT_SECONDS} seconds"
|
||||
)
|
||||
|
||||
def _resolve_resource_types(
|
||||
self, supported_types: list[str]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Determine which resource types to scan and log warnings for unsupported ones.
|
||||
|
||||
Returns:
|
||||
Tuple of (resource_types_to_scan, warnings_list)
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
if self.profile.resource_type_filters is None:
|
||||
# No filters: scan all supported types
|
||||
return supported_types, warnings
|
||||
|
||||
# Filter requested types against supported types
|
||||
valid_types: list[str] = []
|
||||
for rt in self.profile.resource_type_filters:
|
||||
if rt in supported_types:
|
||||
valid_types.append(rt)
|
||||
else:
|
||||
warning_msg = (
|
||||
f"Unsupported resource type '{rt}' for provider "
|
||||
f"'{self.profile.provider.value}'; skipping"
|
||||
)
|
||||
warnings.append(warning_msg)
|
||||
logger.warning(warning_msg)
|
||||
|
||||
return valid_types, warnings
|
||||
|
||||
def _discover_with_retries(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback: Optional[Callable[[ScanProgress], None]],
|
||||
) -> ScanResult:
|
||||
"""Call the plugin's discover_resources with retry logic.
|
||||
|
||||
Retries up to MAX_RETRIES times with exponential backoff for
|
||||
transient errors. On connection loss, returns partial inventory.
|
||||
"""
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
result = self.plugin.discover_resources(
|
||||
endpoints=endpoints,
|
||||
resource_types=resource_types,
|
||||
progress_callback=progress_callback or self._noop_callback,
|
||||
)
|
||||
return result
|
||||
except ConnectionLostError:
|
||||
# Connection lost: return partial results immediately
|
||||
raise
|
||||
except ConnectionError as exc:
|
||||
# Connection lost during scan: build partial result
|
||||
logger.warning(
|
||||
"Connection lost during scan (attempt %d/%d): %s",
|
||||
attempt + 1,
|
||||
MAX_RETRIES + 1,
|
||||
exc,
|
||||
)
|
||||
partial = ScanResult(
|
||||
resources=[],
|
||||
warnings=[f"Connection lost: {exc}"],
|
||||
errors=[str(exc)],
|
||||
scan_timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
profile_hash=self._compute_profile_hash(),
|
||||
is_partial=True,
|
||||
)
|
||||
raise ConnectionLostError(partial_result=partial) from exc
|
||||
except Exception as exc:
|
||||
last_exception = exc
|
||||
if attempt < MAX_RETRIES:
|
||||
backoff = INITIAL_BACKOFF_SECONDS * (2**attempt)
|
||||
logger.warning(
|
||||
"Transient error during scan (attempt %d/%d), "
|
||||
"retrying in %.1fs: %s",
|
||||
attempt + 1,
|
||||
MAX_RETRIES + 1,
|
||||
backoff,
|
||||
exc,
|
||||
)
|
||||
time.sleep(backoff)
|
||||
else:
|
||||
logger.error(
|
||||
"Scan failed after %d attempts: %s",
|
||||
MAX_RETRIES + 1,
|
||||
exc,
|
||||
)
|
||||
|
||||
# All retries exhausted — return error result
|
||||
return ScanResult(
|
||||
resources=[],
|
||||
warnings=[],
|
||||
errors=[f"Scan failed after {MAX_RETRIES + 1} attempts: {last_exception}"],
|
||||
scan_timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
profile_hash=self._compute_profile_hash(),
|
||||
is_partial=True,
|
||||
)
|
||||
|
||||
def _validate_profile(self) -> list[str]:
|
||||
"""Validate critical scan profile fields.
|
||||
|
||||
Only checks fields that prevent scanning entirely (e.g., missing
|
||||
credentials). Unsupported resource types are handled as warnings
|
||||
during the scan per Requirement 1.4.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not self.profile.credentials:
|
||||
errors.append("credentials must not be empty")
|
||||
return errors
|
||||
|
||||
def _compute_profile_hash(self) -> str:
|
||||
"""Compute a stable hash of the scan profile for snapshot matching."""
|
||||
content = (
|
||||
f"{self.profile.provider.value}:"
|
||||
f"{sorted(self.profile.credentials.items())}:"
|
||||
f"{self.profile.endpoints}:"
|
||||
f"{self.profile.resource_type_filters}"
|
||||
)
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _noop_callback(progress: ScanProgress) -> None:
|
||||
"""No-op progress callback used when none is provided."""
|
||||
pass
|
||||
482
src/iac_reverse/scanner/synology_plugin.py
Normal file
482
src/iac_reverse/scanner/synology_plugin.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""Synology DSM provider plugin.
|
||||
|
||||
Discovers shared folders, volumes, storage pools, replication tasks, and users
|
||||
from a Synology DiskStation Manager (DSM) appliance via its HTTP API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
from synology_dsm import SynologyDSM
|
||||
except ImportError: # pragma: no cover
|
||||
SynologyDSM = None # type: ignore[assignment,misc]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Resource type constants
|
||||
SYNOLOGY_SHARED_FOLDER = "synology_shared_folder"
|
||||
SYNOLOGY_VOLUME = "synology_volume"
|
||||
SYNOLOGY_STORAGE_POOL = "synology_storage_pool"
|
||||
SYNOLOGY_REPLICATION_TASK = "synology_replication_task"
|
||||
SYNOLOGY_USER = "synology_user"
|
||||
|
||||
SUPPORTED_RESOURCE_TYPES = [
|
||||
SYNOLOGY_SHARED_FOLDER,
|
||||
SYNOLOGY_VOLUME,
|
||||
SYNOLOGY_STORAGE_POOL,
|
||||
SYNOLOGY_REPLICATION_TASK,
|
||||
SYNOLOGY_USER,
|
||||
]
|
||||
|
||||
|
||||
class SynologyPlugin(ProviderPlugin):
|
||||
"""Provider plugin for Synology DiskStation Manager (DSM).
|
||||
|
||||
Connects to the Synology DSM API to discover storage infrastructure
|
||||
including shared folders, volumes, storage pools, replication tasks,
|
||||
and local users.
|
||||
|
||||
Expected credentials:
|
||||
- host: DSM hostname or IP address
|
||||
- port: DSM port (default "5001")
|
||||
- username: DSM admin username
|
||||
- password: DSM admin password
|
||||
- use_ssl: "true" or "false" (default "true")
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api: Optional[object] = None
|
||||
self._host: str = ""
|
||||
self._port: str = "5001"
|
||||
self._use_ssl: bool = True
|
||||
self._authenticated: bool = False
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
"""Authenticate with the Synology DSM API.
|
||||
|
||||
Args:
|
||||
credentials: Dict with keys: host, port, username, password,
|
||||
and optionally use_ssl.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If connection or login fails.
|
||||
"""
|
||||
host = credentials.get("host", "")
|
||||
port = credentials.get("port", "5001")
|
||||
username = credentials.get("username", "")
|
||||
password = credentials.get("password", "")
|
||||
use_ssl = credentials.get("use_ssl", "true").lower() == "true"
|
||||
|
||||
if not host:
|
||||
raise AuthenticationError("synology", "host is required")
|
||||
if not username:
|
||||
raise AuthenticationError("synology", "username is required")
|
||||
if not password:
|
||||
raise AuthenticationError("synology", "password is required")
|
||||
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._use_ssl = use_ssl
|
||||
|
||||
try:
|
||||
if SynologyDSM is None:
|
||||
raise AuthenticationError(
|
||||
"synology",
|
||||
"python-synology library is not installed",
|
||||
)
|
||||
|
||||
api = SynologyDSM(
|
||||
host,
|
||||
int(port),
|
||||
username,
|
||||
password,
|
||||
use_https=use_ssl,
|
||||
verify_ssl=False,
|
||||
)
|
||||
# Attempt login
|
||||
if not api.login():
|
||||
raise AuthenticationError(
|
||||
"synology",
|
||||
f"Login failed for user '{username}' on {host}:{port}",
|
||||
)
|
||||
self._api = api
|
||||
self._authenticated = True
|
||||
logger.info("Authenticated with Synology DSM at %s:%s", host, port)
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
"synology",
|
||||
f"Failed to connect to DSM at {host}:{port}: {exc}",
|
||||
) from exc
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
"""Return STORAGE_APPLIANCE platform category."""
|
||||
return PlatformCategory.STORAGE_APPLIANCE
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
"""Return the DSM endpoint address."""
|
||||
protocol = "https" if self._use_ssl else "http"
|
||||
return [f"{protocol}://{self._host}:{self._port}"]
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
"""Return all Synology resource types this plugin can discover."""
|
||||
return list(SUPPORTED_RESOURCE_TYPES)
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
"""Detect CPU architecture from Synology system info.
|
||||
|
||||
Queries the DSM information API to determine if the NAS
|
||||
runs on ARM or AMD64 hardware.
|
||||
|
||||
Args:
|
||||
endpoint: The DSM endpoint (used for context, not connection).
|
||||
|
||||
Returns:
|
||||
CpuArchitecture.ARM for ARM-based models,
|
||||
CpuArchitecture.AARCH64 for 64-bit ARM models,
|
||||
CpuArchitecture.AMD64 for x86-64 models.
|
||||
"""
|
||||
if self._api is None:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
try:
|
||||
info = self._api.information
|
||||
if info is None:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
# The model name or CPU info can indicate architecture
|
||||
model = getattr(info, "model", "") or ""
|
||||
cpu_name = getattr(info, "cpu_hardware_name", "") or ""
|
||||
|
||||
# Combine for matching
|
||||
hw_info = f"{model} {cpu_name}".lower()
|
||||
|
||||
if "aarch64" in hw_info or "arm64" in hw_info:
|
||||
return CpuArchitecture.AARCH64
|
||||
elif "arm" in hw_info or "rtd" in hw_info or "alpine" in hw_info:
|
||||
return CpuArchitecture.ARM
|
||||
else:
|
||||
return CpuArchitecture.AMD64
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to detect architecture: %s", exc)
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback: Callable[[ScanProgress], None],
|
||||
) -> ScanResult:
|
||||
"""Discover Synology resources from the DSM API.
|
||||
|
||||
Enumerates shared folders, volumes, storage pools, replication tasks,
|
||||
and users based on the requested resource_types.
|
||||
|
||||
Args:
|
||||
endpoints: List of DSM endpoints (typically one).
|
||||
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 self.list_endpoints()[0]
|
||||
architecture = self.detect_architecture(endpoint)
|
||||
|
||||
total_types = len(resource_types)
|
||||
completed = 0
|
||||
|
||||
# Discovery dispatch table
|
||||
discovery_methods = {
|
||||
SYNOLOGY_SHARED_FOLDER: self._discover_shared_folders,
|
||||
SYNOLOGY_VOLUME: self._discover_volumes,
|
||||
SYNOLOGY_STORAGE_POOL: self._discover_storage_pools,
|
||||
SYNOLOGY_REPLICATION_TASK: self._discover_replication_tasks,
|
||||
SYNOLOGY_USER: self._discover_users,
|
||||
}
|
||||
|
||||
for rt in resource_types:
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=rt,
|
||||
resources_discovered=len(resources),
|
||||
resource_types_completed=completed,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
method = discovery_methods.get(rt)
|
||||
if method is None:
|
||||
warnings.append(f"Unsupported resource type: {rt}")
|
||||
completed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
discovered = method(endpoint, architecture)
|
||||
resources.extend(discovered)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error discovering {rt}: {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=completed,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
scan_timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private discovery methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _discover_shared_folders(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover shared folders from DSM."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
|
||||
storage = self._api.storage
|
||||
if storage is None:
|
||||
return resources
|
||||
|
||||
# Access shared folders via the storage API
|
||||
shares = getattr(storage, "shares", None)
|
||||
if shares is None:
|
||||
return resources
|
||||
|
||||
for share in shares:
|
||||
name = share.get("name", "unknown")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type=SYNOLOGY_SHARED_FOLDER,
|
||||
unique_id=f"synology/shared_folder/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
platform_category=PlatformCategory.STORAGE_APPLIANCE,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"name": name,
|
||||
"path": share.get("path", ""),
|
||||
"desc": share.get("desc", ""),
|
||||
"encryption": share.get("is_encrypted", False),
|
||||
"recycle_bin": share.get("enable_recycle_bin", False),
|
||||
"vol_path": share.get("vol_path", ""),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_volumes(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover volumes from DSM."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
|
||||
storage = self._api.storage
|
||||
if storage is None:
|
||||
return resources
|
||||
|
||||
volumes = getattr(storage, "volumes", None)
|
||||
if volumes is None:
|
||||
return resources
|
||||
|
||||
for volume in volumes:
|
||||
vol_id = volume.get("id", "unknown")
|
||||
name = volume.get("display_name", vol_id)
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type=SYNOLOGY_VOLUME,
|
||||
unique_id=f"synology/volume/{vol_id}",
|
||||
name=name,
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
platform_category=PlatformCategory.STORAGE_APPLIANCE,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"id": vol_id,
|
||||
"display_name": name,
|
||||
"status": volume.get("status", ""),
|
||||
"fs_type": volume.get("fs_type", ""),
|
||||
"size_total": volume.get("size", {}).get("total", ""),
|
||||
"size_used": volume.get("size", {}).get("used", ""),
|
||||
"pool_path": volume.get("pool_path", ""),
|
||||
},
|
||||
raw_references=[
|
||||
f"synology/storage_pool/{volume.get('pool_path', '')}"
|
||||
]
|
||||
if volume.get("pool_path")
|
||||
else [],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_storage_pools(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover storage pools from DSM."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
|
||||
storage = self._api.storage
|
||||
if storage is None:
|
||||
return resources
|
||||
|
||||
pools = getattr(storage, "storage_pools", None)
|
||||
if pools is None:
|
||||
return resources
|
||||
|
||||
for pool in pools:
|
||||
pool_id = pool.get("id", "unknown")
|
||||
name = pool.get("display_name", pool_id)
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type=SYNOLOGY_STORAGE_POOL,
|
||||
unique_id=f"synology/storage_pool/{pool_id}",
|
||||
name=name,
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
platform_category=PlatformCategory.STORAGE_APPLIANCE,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"id": pool_id,
|
||||
"display_name": name,
|
||||
"status": pool.get("status", ""),
|
||||
"raid_type": pool.get("raid_type", ""),
|
||||
"size_total": pool.get("size", {}).get("total", ""),
|
||||
"size_used": pool.get("size", {}).get("used", ""),
|
||||
"disk_count": len(pool.get("disks", [])),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_replication_tasks(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover replication tasks from DSM."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
|
||||
# Replication tasks are accessed via a separate API module
|
||||
api = self._api
|
||||
if api is None:
|
||||
return resources
|
||||
|
||||
# Try to access replication info if available
|
||||
replication = getattr(api, "replication", None)
|
||||
if replication is None:
|
||||
return resources
|
||||
|
||||
tasks = getattr(replication, "tasks", None)
|
||||
if tasks is None:
|
||||
return resources
|
||||
|
||||
for task in tasks:
|
||||
task_id = task.get("id", "unknown")
|
||||
name = task.get("name", task_id)
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type=SYNOLOGY_REPLICATION_TASK,
|
||||
unique_id=f"synology/replication_task/{task_id}",
|
||||
name=name,
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
platform_category=PlatformCategory.STORAGE_APPLIANCE,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"status": task.get("status", ""),
|
||||
"type": task.get("type", ""),
|
||||
"destination": task.get("destination", ""),
|
||||
"schedule": task.get("schedule", {}),
|
||||
"shared_folder": task.get("shared_folder", ""),
|
||||
},
|
||||
raw_references=[
|
||||
f"synology/shared_folder/{task.get('shared_folder', '')}"
|
||||
]
|
||||
if task.get("shared_folder")
|
||||
else [],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
|
||||
def _discover_users(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover local users from DSM."""
|
||||
resources: list[DiscoveredResource] = []
|
||||
|
||||
api = self._api
|
||||
if api is None:
|
||||
return resources
|
||||
|
||||
# Users are typically accessed via SYNO.Core.User API
|
||||
users_api = getattr(api, "users", None)
|
||||
if users_api is None:
|
||||
return resources
|
||||
|
||||
users = getattr(users_api, "users", None)
|
||||
if users is None:
|
||||
return resources
|
||||
|
||||
for user in users:
|
||||
username = user.get("name", "unknown")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type=SYNOLOGY_USER,
|
||||
unique_id=f"synology/user/{username}",
|
||||
name=username,
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
platform_category=PlatformCategory.STORAGE_APPLIANCE,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"name": username,
|
||||
"description": user.get("description", ""),
|
||||
"email": user.get("email", ""),
|
||||
"expired": user.get("expired", False),
|
||||
"groups": user.get("groups", []),
|
||||
},
|
||||
raw_references=[],
|
||||
)
|
||||
)
|
||||
|
||||
return resources
|
||||
825
src/iac_reverse/scanner/windows_plugin.py
Normal file
825
src/iac_reverse/scanner/windows_plugin.py
Normal file
@@ -0,0 +1,825 @@
|
||||
"""Windows provider plugin for infrastructure discovery via WinRM.
|
||||
|
||||
Uses pywinrm to connect to Windows machines and discover services,
|
||||
scheduled tasks, IIS sites, app pools, network adapters, firewall rules,
|
||||
installed software, Windows features, Hyper-V VMs, Hyper-V switches,
|
||||
DNS records, local users, and local groups.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import winrm
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WinRMNotEnabledError(Exception):
|
||||
"""Raised when WinRM is not enabled on the target host."""
|
||||
|
||||
def __init__(self, host: str, reason: str = ""):
|
||||
self.host = host
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"WinRM is not enabled or unreachable on host '{host}'"
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
|
||||
|
||||
class WMIQueryError(Exception):
|
||||
"""Raised when a WMI query fails on the target host."""
|
||||
|
||||
def __init__(self, query: str, reason: str = ""):
|
||||
self.query = query
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"WMI query failed: '{query}'"
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
|
||||
|
||||
class InsufficientPrivilegesError(Exception):
|
||||
"""Raised when the authenticated user lacks required privileges."""
|
||||
|
||||
def __init__(self, operation: str, reason: str = ""):
|
||||
self.operation = operation
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Insufficient privileges for operation '{operation}'"
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Discovery Plugin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WINDOWS_RESOURCE_TYPES = [
|
||||
"windows_service",
|
||||
"windows_scheduled_task",
|
||||
"windows_iis_site",
|
||||
"windows_iis_app_pool",
|
||||
"windows_network_adapter",
|
||||
"windows_firewall_rule",
|
||||
"windows_installed_software",
|
||||
"windows_feature",
|
||||
"windows_hyperv_vm",
|
||||
"windows_hyperv_switch",
|
||||
"windows_dns_record",
|
||||
"windows_local_user",
|
||||
"windows_local_group",
|
||||
]
|
||||
|
||||
|
||||
class WindowsDiscoveryPlugin(ProviderPlugin):
|
||||
"""Provider plugin for discovering Windows infrastructure via WinRM.
|
||||
|
||||
Connects to Windows machines using pywinrm and discovers resources
|
||||
through PowerShell commands and WMI queries executed over WinRM.
|
||||
|
||||
Expected credentials dict keys:
|
||||
host: Target hostname or IP address
|
||||
username: Windows username (domain\\user or user@domain)
|
||||
password: Password for authentication
|
||||
transport: Authentication transport - "ntlm" (default) or "kerberos"
|
||||
port: WinRM port - "5985" (HTTP) or "5986" (HTTPS, default)
|
||||
use_ssl: Whether to use SSL - "true" (default) or "false"
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: winrm.Session | None = None
|
||||
self._host: str = ""
|
||||
self._credentials: dict[str, str] = {}
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
"""Authenticate with the Windows host via WinRM.
|
||||
|
||||
Args:
|
||||
credentials: Dict with keys: host, username, password,
|
||||
transport (default "ntlm"), port (default "5986"),
|
||||
use_ssl (default "true").
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
WinRMNotEnabledError: If WinRM is not reachable.
|
||||
"""
|
||||
host = credentials.get("host", "")
|
||||
username = credentials.get("username", "")
|
||||
password = credentials.get("password", "")
|
||||
transport = credentials.get("transport", "ntlm")
|
||||
port = credentials.get("port", "5986")
|
||||
use_ssl = credentials.get("use_ssl", "true").lower() == "true"
|
||||
|
||||
if not host:
|
||||
raise AuthenticationError("windows", "host is required")
|
||||
if not username:
|
||||
raise AuthenticationError("windows", "username is required")
|
||||
if not password:
|
||||
raise AuthenticationError("windows", "password is required")
|
||||
|
||||
self._host = host
|
||||
self._credentials = credentials
|
||||
|
||||
scheme = "https" if use_ssl else "http"
|
||||
endpoint = f"{scheme}://{host}:{port}/wsman"
|
||||
|
||||
try:
|
||||
self._session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(username, password),
|
||||
transport=transport,
|
||||
server_cert_validation="ignore" if use_ssl else "validate",
|
||||
)
|
||||
# Test connectivity with a simple command
|
||||
result = self._session.run_ps("$env:COMPUTERNAME")
|
||||
if result.status_code != 0:
|
||||
stderr = result.std_err.decode("utf-8", errors="replace").strip()
|
||||
if "access" in stderr.lower() or "denied" in stderr.lower():
|
||||
raise InsufficientPrivilegesError(
|
||||
"authenticate", stderr
|
||||
)
|
||||
raise AuthenticationError("windows", stderr or "Authentication test failed")
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except InsufficientPrivilegesError as exc:
|
||||
raise AuthenticationError("windows", str(exc)) from exc
|
||||
except WinRMNotEnabledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_msg = str(exc).lower()
|
||||
if "connection" in error_msg or "refused" in error_msg or "unreachable" in error_msg:
|
||||
raise WinRMNotEnabledError(host, str(exc)) from exc
|
||||
raise AuthenticationError("windows", str(exc)) from exc
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
"""Return PlatformCategory.WINDOWS."""
|
||||
return PlatformCategory.WINDOWS
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
"""Return the single Windows host as the endpoint."""
|
||||
return [self._host] if self._host else []
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
"""Return all 13 Windows resource types."""
|
||||
return list(WINDOWS_RESOURCE_TYPES)
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
"""Detect CPU architecture via WMI Win32_Processor query.
|
||||
|
||||
Args:
|
||||
endpoint: The Windows host to query.
|
||||
|
||||
Returns:
|
||||
CpuArchitecture enum value.
|
||||
|
||||
Raises:
|
||||
WMIQueryError: If the WMI query fails.
|
||||
"""
|
||||
query = "Get-WmiObject Win32_Processor | Select-Object -First 1 -ExpandProperty Architecture"
|
||||
result = self._run_powershell(query)
|
||||
|
||||
if result.status_code != 0:
|
||||
stderr = result.std_err.decode("utf-8", errors="replace").strip()
|
||||
raise WMIQueryError("Win32_Processor.Architecture", stderr)
|
||||
|
||||
arch_code = result.std_out.decode("utf-8", errors="replace").strip()
|
||||
|
||||
# WMI Architecture codes:
|
||||
# 0 = x86, 5 = ARM, 9 = x64, 12 = ARM64
|
||||
arch_map = {
|
||||
"0": CpuArchitecture.AMD64, # x86 mapped to amd64 for simplicity
|
||||
"5": CpuArchitecture.ARM,
|
||||
"9": CpuArchitecture.AMD64,
|
||||
"12": CpuArchitecture.AARCH64,
|
||||
}
|
||||
|
||||
return arch_map.get(arch_code, CpuArchitecture.AMD64)
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback: Callable[[ScanProgress], None],
|
||||
) -> ScanResult:
|
||||
"""Discover Windows resources via WinRM/PowerShell.
|
||||
|
||||
Args:
|
||||
endpoints: List of Windows hosts to scan.
|
||||
resource_types: List of resource type strings to discover.
|
||||
progress_callback: Callable for progress updates.
|
||||
|
||||
Returns:
|
||||
ScanResult with discovered resources, warnings, and errors.
|
||||
"""
|
||||
all_resources: list[DiscoveredResource] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
total_types = len(resource_types)
|
||||
|
||||
for endpoint in endpoints:
|
||||
# Detect architecture for this endpoint
|
||||
try:
|
||||
architecture = self.detect_architecture(endpoint)
|
||||
except (WMIQueryError, Exception) as exc:
|
||||
warnings.append(
|
||||
f"Could not detect architecture for {endpoint}: {exc}. "
|
||||
f"Defaulting to AMD64."
|
||||
)
|
||||
architecture = CpuArchitecture.AMD64
|
||||
|
||||
# Check if Hyper-V is installed (needed for hyperv resource types)
|
||||
hyperv_installed = self._is_hyperv_installed()
|
||||
|
||||
for idx, resource_type in enumerate(resource_types):
|
||||
try:
|
||||
# Skip Hyper-V resources if role not installed
|
||||
if resource_type in ("windows_hyperv_vm", "windows_hyperv_switch"):
|
||||
if not hyperv_installed:
|
||||
warnings.append(
|
||||
f"Skipping {resource_type}: Hyper-V role not installed on {endpoint}"
|
||||
)
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=resource_type,
|
||||
resources_discovered=len(all_resources),
|
||||
resource_types_completed=idx + 1,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
discovered = self._discover_resource_type(
|
||||
endpoint, resource_type, architecture
|
||||
)
|
||||
all_resources.extend(discovered)
|
||||
|
||||
except InsufficientPrivilegesError as exc:
|
||||
errors.append(
|
||||
f"Insufficient privileges for {resource_type} on {endpoint}: {exc}"
|
||||
)
|
||||
except WMIQueryError as exc:
|
||||
errors.append(
|
||||
f"WMI query failed for {resource_type} on {endpoint}: {exc}"
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(
|
||||
f"Error discovering {resource_type} on {endpoint}: {exc}"
|
||||
)
|
||||
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=resource_type,
|
||||
resources_discovered=len(all_resources),
|
||||
resource_types_completed=idx + 1,
|
||||
total_resource_types=total_types,
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=all_resources,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _run_powershell(self, script: str) -> winrm.Response:
|
||||
"""Execute a PowerShell script via WinRM.
|
||||
|
||||
Args:
|
||||
script: PowerShell script to execute.
|
||||
|
||||
Returns:
|
||||
winrm.Response object.
|
||||
|
||||
Raises:
|
||||
WinRMNotEnabledError: If the session is not established.
|
||||
"""
|
||||
if self._session is None:
|
||||
raise WinRMNotEnabledError(self._host, "No active WinRM session")
|
||||
return self._session.run_ps(script)
|
||||
|
||||
def _run_powershell_json(self, script: str) -> list[dict]:
|
||||
"""Execute a PowerShell script and parse JSON output.
|
||||
|
||||
The script should output ConvertTo-Json formatted data.
|
||||
|
||||
Args:
|
||||
script: PowerShell script that outputs JSON.
|
||||
|
||||
Returns:
|
||||
List of dicts parsed from JSON output.
|
||||
|
||||
Raises:
|
||||
WMIQueryError: If the command fails.
|
||||
InsufficientPrivilegesError: If access is denied.
|
||||
"""
|
||||
result = self._run_powershell(script)
|
||||
|
||||
if result.status_code != 0:
|
||||
stderr = result.std_err.decode("utf-8", errors="replace").strip()
|
||||
if "access" in stderr.lower() or "denied" in stderr.lower() or "privilege" in stderr.lower():
|
||||
raise InsufficientPrivilegesError(script, stderr)
|
||||
raise WMIQueryError(script, stderr)
|
||||
|
||||
stdout = result.std_out.decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
return data if isinstance(data, list) else []
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def _is_hyperv_installed(self) -> bool:
|
||||
"""Check if the Hyper-V role is installed on the target.
|
||||
|
||||
Returns:
|
||||
True if Hyper-V is installed, False otherwise.
|
||||
"""
|
||||
script = (
|
||||
"Get-WindowsFeature -Name Hyper-V | "
|
||||
"Select-Object -ExpandProperty Installed | "
|
||||
"ConvertTo-Json"
|
||||
)
|
||||
try:
|
||||
result = self._run_powershell(script)
|
||||
if result.status_code != 0:
|
||||
return False
|
||||
stdout = result.std_out.decode("utf-8", errors="replace").strip()
|
||||
return stdout.lower() == "true"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _discover_resource_type(
|
||||
self,
|
||||
endpoint: str,
|
||||
resource_type: str,
|
||||
architecture: CpuArchitecture,
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover resources of a specific type.
|
||||
|
||||
Args:
|
||||
endpoint: The Windows host.
|
||||
resource_type: The resource type to discover.
|
||||
architecture: Detected CPU architecture.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredResource objects.
|
||||
"""
|
||||
discovery_map = {
|
||||
"windows_service": self._discover_services,
|
||||
"windows_scheduled_task": self._discover_scheduled_tasks,
|
||||
"windows_iis_site": self._discover_iis_sites,
|
||||
"windows_iis_app_pool": self._discover_iis_app_pools,
|
||||
"windows_network_adapter": self._discover_network_adapters,
|
||||
"windows_firewall_rule": self._discover_firewall_rules,
|
||||
"windows_installed_software": self._discover_installed_software,
|
||||
"windows_feature": self._discover_windows_features,
|
||||
"windows_hyperv_vm": self._discover_hyperv_vms,
|
||||
"windows_hyperv_switch": self._discover_hyperv_switches,
|
||||
"windows_dns_record": self._discover_dns_records,
|
||||
"windows_local_user": self._discover_local_users,
|
||||
"windows_local_group": self._discover_local_groups,
|
||||
}
|
||||
|
||||
discover_fn = discovery_map.get(resource_type)
|
||||
if discover_fn is None:
|
||||
return []
|
||||
|
||||
return discover_fn(endpoint, architecture)
|
||||
|
||||
def _discover_services(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Windows services."""
|
||||
script = (
|
||||
"Get-Service | Select-Object Name, DisplayName, Status, StartType | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_service",
|
||||
unique_id=f"{endpoint}/service/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"display_name": item.get("DisplayName", ""),
|
||||
"status": str(item.get("Status", "")),
|
||||
"start_type": str(item.get("StartType", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_scheduled_tasks(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Windows scheduled tasks."""
|
||||
script = (
|
||||
"Get-ScheduledTask | Where-Object {$_.TaskPath -notlike '\\\\Microsoft\\\\*'} | "
|
||||
"Select-Object TaskName, TaskPath, State | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("TaskName", "")
|
||||
task_path = item.get("TaskPath", "\\")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_scheduled_task",
|
||||
unique_id=f"{endpoint}/scheduled_task/{task_path}{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"task_path": task_path,
|
||||
"state": str(item.get("State", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_iis_sites(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover IIS websites."""
|
||||
script = (
|
||||
"Import-Module WebAdministration; "
|
||||
"Get-Website | Select-Object Name, ID, State, PhysicalPath | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_iis_site",
|
||||
unique_id=f"{endpoint}/iis_site/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"site_id": str(item.get("ID", "")),
|
||||
"state": str(item.get("State", "")),
|
||||
"physical_path": item.get("PhysicalPath", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_iis_app_pools(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover IIS application pools."""
|
||||
script = (
|
||||
"Import-Module WebAdministration; "
|
||||
"Get-ChildItem IIS:\\AppPools | "
|
||||
"Select-Object Name, State, ManagedRuntimeVersion | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_iis_app_pool",
|
||||
unique_id=f"{endpoint}/iis_app_pool/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"state": str(item.get("State", "")),
|
||||
"managed_runtime_version": item.get("ManagedRuntimeVersion", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_network_adapters(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover network adapters."""
|
||||
script = (
|
||||
"Get-NetAdapter | Select-Object Name, InterfaceDescription, "
|
||||
"Status, MacAddress, LinkSpeed | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_network_adapter",
|
||||
unique_id=f"{endpoint}/network_adapter/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"interface_description": item.get("InterfaceDescription", ""),
|
||||
"status": str(item.get("Status", "")),
|
||||
"mac_address": item.get("MacAddress", ""),
|
||||
"link_speed": item.get("LinkSpeed", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_firewall_rules(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Windows firewall rules."""
|
||||
script = (
|
||||
"Get-NetFirewallRule | Where-Object {$_.Enabled -eq 'True'} | "
|
||||
"Select-Object Name, DisplayName, Direction, Action, Profile | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_firewall_rule",
|
||||
unique_id=f"{endpoint}/firewall_rule/{name}",
|
||||
name=item.get("DisplayName", name),
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"rule_name": name,
|
||||
"direction": str(item.get("Direction", "")),
|
||||
"action": str(item.get("Action", "")),
|
||||
"profile": str(item.get("Profile", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_installed_software(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover installed software via registry."""
|
||||
script = (
|
||||
"Get-ItemProperty HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\* | "
|
||||
"Where-Object {$_.DisplayName -ne $null} | "
|
||||
"Select-Object DisplayName, DisplayVersion, Publisher, InstallDate | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("DisplayName", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_installed_software",
|
||||
unique_id=f"{endpoint}/installed_software/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"version": item.get("DisplayVersion", ""),
|
||||
"publisher": item.get("Publisher", ""),
|
||||
"install_date": item.get("InstallDate", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_windows_features(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover installed Windows features."""
|
||||
script = (
|
||||
"Get-WindowsFeature | Where-Object {$_.Installed -eq $true} | "
|
||||
"Select-Object Name, DisplayName, FeatureType | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_feature",
|
||||
unique_id=f"{endpoint}/feature/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"display_name": item.get("DisplayName", ""),
|
||||
"feature_type": item.get("FeatureType", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_hyperv_vms(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Hyper-V virtual machines."""
|
||||
script = (
|
||||
"Get-VM | Select-Object Name, VMId, State, "
|
||||
"MemoryAssigned, ProcessorCount, Generation | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
vm_id = str(item.get("VMId", ""))
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_hyperv_vm",
|
||||
unique_id=f"{endpoint}/hyperv_vm/{vm_id}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"vm_id": vm_id,
|
||||
"state": str(item.get("State", "")),
|
||||
"memory_assigned": str(item.get("MemoryAssigned", "")),
|
||||
"processor_count": str(item.get("ProcessorCount", "")),
|
||||
"generation": str(item.get("Generation", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_hyperv_switches(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover Hyper-V virtual switches."""
|
||||
script = (
|
||||
"Get-VMSwitch | Select-Object Name, Id, SwitchType, "
|
||||
"NetAdapterInterfaceDescription | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
switch_id = str(item.get("Id", ""))
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_hyperv_switch",
|
||||
unique_id=f"{endpoint}/hyperv_switch/{switch_id}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"switch_id": switch_id,
|
||||
"switch_type": str(item.get("SwitchType", "")),
|
||||
"net_adapter": item.get("NetAdapterInterfaceDescription", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_dns_records(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover DNS records from local DNS server."""
|
||||
script = (
|
||||
"Get-DnsServerZone | ForEach-Object { "
|
||||
"Get-DnsServerResourceRecord -ZoneName $_.ZoneName "
|
||||
"-ErrorAction SilentlyContinue } | "
|
||||
"Select-Object HostName, RecordType, "
|
||||
"@{N='RecordData';E={$_.RecordData.IPv4Address.IPAddressToString}} | "
|
||||
"ConvertTo-Json -Depth 3"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
hostname = item.get("HostName", "")
|
||||
record_type = item.get("RecordType", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_dns_record",
|
||||
unique_id=f"{endpoint}/dns_record/{hostname}/{record_type}",
|
||||
name=f"{hostname} ({record_type})",
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"hostname": hostname,
|
||||
"record_type": record_type,
|
||||
"record_data": item.get("RecordData", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_local_users(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover local user accounts."""
|
||||
script = (
|
||||
"Get-LocalUser | Select-Object Name, Enabled, "
|
||||
"Description, LastLogon | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_local_user",
|
||||
unique_id=f"{endpoint}/local_user/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"enabled": str(item.get("Enabled", "")),
|
||||
"description": item.get("Description", ""),
|
||||
"last_logon": str(item.get("LastLogon", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
def _discover_local_groups(
|
||||
self, endpoint: str, architecture: CpuArchitecture
|
||||
) -> list[DiscoveredResource]:
|
||||
"""Discover local groups."""
|
||||
script = (
|
||||
"Get-LocalGroup | Select-Object Name, Description, SID | "
|
||||
"ConvertTo-Json -Depth 2"
|
||||
)
|
||||
items = self._run_powershell_json(script)
|
||||
resources = []
|
||||
for item in items:
|
||||
name = item.get("Name", "")
|
||||
resources.append(
|
||||
DiscoveredResource(
|
||||
resource_type="windows_local_group",
|
||||
unique_id=f"{endpoint}/local_group/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=architecture,
|
||||
endpoint=endpoint,
|
||||
attributes={
|
||||
"description": item.get("Description", ""),
|
||||
"sid": str(item.get("SID", "")),
|
||||
},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
Reference in New Issue
Block a user