Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:36:21 +02:00
commit cb5be898ea
663 files changed
+82674

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
"""Declarative catalog validation and reconciliation boundaries."""
from .loader import CatalogLoader
from .reconciler import CatalogReconciler
__all__ = ["CatalogLoader", "CatalogReconciler"]
+342
View File
@@ -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))
+99
View File
@@ -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
+125
View File
@@ -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,
)
+230
View File
@@ -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)
+65
View File
@@ -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
+91
View File
@@ -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))