174 lines
6.9 KiB
Python
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)
|