Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,6 @@
|
||||
"""Declarative catalog validation and reconciliation boundaries."""
|
||||
|
||||
from .loader import CatalogLoader
|
||||
from .reconciler import CatalogReconciler
|
||||
|
||||
__all__ = ["CatalogLoader", "CatalogReconciler"]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Catalog file loading, JSON Schema validation, and safe normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import yaml # type: ignore[import-untyped]
|
||||
from jsonschema import Draft202012Validator, RefResolver # type: ignore[import-untyped]
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from services.control_api.contextforge.models import GatewayDesiredState
|
||||
|
||||
from .models import CatalogDocument, CatalogIssue, CatalogSource, CatalogState, ValidationReport
|
||||
|
||||
SCHEMA_BY_DIRECTORY = {
|
||||
"workspaces": "workspace.schema.json",
|
||||
"projects": "project.schema.json",
|
||||
"connectors": "connector-manifest.schema.json",
|
||||
"capability-packs": "capability-pack.schema.json",
|
||||
"policies": "policy.schema.json",
|
||||
"prompts": "prompt-manifest.schema.json",
|
||||
"resources": "resource-manifest.schema.json",
|
||||
"tools": "tool-manifest.schema.json",
|
||||
}
|
||||
ALLOWED_REFERENCE = re.compile(
|
||||
r"^(?:docker-secret|service-identity|authentik-client|config)://[^\s]+$"
|
||||
)
|
||||
SENSITIVE_KEY = re.compile(
|
||||
r"(?:password|secret|token|private[_-]?key|credential|authorization|cookie)", re.I
|
||||
)
|
||||
|
||||
|
||||
class _Envelope(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
apiVersion: str
|
||||
kind: str
|
||||
metadata: dict[str, Any]
|
||||
spec: dict[str, Any]
|
||||
|
||||
|
||||
def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def _pointer(parts: list[Any]) -> str:
|
||||
escaped = [str(part).replace("~", "~0").replace("/", "~1") for part in parts]
|
||||
return "/" + "/".join(escaped) if escaped else "/"
|
||||
|
||||
|
||||
class CatalogLoader:
|
||||
"""Loads only the configured catalog root; caller-controlled paths are rejected."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
catalog_root: Path,
|
||||
*,
|
||||
schema_root: Path | None = None,
|
||||
public_base_url: str = "https://mcp.example.com",
|
||||
) -> None:
|
||||
self.root = catalog_root.resolve()
|
||||
self.schema_root = (schema_root or self.root.parent / "contracts" / "schemas").resolve()
|
||||
self.public_base_url = str(TypeAdapter(AnyHttpUrl).validate_python(public_base_url)).rstrip(
|
||||
"/"
|
||||
)
|
||||
|
||||
def _issue(self, code: str, message: str, path: Path, pointer: str = "/") -> CatalogIssue:
|
||||
return CatalogIssue(
|
||||
code=code,
|
||||
message=message,
|
||||
source=CatalogSource(file=path.relative_to(self.root).as_posix(), pointer=pointer),
|
||||
)
|
||||
|
||||
def _secret_issues(
|
||||
self, value: Any, path: Path, pointer_parts: list[Any], key: str | None = None
|
||||
) -> list[CatalogIssue]:
|
||||
issues: list[CatalogIssue] = []
|
||||
pointer = _pointer(pointer_parts)
|
||||
if isinstance(value, dict):
|
||||
for child_key, child_value in value.items():
|
||||
issues.extend(
|
||||
self._secret_issues(
|
||||
child_value, path, [*pointer_parts, child_key], str(child_key)
|
||||
)
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
for index, child_value in enumerate(value):
|
||||
issues.extend(self._secret_issues(child_value, path, [*pointer_parts, index], key))
|
||||
elif isinstance(value, str):
|
||||
parsed = urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
issues.append(
|
||||
self._issue(
|
||||
"secret_value", "catalog URLs must not contain userinfo", path, pointer
|
||||
)
|
||||
)
|
||||
if (
|
||||
key
|
||||
and SENSITIVE_KEY.search(key)
|
||||
and value
|
||||
and not ALLOWED_REFERENCE.fullmatch(value)
|
||||
):
|
||||
issues.append(
|
||||
self._issue(
|
||||
"secret_value",
|
||||
"secret-bearing fields must contain a reference URI, not a value",
|
||||
path,
|
||||
pointer,
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
def _safe_payload(self, value: Any, key: str | None = None) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(child_key): self._safe_payload(child_value, str(child_key))
|
||||
for child_key, child_value in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [self._safe_payload(child_value, key) for child_value in value]
|
||||
if isinstance(value, str):
|
||||
if key and SENSITIVE_KEY.search(key) and not ALLOWED_REFERENCE.fullmatch(value):
|
||||
return "<redacted>"
|
||||
parsed = urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
return "<redacted-url>"
|
||||
return value
|
||||
|
||||
def _schema(self, schema_name: str) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any], json.loads((self.schema_root / schema_name).read_text(encoding="utf-8"))
|
||||
)
|
||||
|
||||
def _schema_store(self) -> dict[str, Any]:
|
||||
store: dict[str, Any] = {}
|
||||
for schema_path in self.schema_root.glob("*.schema.json"):
|
||||
schema = cast(dict[str, Any], json.loads(schema_path.read_text(encoding="utf-8")))
|
||||
schema_id = schema.get("$id")
|
||||
if isinstance(schema_id, str):
|
||||
store[schema_id] = schema
|
||||
return store
|
||||
|
||||
def load(self) -> ValidationReport:
|
||||
issues: list[CatalogIssue] = []
|
||||
documents: list[CatalogDocument] = []
|
||||
source_files: list[str] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
if not self.root.is_dir():
|
||||
return ValidationReport(
|
||||
valid=False,
|
||||
issues=(
|
||||
self._issue("catalog_root_missing", "catalog root does not exist", self.root),
|
||||
),
|
||||
)
|
||||
for path in sorted(self.root.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in {".yaml", ".yml", ".json"}:
|
||||
continue
|
||||
relative = path.relative_to(self.root)
|
||||
directory = relative.parts[0] if relative.parts else ""
|
||||
schema_name = SCHEMA_BY_DIRECTORY.get(directory)
|
||||
if schema_name is None:
|
||||
issues.append(
|
||||
self._issue(
|
||||
"unsupported_catalog_path",
|
||||
"catalog file is outside a supported catalog directory",
|
||||
path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
source_files.append(relative.as_posix())
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
issues.append(
|
||||
self._issue(
|
||||
"parse_error",
|
||||
f"catalog document could not be parsed: {type(exc).__name__}",
|
||||
path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
issues.append(
|
||||
self._issue("document_type", "catalog document must be an object", path)
|
||||
)
|
||||
continue
|
||||
issues.extend(self._secret_issues(raw, path, []))
|
||||
schema = self._schema(schema_name)
|
||||
resolver = RefResolver.from_schema(schema, store=self._schema_store())
|
||||
for error in sorted(
|
||||
Draft202012Validator(schema, resolver=resolver).iter_errors(raw),
|
||||
key=lambda item: list(item.path),
|
||||
):
|
||||
issues.append(
|
||||
self._issue(
|
||||
"schema_error",
|
||||
error.message[:480],
|
||||
path,
|
||||
_pointer(list(error.absolute_path)),
|
||||
)
|
||||
)
|
||||
try:
|
||||
envelope = _Envelope.model_validate(raw)
|
||||
metadata = envelope.metadata
|
||||
slug = metadata.get("slug")
|
||||
name = metadata.get("name")
|
||||
if not isinstance(slug, str) or not isinstance(name, str):
|
||||
raise ValueError("metadata.name and metadata.slug are required")
|
||||
identity = (envelope.kind, slug)
|
||||
if identity in seen:
|
||||
issues.append(
|
||||
self._issue(
|
||||
"duplicate_identity",
|
||||
f"duplicate catalog identity {envelope.kind}/{slug}",
|
||||
path,
|
||||
"/metadata/slug",
|
||||
)
|
||||
)
|
||||
seen.add(identity)
|
||||
documents.append(
|
||||
CatalogDocument(
|
||||
kind=envelope.kind,
|
||||
name=name,
|
||||
slug=slug,
|
||||
payload=self._safe_payload(raw),
|
||||
source=CatalogSource(file=relative.as_posix()),
|
||||
)
|
||||
)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
issues.append(self._issue("typed_validation", str(exc).splitlines()[0][:480], path))
|
||||
issues.extend(self._cross_reference_issues(documents))
|
||||
if issues:
|
||||
return ValidationReport(
|
||||
valid=False,
|
||||
documents=tuple(sorted(documents, key=lambda item: (item.kind, item.slug))),
|
||||
issues=tuple(issues),
|
||||
source_files=tuple(source_files),
|
||||
)
|
||||
documents_tuple = tuple(sorted(documents, key=lambda item: (item.kind, item.slug)))
|
||||
gateways = self._gateway_desired(documents_tuple)
|
||||
gateway_slugs = [gateway.slug for gateway in gateways]
|
||||
if len(gateway_slugs) != len(set(gateway_slugs)):
|
||||
duplicate = next(slug for slug in gateway_slugs if gateway_slugs.count(slug) > 1)
|
||||
issue = self._issue(
|
||||
"duplicate_gateway_identity",
|
||||
f"duplicate virtual server gateway identity {duplicate}",
|
||||
self.root / documents_tuple[0].source.file,
|
||||
"/spec/virtualServers",
|
||||
)
|
||||
return ValidationReport(
|
||||
valid=False,
|
||||
documents=documents_tuple,
|
||||
issues=(issue,),
|
||||
source_files=tuple(source_files),
|
||||
)
|
||||
desired_material = {
|
||||
"documents": [document.payload for document in documents_tuple],
|
||||
"gateways": [item.model_dump(mode="json") for item in gateways],
|
||||
}
|
||||
desired_hash = hashlib.sha256(_canonical(desired_material).encode("utf-8")).hexdigest()
|
||||
return ValidationReport(
|
||||
valid=True,
|
||||
desired_hash=desired_hash,
|
||||
documents=documents_tuple,
|
||||
source_files=tuple(source_files),
|
||||
)
|
||||
|
||||
def state(self) -> CatalogState:
|
||||
report = self.load()
|
||||
if not report.valid or report.desired_hash is None:
|
||||
raise ValueError(
|
||||
"catalog is invalid: " + "; ".join(issue.message for issue in report.issues[:5])
|
||||
)
|
||||
gateways = self._gateway_desired(report.documents)
|
||||
return CatalogState(
|
||||
documents=report.documents,
|
||||
gateways=gateways,
|
||||
desired_hash=report.desired_hash,
|
||||
source_files=report.source_files,
|
||||
)
|
||||
|
||||
def _cross_reference_issues(self, documents: list[CatalogDocument]) -> list[CatalogIssue]:
|
||||
known = {(document.kind, document.slug) for document in documents}
|
||||
issues: list[CatalogIssue] = []
|
||||
for document in documents:
|
||||
if document.kind != "Project":
|
||||
continue
|
||||
spec = document.payload["spec"]
|
||||
for field, kind in (
|
||||
("capabilityPacks", "CapabilityPack"),
|
||||
("connectorBindings", "ConnectorTemplate"),
|
||||
):
|
||||
values = spec.get(field, [])
|
||||
refs = [
|
||||
value
|
||||
if isinstance(value, str)
|
||||
else value.get("ref")
|
||||
if isinstance(value, dict)
|
||||
else None
|
||||
for value in values
|
||||
]
|
||||
for ref in refs:
|
||||
if isinstance(ref, str) and (kind, ref) not in known:
|
||||
issues.append(
|
||||
self._issue(
|
||||
"missing_reference",
|
||||
f"{field} references unknown {kind}/{ref}",
|
||||
self.root / document.source.file,
|
||||
f"/spec/{field}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
def _gateway_desired(
|
||||
self, documents: tuple[CatalogDocument, ...]
|
||||
) -> tuple[GatewayDesiredState, ...]:
|
||||
projects = [document for document in documents if document.kind == "Project"]
|
||||
result: list[GatewayDesiredState] = []
|
||||
for project in projects:
|
||||
spec = project.payload["spec"]
|
||||
for virtual_server in spec["virtualServers"]:
|
||||
endpoint = virtual_server["endpointAlias"]
|
||||
url = f"{self.public_base_url}{endpoint}"
|
||||
result.append(
|
||||
GatewayDesiredState.model_validate(
|
||||
{
|
||||
"slug": virtual_server["slug"],
|
||||
"url": url,
|
||||
"description": (
|
||||
f"{project.name} {virtual_server['environment']} virtual server"
|
||||
),
|
||||
"transport": "STREAMABLEHTTP",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
return tuple(sorted(result, key=lambda item: item.slug))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Typed, secret-safe catalog and reconciliation contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from services.control_api.contextforge.models import GatewayDesiredState, GatewaySnapshot
|
||||
|
||||
|
||||
class CatalogSource(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
file: str = Field(min_length=1, max_length=512)
|
||||
pointer: str = Field(default="/", max_length=512)
|
||||
|
||||
|
||||
class CatalogIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
message: str = Field(min_length=1, max_length=512)
|
||||
source: CatalogSource
|
||||
|
||||
|
||||
class CatalogDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
kind: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
slug: str = Field(min_length=1, max_length=80)
|
||||
payload: dict[str, Any]
|
||||
source: CatalogSource
|
||||
|
||||
|
||||
class CatalogState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
documents: tuple[CatalogDocument, ...]
|
||||
gateways: tuple[GatewayDesiredState, ...]
|
||||
desired_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_files: tuple[str, ...]
|
||||
|
||||
|
||||
class ValidationReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
valid: bool
|
||||
desired_hash: str | None = None
|
||||
documents: tuple[CatalogDocument, ...] = ()
|
||||
issues: tuple[CatalogIssue, ...] = ()
|
||||
source_files: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class PlanAction(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
action: Literal["create", "update", "disable", "noop"]
|
||||
slug: str = Field(min_length=1, max_length=63)
|
||||
desired: GatewayDesiredState
|
||||
current: GatewaySnapshot | None = None
|
||||
managed: bool = False
|
||||
reason: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class DriftItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
slug: str = Field(min_length=1, max_length=256)
|
||||
kind: Literal["unknown_gateway", "managed_gateway", "missing_gateway"]
|
||||
detail: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class ReconcilePlan(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
desired_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
actions: tuple[PlanAction, ...]
|
||||
drift: tuple[DriftItem, ...]
|
||||
source_files: tuple[str, ...]
|
||||
|
||||
|
||||
class ReconcileRunView(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
desired_hash: str
|
||||
current_hash: str | None
|
||||
plan_hash: str | None
|
||||
status: str
|
||||
planned_changes: tuple[dict[str, Any], ...]
|
||||
applied_changes: tuple[dict[str, Any], ...]
|
||||
verification: dict[str, Any]
|
||||
revision: int
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Deterministic gateway diff and drift planning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
|
||||
from services.control_api.contextforge.models import GatewayDesiredState, GatewaySnapshot
|
||||
|
||||
from .models import CatalogState, DriftItem, PlanAction, ReconcilePlan
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def _same(current: GatewaySnapshot, desired: GatewayDesiredState) -> bool:
|
||||
value = desired.model_dump(mode="json")
|
||||
return (
|
||||
current.slug == value["slug"]
|
||||
and current.url.rstrip("/") == str(value["url"]).rstrip("/")
|
||||
and current.description == value["description"]
|
||||
and current.transport == value["transport"]
|
||||
and current.enabled == value["enabled"]
|
||||
)
|
||||
|
||||
|
||||
def build_plan(
|
||||
state: CatalogState, current: Iterable[GatewaySnapshot], managed_slugs: set[str]
|
||||
) -> ReconcilePlan:
|
||||
current_by_slug = {item.slug: item for item in current}
|
||||
desired_by_slug = {item.slug: item for item in state.gateways}
|
||||
actions: list[PlanAction] = []
|
||||
for desired in state.gateways:
|
||||
snapshot = current_by_slug.get(desired.slug)
|
||||
if snapshot is None:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
action="create",
|
||||
slug=desired.slug,
|
||||
desired=desired,
|
||||
reason="desired gateway is absent",
|
||||
)
|
||||
)
|
||||
elif _same(snapshot, desired):
|
||||
actions.append(
|
||||
PlanAction(
|
||||
action="noop",
|
||||
slug=desired.slug,
|
||||
desired=desired,
|
||||
current=snapshot,
|
||||
managed=True,
|
||||
reason="desired gateway already matches",
|
||||
)
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
action="update",
|
||||
slug=desired.slug,
|
||||
desired=desired,
|
||||
current=snapshot,
|
||||
managed=True,
|
||||
reason="gateway differs from desired state",
|
||||
)
|
||||
)
|
||||
for slug in sorted(set(current_by_slug) - set(desired_by_slug)):
|
||||
snapshot = current_by_slug[slug]
|
||||
if slug in managed_slugs:
|
||||
disabled = desired_by_slug.get(slug)
|
||||
if disabled is None:
|
||||
disabled = GatewayDesiredState.model_validate(
|
||||
{
|
||||
"slug": slug,
|
||||
"url": snapshot.url,
|
||||
"description": snapshot.description,
|
||||
"transport": snapshot.transport,
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
actions.append(
|
||||
PlanAction(
|
||||
action="disable",
|
||||
slug=slug,
|
||||
desired=disabled,
|
||||
current=snapshot,
|
||||
managed=True,
|
||||
reason="managed gateway is no longer declared",
|
||||
)
|
||||
)
|
||||
drift = tuple(
|
||||
DriftItem(
|
||||
slug=slug,
|
||||
kind="unknown_gateway",
|
||||
detail="gateway exists outside the catalog and will not be deleted",
|
||||
)
|
||||
for slug in sorted(set(current_by_slug) - set(desired_by_slug) - managed_slugs)
|
||||
)
|
||||
action_material = [
|
||||
{
|
||||
"action": action.action,
|
||||
"slug": action.slug,
|
||||
"desired": action.desired.model_dump(mode="json"),
|
||||
"current": action.current.model_dump(mode="json") if action.current else None,
|
||||
"managed": action.managed,
|
||||
}
|
||||
for action in actions
|
||||
]
|
||||
plan_hash = hashlib.sha256(
|
||||
_canonical(
|
||||
{
|
||||
"desired_hash": state.desired_hash,
|
||||
"actions": action_material,
|
||||
"drift": [item.model_dump(mode="json") for item in drift],
|
||||
}
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return ReconcilePlan(
|
||||
desired_hash=state.desired_hash,
|
||||
plan_hash=plan_hash,
|
||||
actions=tuple(actions),
|
||||
drift=drift,
|
||||
source_files=state.source_files,
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Safe, resumable catalog plan/apply/verify orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from packages.hub_core.domain import ReconcileRun
|
||||
from services.control_api.contextforge.client import GatewayAdapter
|
||||
|
||||
from .loader import CatalogLoader
|
||||
from .models import CatalogState, ReconcilePlan, ReconcileRunView, ValidationReport
|
||||
from .planner import build_plan
|
||||
from .store import MemoryReconcileRunStore, now
|
||||
|
||||
|
||||
class ReconciliationLock:
|
||||
"""Process-local fallback lock; Redis may be unavailable or evicted without data loss."""
|
||||
|
||||
_locks: dict[str, RLock] = {}
|
||||
_registry_lock = RLock()
|
||||
|
||||
def __init__(self, key: str) -> None:
|
||||
with self._registry_lock:
|
||||
self._lock = self._locks.setdefault(key, RLock())
|
||||
|
||||
def __enter__(self) -> ReconciliationLock:
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self._lock.release()
|
||||
|
||||
|
||||
class CatalogReconciler:
|
||||
def __init__(
|
||||
self, loader: CatalogLoader, adapter: GatewayAdapter, *, store: Any | None = None
|
||||
) -> None:
|
||||
self.loader = loader
|
||||
self.adapter = adapter
|
||||
self.store = store or MemoryReconcileRunStore()
|
||||
self._managed_slugs: set[str] = set()
|
||||
|
||||
def validate(self) -> ValidationReport:
|
||||
return self.loader.load()
|
||||
|
||||
def _state_and_plan(self) -> tuple[CatalogState, ReconcilePlan]:
|
||||
state = self.loader.state()
|
||||
current = self.adapter.list_gateways(limit=100)
|
||||
known = self._known_managed_slugs()
|
||||
return state, build_plan(state, current, known)
|
||||
|
||||
def _known_managed_slugs(self) -> set[str]:
|
||||
known = set(self._managed_slugs)
|
||||
provider = getattr(self.store, "managed_slugs", None)
|
||||
if callable(provider):
|
||||
known.update(provider())
|
||||
return known
|
||||
|
||||
def preview(self) -> ReconcilePlan:
|
||||
with ReconciliationLock("catalog-preview"):
|
||||
return self._state_and_plan()[1]
|
||||
|
||||
def _view(self, run: ReconcileRun) -> ReconcileRunView:
|
||||
return ReconcileRunView(
|
||||
id=run.id,
|
||||
workspace_id=run.workspace_id,
|
||||
project_id=run.project_id,
|
||||
desired_hash=run.desired_hash,
|
||||
current_hash=run.current_hash,
|
||||
plan_hash=run.plan_hash,
|
||||
status=run.status,
|
||||
planned_changes=tuple(run.planned_changes),
|
||||
applied_changes=tuple(run.applied_changes),
|
||||
verification=run.verification,
|
||||
revision=run.revision,
|
||||
)
|
||||
|
||||
def _save(self, run: ReconcileRun, **changes: Any) -> ReconcileRun:
|
||||
updated = run.model_copy(
|
||||
update={"updated_at": now(), "revision": run.revision + 1, **changes}
|
||||
)
|
||||
return self.store.save(updated)
|
||||
|
||||
def apply(self, workspace_id: UUID, project_id: UUID) -> ReconcileRunView:
|
||||
with ReconciliationLock(f"catalog-apply:{workspace_id}:{project_id}"):
|
||||
report = self.validate()
|
||||
if not report.valid or report.desired_hash is None:
|
||||
run = ReconcileRun(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
desired_hash=report.desired_hash or "0" * 64,
|
||||
status="invalid",
|
||||
planned_changes=[],
|
||||
applied_changes=[],
|
||||
verification={
|
||||
"issues": [issue.model_dump(mode="json") for issue in report.issues]
|
||||
},
|
||||
created_at=now(),
|
||||
updated_at=now(),
|
||||
)
|
||||
return self._view(self.store.create(run))
|
||||
state, plan = self._state_and_plan()
|
||||
run = ReconcileRun(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
desired_hash=state.desired_hash,
|
||||
plan_hash=plan.plan_hash,
|
||||
status="created",
|
||||
planned_changes=[self._safe_action(action) for action in plan.actions],
|
||||
applied_changes=[],
|
||||
verification={"drift": [item.model_dump(mode="json") for item in plan.drift]},
|
||||
started_at=now(),
|
||||
created_at=now(),
|
||||
updated_at=now(),
|
||||
)
|
||||
run = self.store.create(run)
|
||||
run = self._save(run, status="planning")
|
||||
run = self._save(run, status="planned", current_hash=self._current_hash(plan))
|
||||
if not any(action.action != "noop" for action in plan.actions):
|
||||
run = self._save(run, status="verifying")
|
||||
verification = self._verify(state, plan)
|
||||
run = self._save(
|
||||
run,
|
||||
status="succeeded" if verification["valid"] else "failed",
|
||||
verification=verification,
|
||||
completed_at=now(),
|
||||
)
|
||||
return self._view(run)
|
||||
run = self._save(run, status="applying")
|
||||
applied: list[dict[str, Any]] = []
|
||||
try:
|
||||
for action in plan.actions:
|
||||
if action.action == "noop":
|
||||
continue
|
||||
self.adapter.ensure_gateway(action.desired)
|
||||
self._managed_slugs.add(action.slug)
|
||||
applied.append(
|
||||
{
|
||||
"action": action.action,
|
||||
"slug": action.slug,
|
||||
"managed": True,
|
||||
"status": "applied",
|
||||
}
|
||||
)
|
||||
run = self._save(run, applied_changes=applied)
|
||||
except Exception as exc: # adapter errors are intentionally sanitized
|
||||
failure = {
|
||||
"action": action.action,
|
||||
"slug": action.slug,
|
||||
"managed": True,
|
||||
"status": "failed",
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
run = self._save(
|
||||
run,
|
||||
applied_changes=[*applied, failure],
|
||||
verification={"valid": False, "resumable": True, "error": type(exc).__name__},
|
||||
status="failed",
|
||||
completed_at=now(),
|
||||
)
|
||||
return self._view(run)
|
||||
run = self._save(run, status="verifying")
|
||||
verification = self._verify(state, plan)
|
||||
run = self._save(
|
||||
run,
|
||||
status="succeeded" if verification["valid"] else "failed",
|
||||
verification=verification,
|
||||
completed_at=now(),
|
||||
)
|
||||
return self._view(run)
|
||||
|
||||
def _verify(self, state: CatalogState, plan: ReconcilePlan) -> dict[str, Any]:
|
||||
current = self.adapter.list_gateways(limit=100)
|
||||
actual = {item.slug: item for item in current}
|
||||
missing = sorted(
|
||||
item.slug
|
||||
for item in state.gateways
|
||||
if item.slug not in actual or actual[item.slug].enabled is not True
|
||||
)
|
||||
mismatched = sorted(
|
||||
item.slug
|
||||
for item in state.gateways
|
||||
if item.slug in actual
|
||||
and actual[item.slug].enabled
|
||||
and actual[item.slug].url.rstrip("/") != str(item.url).rstrip("/")
|
||||
)
|
||||
return {
|
||||
"valid": not missing and not mismatched,
|
||||
"missing": missing,
|
||||
"mismatched": mismatched,
|
||||
"drift": [item.model_dump(mode="json") for item in plan.drift],
|
||||
"resumable": bool(missing or mismatched),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _current_hash(plan: ReconcilePlan) -> str:
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
[
|
||||
action.current.model_dump(mode="json")
|
||||
for action in plan.actions
|
||||
if action.current
|
||||
],
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _safe_action(action: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"action": action.action,
|
||||
"slug": action.slug,
|
||||
"managed": action.managed,
|
||||
"reason": action.reason,
|
||||
"desired": action.desired.model_dump(mode="json"),
|
||||
"current": action.current.model_dump(mode="json") if action.current else None,
|
||||
}
|
||||
|
||||
def status(self, run_id: UUID) -> ReconcileRunView | None:
|
||||
run = self.store.get(run_id)
|
||||
return self._view(run) if run else None
|
||||
|
||||
def drift(self) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(item.model_dump(mode="json") for item in self.preview().drift)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Authenticated management API boundary for catalog operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .models import ReconcilePlan, ReconcileRunView, ValidationReport
|
||||
from .reconciler import CatalogReconciler
|
||||
|
||||
|
||||
def _deny_by_default(_: Request) -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="management authorization required"
|
||||
)
|
||||
|
||||
|
||||
class ScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
|
||||
|
||||
def build_catalog_router(
|
||||
reconciler: CatalogReconciler, *, authorize: Callable[[Request], Any] = _deny_by_default
|
||||
) -> APIRouter:
|
||||
def authorize_request(request: Request) -> None:
|
||||
authorize(request)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/catalog", tags=["catalog"], dependencies=[Depends(authorize_request)]
|
||||
)
|
||||
|
||||
@router.post("/validate", response_model=ValidationReport)
|
||||
def validate() -> ValidationReport:
|
||||
return reconciler.validate()
|
||||
|
||||
@router.post("/preview", response_model=ReconcilePlan)
|
||||
def preview() -> ReconcilePlan:
|
||||
try:
|
||||
return reconciler.preview()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/apply", response_model=ReconcileRunView)
|
||||
def apply(request: ScopeRequest) -> ReconcileRunView:
|
||||
return reconciler.apply(request.workspace_id, request.project_id)
|
||||
|
||||
@router.get("/status/{run_id}", response_model=ReconcileRunView)
|
||||
def status_for_run(run_id: UUID) -> ReconcileRunView:
|
||||
result = reconciler.status(run_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="reconcile run not found")
|
||||
return result
|
||||
|
||||
@router.get("/drift")
|
||||
def drift() -> dict[str, Any]:
|
||||
return {"items": reconciler.drift()}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Reconcile-run stores: in-memory for tests and Hub PostgreSQL/SQLite persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from threading import RLock
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.hub_core.domain import ReconcileRun, Scope
|
||||
from services.control_api.persistence.repositories import ReconcileRunRepository
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class MemoryReconcileRunStore:
|
||||
def __init__(self) -> None:
|
||||
self._runs: dict[UUID, ReconcileRun] = {}
|
||||
self._lock = RLock()
|
||||
|
||||
def create(self, run: ReconcileRun) -> ReconcileRun:
|
||||
with self._lock:
|
||||
self._runs[run.id] = deepcopy(run)
|
||||
return deepcopy(run)
|
||||
|
||||
def save(self, run: ReconcileRun) -> ReconcileRun:
|
||||
with self._lock:
|
||||
previous = self._runs.get(run.id)
|
||||
if previous is None:
|
||||
raise LookupError(f"reconcile run not found: {run.id}")
|
||||
if run.revision != previous.revision + 1:
|
||||
raise ValueError("reconcile run revision must advance exactly once")
|
||||
self._runs[run.id] = deepcopy(run)
|
||||
return deepcopy(run)
|
||||
|
||||
def managed_slugs(self) -> set[str]:
|
||||
with self._lock:
|
||||
slugs: set[str] = set()
|
||||
for run in self._runs.values():
|
||||
for change in [*run.planned_changes, *run.applied_changes]:
|
||||
slug = change.get("slug")
|
||||
if change.get("managed") is True and isinstance(slug, str):
|
||||
slugs.add(slug)
|
||||
return slugs
|
||||
|
||||
def get(self, run_id: UUID) -> ReconcileRun | None:
|
||||
with self._lock:
|
||||
return deepcopy(self._runs.get(run_id))
|
||||
|
||||
def list(self, workspace_id: UUID, project_id: UUID) -> tuple[ReconcileRun, ...]:
|
||||
with self._lock:
|
||||
return tuple(
|
||||
deepcopy(run)
|
||||
for run in self._runs.values()
|
||||
if run.workspace_id == workspace_id and run.project_id == project_id
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyReconcileRunStore:
|
||||
"""Adapter over the existing scoped reconcile_runs table."""
|
||||
|
||||
def __init__(self, session: Session, scope: Scope) -> None:
|
||||
self.repository = ReconcileRunRepository(session, scope)
|
||||
|
||||
def create(self, run: ReconcileRun) -> ReconcileRun:
|
||||
return self.repository.create(run)
|
||||
|
||||
def save(self, run: ReconcileRun) -> ReconcileRun:
|
||||
current = self.repository.get(run.id)
|
||||
if current is None:
|
||||
raise LookupError(f"reconcile run not found: {run.id}")
|
||||
return self.repository.update(run, expected_version=current.revision)
|
||||
|
||||
def managed_slugs(self) -> set[str]:
|
||||
slugs: set[str] = set()
|
||||
for run in self.repository.list(limit=500):
|
||||
for change in [*run.planned_changes, *run.applied_changes]:
|
||||
slug = change.get("slug")
|
||||
if change.get("managed") is True and isinstance(slug, str):
|
||||
slugs.add(slug)
|
||||
return slugs
|
||||
|
||||
def get(self, run_id: UUID) -> ReconcileRun | None:
|
||||
return self.repository.get(run_id)
|
||||
|
||||
def list(self, workspace_id: UUID, project_id: UUID) -> tuple[ReconcileRun, ...]:
|
||||
return tuple(self.repository.list(limit=500))
|
||||
Reference in new issue
Block a user