Created IAC reverse generator

This commit is contained in:
p2913020
2026-05-22 00:19:30 -04:00
parent d04c2c6e4b
commit 1a11244fff
161 changed files with 26806 additions and 51 deletions

View 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",
]

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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