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)