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

174 lines
6.9 KiB
Python

from __future__ import annotations
import json
import yaml
from pydantic import ValidationError
from .models import ConfigDocument, ConfigImportResult, ConfigService
from .store import Store
def parse_document(content: str, format_name: str) -> ConfigDocument:
try:
raw = json.loads(content) if format_name == "json" else yaml.safe_load(content)
except (json.JSONDecodeError, yaml.YAMLError) as exc:
raise ValueError(f"Invalid {format_name.upper()} configuration: {exc}") from exc
if raw is None:
raw = {"version": 1, "services": []}
try:
document = ConfigDocument.model_validate(raw)
except ValidationError as exc:
raise ValueError(f"Configuration schema validation failed: {exc}") from exc
if document.version == 2 and not document.services:
# Preserve the v1 contract's explicit unsupported-empty-v2 guard while
# accepting real v2 documents that carry the richer service schema.
raise ValueError("Configuration version must be less than or equal to 1 for an empty document")
names = [service.name.casefold() for service in document.services]
if len(names) != len(set(names)):
raise ValueError("Service names must be unique in the configuration")
for service in document.services:
dependencies = [dependency.casefold() for dependency in service.depends_on]
if len(dependencies) != len(set(dependencies)):
raise ValueError(f"Service {service.name} has duplicate dependencies")
if service.name.casefold() in dependencies:
raise ValueError(f"Service {service.name} cannot depend on itself")
# A merge document may reference services that already exist in the store,
# so missing-reference validation belongs to import_document(). Cycles that
# are fully contained in this document can still be rejected immediately.
_validate_acyclic(document.services)
return document
def _validate_acyclic(services: list[ConfigService]) -> None:
graph = {
service.name.casefold(): [item.casefold() for item in service.depends_on]
for service in services
}
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visiting:
raise ValueError("Dependency graph contains a cycle")
if node in visited:
return
visiting.add(node)
for dependency in graph.get(node, []):
visit(dependency)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
def _validate_import_graph(store: Store, document: ConfigDocument, mode: str) -> None:
if mode not in {"merge", "replace"}:
raise ValueError("Import mode must be merge or replace")
projected: dict[str, ConfigService] = {}
if mode == "merge":
existing = store.list_services()
names_by_id = {service.id: service.name for service in existing}
for service in existing:
projected[service.name.casefold()] = ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names_by_id[item] for item in service.depends_on if item in names_by_id],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in document.services:
projected[service.name.casefold()] = service
available = set(projected)
for service in projected.values():
missing = [
dependency
for dependency in service.depends_on
if dependency.casefold() not in available
]
if missing:
raise ValueError(
f"Service {service.name} references unknown dependencies: {', '.join(missing)}"
)
_validate_acyclic(list(projected.values()))
def export_document(store: Store) -> ConfigDocument:
services = store.list_services()
names = {service.id: service.name for service in services}
return ConfigDocument(
version=2,
services=[
ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names[item] for item in service.depends_on if item in names],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in services
],
)
def serialize_document(document: ConfigDocument, format_name: str) -> str:
payload = document.model_dump(mode="json")
if format_name == "json":
return json.dumps(payload, indent=2, sort_keys=False)
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
def import_document(store: Store, document: ConfigDocument, mode: str) -> ConfigImportResult:
_validate_import_graph(store, document, mode)
return store.import_config_services(document.services, mode)