Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
This commit is contained in:
commit
105251b9c8
96 files changed
+23587
No files matched your search
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
Reference in new issue
Block a user