Files
ServiceMap-Public/backend/app/docker_discovery.py
T

683 lines
31 KiB
Python

from __future__ import annotations
import os
import re
import socket
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import docker
import httpx
from docker.errors import DockerException
from .docker_provider import DockerProviderError, docker_client_for_inventory, docker_provider_mode
from .models import (
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryLifecycle,
DiscoveryVisibility,
DockerCandidate,
DockerDiscoveryResult,
DockerPort,
DockerSuggestion,
EvidenceScope,
ProbeEndpointCandidate,
ProbeEndpointSource,
ProbeReachability,
Service,
ServiceKind,
)
ALLOWED_SCHEMES = {"unix", "tcp", "http", "https"}
_RELATION_ENV = re.compile(r"(URL|HOST|HOSTNAME|ADDRESS|ENDPOINT|DSN|DATABASE|REDIS|POSTGRES|MYSQL|MONGO|BROKER|UPSTREAM|S3|STORAGE)", re.IGNORECASE)
_SECRET_ENV = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|BEARER)", re.IGNORECASE)
_SECRET_LABEL = re.compile(
r"(PASSWORD|PASSWD|SECRET|TOKEN|(?:API|ACCESS|PRIVATE|SIGNING|ENCRYPTION)[_-]?KEY|"
r"CREDENTIAL|AUTH(?:ORIZATION)?|BEARER|COOKIE)",
re.IGNORECASE,
)
_HTTP_CONTAINER_PORTS = {
80, 443, 3000, 3001, 5000, 6060, 8000, 8080, 8081, 8181, 8188,
8265, 8989, 9000, 9443, 9494, 11434,
}
@dataclass(frozen=True)
class AppOpsInfo:
app_id: str
category: str | None
criticality: str | None
health: str | None
display_name: str | None
discovery_confidence: float | None = None
policy_profile: str | None = None
app_type: str | None = None
reasons: tuple[str, ...] = ()
@dataclass(frozen=True)
class AppOpsInventory:
by_container: dict[str, AppOpsInfo]
available: bool
detail: str = ""
@dataclass(frozen=True)
class DockerHealthObservation:
"""The small, health-only subset needed by the recurring observer."""
container_id: str
name: str
stable_source_ref: str
state: str
health: str | None
healthcheck_present: bool
@dataclass(frozen=True)
class ProxyRoute:
domains: tuple[str, ...]
forward_host: str
forward_port: int
forward_scheme: str
def _normal_name(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
def stable_source_ref(name: str, labels: dict[str, str]) -> str:
project = labels.get("com.docker.compose.project", "").strip()
service = labels.get("com.docker.compose.service", "").strip()
if project and service:
return f"docker:compose:{_normal_name(project)}:{_normal_name(service)}"
return f"docker:name:{_normal_name(name)}"
def _legacy_source_ref(container_id: str, image_name: str) -> str:
return f"docker:{container_id[:12]}@{image_name}"
def _safe_labels(labels: dict[str, str]) -> dict[str, str]:
prefixes = ("servicemap.", "com.docker.compose.", "net.unraid.docker.", "org.opencontainers.image.")
return {
key: value
for key, value in labels.items()
if key.startswith(prefixes) and not _SECRET_LABEL.search(key)
}
def _classify(name: str, image: str, labels: dict[str, str], appops: AppOpsInfo | None = None) -> DiscoveryCategory:
explicit = labels.get("servicemap.category", "").strip().casefold()
if explicit in {item.value for item in DiscoveryCategory}:
return DiscoveryCategory(explicit)
# AppOps category is application metadata. It is helpful only when its
# discovery confidence is high enough; a low-confidence registry row must
# not override deterministic Docker or operator classification.
if appops and appops.category and (appops.discovery_confidence or 0) >= 0.75:
mapped = {
"database": DiscoveryCategory.DATABASE,
"database_admin": DiscoveryCategory.DATABASE,
"cache": DiscoveryCategory.CACHE,
"network": DiscoveryCategory.INFRASTRUCTURE,
"monitoring": DiscoveryCategory.MONITORING,
"media": DiscoveryCategory.APPLICATION,
"development": DiscoveryCategory.DEVELOPMENT,
"storage": DiscoveryCategory.STORAGE,
"infrastructure": DiscoveryCategory.INFRASTRUCTURE,
}.get(appops.category.casefold())
if mapped:
return mapped
value = f"{name} {image}".casefold()
if any(token in value for token in ("postgres", "mariadb", "mysql", "mongo", "qdrant")):
return DiscoveryCategory.DATABASE
if "redis" in value:
return DiscoveryCategory.CACHE
if any(token in value for token in ("nginx", "traefik", "caddy", "proxy")):
return DiscoveryCategory.PROXY
if any(token in value for token in ("prometheus", "grafana", "zabbix", "netdata", "cadvisor", "loki", "alloy")):
return DiscoveryCategory.MONITORING
if any(token in value for token in ("worker", "agent", "runner", "backup", "scheduler", "node")):
return DiscoveryCategory.BACKGROUND_WORKER
if any(token in value for token in ("portainer", "dockdeck", "unraid", "watchtower", "appops", "gitea")):
return DiscoveryCategory.INFRASTRUCTURE
if any(token in value for token in ("dev", "forge", "lab", "builder", "runner")):
return DiscoveryCategory.DEVELOPMENT
return DiscoveryCategory.APPLICATION
def _visibility(name: str, state: str, category: DiscoveryCategory, labels: dict[str, str]) -> DiscoveryVisibility:
if labels.get("servicemap.ignore", "false").casefold() == "true":
return DiscoveryVisibility.IGNORED
if state != "running":
return DiscoveryVisibility.HIDDEN
if category == DiscoveryCategory.SYSTEM:
return DiscoveryVisibility.IGNORED
if labels.get("servicemap.visible", "true").casefold() == "false":
return DiscoveryVisibility.HIDDEN
return DiscoveryVisibility.VISIBLE
def _kind_and_endpoint(
name: str,
ports: list[DockerPort],
labels: dict[str, str],
probe_host: str | None = None,
) -> tuple[ServiceKind, str | None, str | None, int | None, list[str]]:
"""Select only an endpoint ServiceMap can reach from its own network.
Docker's embedded DNS is scoped to a container network. A name such as
``postgres`` is useful discovery evidence but cannot become an active
probe merely because it exists in another project's network.
"""
probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip()
explicit_kind = labels.get("servicemap.probe", "").casefold()
explicit_port = labels.get("servicemap.port", "")
tcp_ports = [item for item in ports if item.container_port.endswith("/tcp")]
def container_port(item: DockerPort) -> int | None:
raw = item.container_port.split("/", 1)[0]
return int(raw) if raw.isdigit() else None
explicit_value = int(explicit_port) if explicit_port.isdigit() else None
selected = next((item for item in tcp_ports if container_port(item) == explicit_value), None)
if selected is None:
published = [item for item in tcp_ports if item.host_port is not None]
# The mapped host port is arbitrary. Select using the container's
# listener port so 8080 -> 8070 remains an HTTP candidate.
selected = next((item for item in published if container_port(item) in _HTTP_CONTAINER_PORTS), None)
selected = selected or (published[0] if published else (tcp_ports[0] if tcp_ports else None))
container_listener_port = container_port(selected) if selected else None
host_port = selected.host_port if selected else None
port = explicit_value or container_listener_port
evidence = ["container metadata"]
if explicit_kind in {"http", "tcp", "dns", "tls"}:
kind = ServiceKind(explicit_kind)
evidence.append("servicemap.probe label")
elif port in _HTTP_CONTAINER_PORTS:
kind = ServiceKind.HTTP
evidence.append("HTTP-like container port")
elif port:
kind = ServiceKind.TCP
evidence.append("published TCP port")
else:
return ServiceKind.TCP, None, name, None, evidence
if kind == ServiceKind.HTTP:
protocol = "https" if port in {443, 8443, 9443} else "http"
if host_port is not None and probe_host:
# HTTP services are represented by a URL in the canonical model;
# carrying the published port alongside it creates an invalid
# mixed endpoint payload during reconciliation.
return kind, f"{protocol}://{probe_host}:{host_port}", None, None, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
if kind == ServiceKind.DNS:
return kind, None, name, None, evidence
if host_port is not None and probe_host:
return kind, None, probe_host, host_port, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
def _preflight_candidate(candidate: ProbeEndpointCandidate) -> ProbeEndpointCandidate:
"""Verify only host-published endpoints before allowing active probes.
Discovery never probes Docker-internal DNS names and never scans paths: the
endpoint must already come from a Docker port mapping or explicit label.
"""
if candidate.source not in {
ProbeEndpointSource.PUBLISHED_PORT,
ProbeEndpointSource.EXPLICIT_LABEL,
}:
return candidate
parsed = urlparse(candidate.url) if candidate.url else None
hostname = parsed.hostname if parsed else candidate.host
port = parsed.port if parsed else candidate.port
if not hostname or not port:
return candidate.model_copy(update={"preflight_detail": "No host-published endpoint to preflight"})
started = time.perf_counter()
try:
with socket.create_connection((hostname, port), timeout=0.75):
pass
elapsed = round((time.perf_counter() - started) * 1000, 1)
if candidate.kind != ServiceKind.HTTP or not candidate.url:
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": "TCP connect succeeded",
"preflight_latency_ms": elapsed,
})
with httpx.Client(timeout=1.25, follow_redirects=False) as client:
response = client.request("HEAD", candidate.url)
if response.status_code == 405:
response = client.request("GET", candidate.url)
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": f"HTTP {response.status_code} responded",
"preflight_latency_ms": elapsed,
})
except (OSError, httpx.HTTPError) as exc:
return candidate.model_copy(update={
"reachability": ProbeReachability.UNVERIFIED,
"primary": False,
"preflight_detail": f"Preflight failed: {type(exc).__name__}",
})
def _probe_candidates(
suggestion: DockerSuggestion | None,
*,
healthcheck_present: bool,
) -> list[ProbeEndpointCandidate]:
candidates: list[ProbeEndpointCandidate] = []
if suggestion:
source = ProbeEndpointSource.EXPLICIT_LABEL if "servicemap.probe label" in suggestion.evidence else ProbeEndpointSource.PUBLISHED_PORT
reachability = ProbeReachability.UNVERIFIED if "host-published endpoint" in suggestion.evidence else ProbeReachability.INTERNAL_ONLY
candidates.append(
ProbeEndpointCandidate(
source=source if reachability == ProbeReachability.UNVERIFIED else ProbeEndpointSource.CONTAINER_INTERNAL,
kind=suggestion.kind,
url=suggestion.url,
host=suggestion.host,
port=suggestion.port,
reachability=reachability,
confidence=DiscoveryConfidence.MEDIUM if reachability == ProbeReachability.UNVERIFIED else DiscoveryConfidence.LOW,
evidence="; ".join(suggestion.evidence),
)
)
if healthcheck_present:
candidates.append(
ProbeEndpointCandidate(
source=ProbeEndpointSource.DOCKER_HEALTHCHECK,
kind=suggestion.kind if suggestion else ServiceKind.TCP,
reachability=ProbeReachability.VERIFIED,
confidence=DiscoveryConfidence.HIGH,
evidence="Docker healthcheck present (observed without a network probe)",
)
)
return candidates
def safe_environment_evidence(env: list[str]) -> list[str]:
evidence: list[str] = []
for entry in env:
name, separator, value = entry.partition("=")
if not separator or not value or _SECRET_ENV.search(name) or not _RELATION_ENV.search(name):
continue
try:
parsed = urlparse(value if "://" in value else f"//{value}")
hostname = parsed.hostname
except ValueError:
# Docker environments frequently contain comma-separated host
# lists or malformed IPv6-like values. Discovery evidence is
# best-effort; one invalid value must not abort the complete scan.
continue
if not hostname or hostname.casefold() in {"localhost", "127.0.0.1", "0.0.0.0"}:
continue
try:
port = parsed.port
except ValueError:
# A parseable hostname with a non-numeric suffix is still useful
# relation evidence; omit only the unsafe port component.
port = None
suffix = f":{port}" if port else ""
evidence.append(f"{name} hostname={hostname}{suffix}")
return sorted(set(evidence))
def _appops_inventory(appops_url: str | None) -> AppOpsInventory:
if not appops_url:
return AppOpsInventory({}, False, "AppOps URL is not configured")
base = appops_url.rstrip("/")
try:
mappings_response = httpx.get(f"{base}/api/inventory/mappings", timeout=5)
apps_response = httpx.get(f"{base}/api/inventory/apps", timeout=5)
mappings_response.raise_for_status()
apps_response.raise_for_status()
mappings = mappings_response.json()
apps = apps_response.json()
except (httpx.HTTPError, ValueError) as exc:
return AppOpsInventory({}, False, f"AppOps inventory unavailable: {type(exc).__name__}")
app_by_id = {str(item.get("id")): item for item in apps if isinstance(item, dict)}
result: dict[str, AppOpsInfo] = {}
for mapping in mappings if isinstance(mappings, list) else []:
if not isinstance(mapping, dict):
continue
container = str(mapping.get("containerName") or "")
app_id = str(mapping.get("appId") or "")
app = app_by_id.get(app_id, {})
try:
confidence = float(app.get("discovery_confidence")) if app.get("discovery_confidence") is not None else None
except (TypeError, ValueError):
confidence = None
if container:
result[container.casefold()] = AppOpsInfo(
app_id=app_id,
category=str(app.get("category")) if app.get("category") else None,
criticality=str(app.get("criticality")) if app.get("criticality") else None,
health=str(app.get("health_status")) if app.get("health_status") else None,
display_name=str(app.get("display_name")) if app.get("display_name") else None,
discovery_confidence=max(0.0, min(confidence, 1.0)) if confidence is not None else None,
policy_profile=str(app.get("policy_profile")) if app.get("policy_profile") else None,
app_type=str(app.get("app_type")) if app.get("app_type") else None,
reasons=tuple(str(item) for item in (app.get("discovery_reasons") or [])[:3]),
)
return AppOpsInventory(result, True, f"{len(result)} container mappings loaded")
def appops_inventory(appops_url: str | None) -> AppOpsInventory:
"""Read AppOps health inventory without triggering Docker discovery."""
return _appops_inventory(appops_url)
def observe_container_health(docker_host: str) -> list[DockerHealthObservation]:
"""Read bounded Docker runtime/health facts without discovery side effects.
This deliberately avoids endpoint preflight, relationship inference,
suggestion persistence, reconciliation and snapshot capture. It is safe
to run on the observation cadence because it cannot rewrite topology or
operator-owned configuration.
"""
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
client = None
try:
client = (
docker.DockerClient(base_url=docker_host, timeout=5)
if docker_provider_mode() == "socket"
else docker_client_for_inventory(docker_host)
)
client.ping()
observations: list[DockerHealthObservation] = []
for container in client.containers.list(all=True):
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
name = str(getattr(container, "name", "unknown")).lstrip("/")
observations.append(
DockerHealthObservation(
container_id=container.id[:12],
name=name,
stable_source_ref=stable_source_ref(name, raw_labels),
state=str(state.get("Status", getattr(container, "status", "unknown"))),
health=(
str(state.get("Health", {}).get("Status"))
if isinstance(state.get("Health"), dict) and state.get("Health", {}).get("Status") is not None
else None
),
healthcheck_present=bool(config.get("Healthcheck")),
)
)
return observations
except (DockerException, DockerProviderError) as exc:
raise RuntimeError(f"Docker observation unavailable: {type(exc).__name__}") from exc
finally:
close = getattr(client, "close", None)
if callable(close):
close()
def _appops_proxy_routes(appops_url: str | None) -> list[ProxyRoute]:
if not appops_url:
return []
try:
payload = httpx.get(f"{appops_url.rstrip('/')}/api/apps/npm/proxy-hosts", timeout=5).json()
except (httpx.HTTPError, ValueError):
return []
items = payload.get("items", []) if isinstance(payload, dict) else payload
routes: list[ProxyRoute] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict) or item.get("enabled") is False:
continue
domains = tuple(sorted({str(domain).strip() for domain in item.get("domainNames", []) if str(domain).strip()}))
host = str(item.get("forwardHost") or "").strip()
try:
port = int(item.get("forwardPort"))
except (TypeError, ValueError):
continue
scheme = str(item.get("forwardScheme") or "http").casefold()
if not domains or not host or not 1 <= port <= 65535 or scheme not in {"http", "https"}:
continue
routes.append(ProxyRoute(domains=domains, forward_host=host, forward_port=port, forward_scheme=scheme))
return routes
def _suggestions(
name: str,
networks: list[str],
ports: list[DockerPort],
labels: dict[str, str],
*,
image: str,
state: str,
appops: AppOpsInfo | None = None,
stable_ref: str,
probe_host: str | None = None,
) -> list[DockerSuggestion]:
if labels.get("servicemap.enabled", "true").casefold() == "false":
return []
kind, url, host, port, evidence = _kind_and_endpoint(name, ports, labels, probe_host)
depends = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()]
compose_depends = labels.get("com.docker.compose.depends_on", "")
if compose_depends:
depends.extend(item.split(":", 1)[0].strip() for item in compose_depends.split(",") if item.strip())
evidence.append("Compose depends_on metadata")
if depends:
evidence.append("explicit dependency metadata")
if appops:
confidence = f" ({appops.discovery_confidence:.2f})" if appops.discovery_confidence is not None else ""
evidence.append(f"AppOps application mapping{confidence}; application scope only")
evidence.extend(appops.reasons)
category = _classify(name, image, labels, appops)
return [
DockerSuggestion(
name=labels.get("servicemap.name", appops.display_name if appops and appops.display_name else name),
kind=kind,
host=host,
port=port,
url=url,
group=labels.get("servicemap.group") or (f"Compose · {labels.get('com.docker.compose.project')}" if labels.get("com.docker.compose.project") else category.value.replace("_", " ").title()),
evidence=sorted(set(evidence)),
confidence="high" if labels.get("servicemap.probe") or labels.get("servicemap.name") or compose_depends or (appops and (appops.discovery_confidence or 0) >= 0.75) else "medium",
depends_on_names=sorted(set(depends)),
stable_source_ref=stable_ref,
category=category,
visibility=_visibility(name, state, category, labels),
)
]
def discover_containers(
docker_host: str,
existing_services: list[Service] | None = None,
*,
include_stopped: bool = True,
appops_url: str | None = None,
probe_host: str | None = None,
) -> DockerDiscoveryResult:
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
# Keep the direct client import at this boundary so existing deployments
# and deterministic SDK-contract tests retain the socket behaviour. Broker
# mode is explicit and never falls back here.
client = docker.DockerClient(base_url=docker_host, timeout=5) if docker_provider_mode() == "socket" else docker_client_for_inventory(docker_host)
try:
client.ping()
containers = client.containers.list(all=include_stopped)
existing_services = existing_services or []
appops_inventory = _appops_inventory(appops_url)
appops = appops_inventory.by_container
configured_probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip() or None
proxy_routes = _appops_proxy_routes(appops_url)
candidates: list[DockerCandidate] = []
discovered_refs: set[str] = set()
for container in containers:
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
network_settings = attrs.get("NetworkSettings", {}) or {}
raw_ports = network_settings.get("Ports", {}) or {}
ports: list[DockerPort] = []
for container_port, bindings in raw_ports.items():
if not bindings:
ports.append(DockerPort(container_port=container_port))
continue
for binding in bindings:
raw_host_port = binding.get("HostPort")
host_port = str(raw_host_port) if raw_host_port is not None else ""
ports.append(
DockerPort(
container_port=container_port,
host_ip=binding.get("HostIp") or None,
host_port=int(host_port) if host_port.isdigit() else None,
)
)
image_tags = getattr(container.image, "tags", []) or []
image_name = image_tags[0] if image_tags else str(config.get("Image", "unknown"))
name = str(getattr(container, "name", "unknown")).lstrip("/")
health = state.get("Health", {}).get("Status") if isinstance(state.get("Health"), dict) else None
state_name = str(state.get("Status", getattr(container, "status", "unknown")))
networks = sorted((network_settings.get("Networks", {}) or {}).keys())
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
labels = _safe_labels(raw_labels)
stable_ref = stable_source_ref(name, raw_labels)
legacy_ref = _legacy_source_ref(container.id, image_name)
discovered_refs.add(stable_ref)
matching_source = next(
(
service
for service in existing_services
if service.source_ref in {stable_ref, legacy_ref}
or (service.source_ref and service.source_ref.removeprefix("docker:").startswith(container.id[:12]))
),
None,
)
matching_name = next((service for service in existing_services if service.name.casefold() == name.casefold()), None)
appops_info = appops.get(name.casefold())
category = _classify(name, image_name, raw_labels, appops_info)
visibility = _visibility(name, state_name, category, raw_labels)
if matching_source is not None:
import_state = "already imported" if matching_source.source_ref in {stable_ref, legacy_ref} else "changed"
elif matching_name is not None:
import_state = "already imported"
else:
import_state = "new"
suggestions = _suggestions(
name,
networks,
ports,
raw_labels,
image=image_name,
state=state_name,
appops=appops_info,
stable_ref=stable_ref,
probe_host=configured_probe_host,
)
for suggestion in suggestions:
suggestion.source_type = "docker"
suggestion.source_ref = legacy_ref
env_evidence = safe_environment_evidence([str(item) for item in (config.get("Env") or [])])
probe_candidates = _probe_candidates(
suggestions[0] if suggestions else None,
healthcheck_present=bool(config.get("Healthcheck")),
)
if state_name == "running":
probe_candidates = [_preflight_candidate(item) for item in probe_candidates]
candidates.append(
DockerCandidate(
container_id=container.id[:12],
name=name,
image=image_name,
state=state_name,
health=health,
networks=networks,
ports=ports,
labels=labels,
import_state=import_state,
suggestions=suggestions,
stable_source_ref=stable_ref,
compose_project=raw_labels.get("com.docker.compose.project"),
compose_service=raw_labels.get("com.docker.compose.service"),
category=category,
visibility=visibility,
lifecycle=DiscoveryLifecycle.ACTIVE if state_name == "running" else DiscoveryLifecycle.STALE,
healthcheck_present=bool(config.get("Healthcheck")),
environment_evidence=env_evidence,
proxy_route_evidence=[],
appops_app_id=appops_info.app_id if appops_info else None,
appops_category=appops_info.category if appops_info else None,
appops_criticality=appops_info.criticality if appops_info else None,
appops_health=appops_info.health if appops_info else None,
appops_scope=EvidenceScope.APPLICATION if appops_info else None,
appops_discovery_confidence=appops_info.discovery_confidence if appops_info else None,
appops_policy_profile=appops_info.policy_profile if appops_info else None,
appops_app_type=appops_info.app_type if appops_info else None,
probe_candidates=probe_candidates,
)
)
for candidate in candidates:
candidate.dependency_suggestions = sorted(
other.name
for other in candidates
if other.name != candidate.name and set(candidate.networks).intersection(other.networks)
)
# Reverse-proxy metadata is useful evidence, but it is never turned
# into a health dependency automatically. Persist only domains and the
# sanitized upstream tuple; AppOps metadata such as certificate or
# Let's Encrypt fields is intentionally discarded.
by_name = {candidate.name.casefold(): candidate for candidate in candidates}
by_published_port = {
port.host_port: candidate
for candidate in candidates
for port in candidate.ports
if port.host_port is not None
}
for route in proxy_routes:
target = by_name.get(route.forward_host.casefold()) or by_published_port.get(route.forward_port)
if target:
target.proxy_route_evidence.append(
f"Reverse proxy route: {', '.join(route.domains)} -> {route.forward_scheme}://{route.forward_host}:{route.forward_port}"
)
for service in existing_services:
if service.source_type != "docker" or not service.source_ref:
continue
if service.source_ref not in discovered_refs and service.lifecycle not in {DiscoveryLifecycle.RETIRED, DiscoveryLifecycle.IGNORED}:
candidates.append(
DockerCandidate(
container_id=service.source_ref.removeprefix("docker:")[:12],
name=service.name,
image=service.container_image or "not found",
state="missing",
health=None,
networks=[],
ports=[],
import_state="missing",
stable_source_ref=service.source_ref,
category=service.category,
visibility=service.visibility,
lifecycle=DiscoveryLifecycle.MISSING,
)
)
candidates.sort(key=lambda item: item.name.casefold())
return DockerDiscoveryResult(
docker_host=docker_host,
candidates=candidates,
proxy_routes=len(proxy_routes),
appops_available=appops_inventory.available,
appops_detail=appops_inventory.detail,
)
except (DockerException, DockerProviderError) as exc:
raise ValueError(f"Docker discovery failed: {exc}") from exc
finally:
client.close()