328 lines
13 KiB
Python
328 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..db import session as db_session
|
|
from ..db.models import Policy
|
|
from .audit_logger import log_event
|
|
from .gitea_project_auto_registration import GiteaProjectAutoRegistration
|
|
from .inventory_discovery import run_discovery
|
|
from .mcp_hub_deployment_reconciliation import McpHubDeploymentReconciliation
|
|
from .media_codec_dashboard_deployment_reconciliation import (
|
|
MediaCodecDashboardDeploymentReconciliation,
|
|
)
|
|
from .privatemuse_deployment_enrollment import PrivateMuseDeploymentEnrollment
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MCP_HUB_APPLICATION_ID = "itworx-mcp-hub"
|
|
_MCP_HUB_REPOSITORY = "jens/itworx_mcp_hub"
|
|
_MCP_HUB_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback")
|
|
|
|
|
|
def _reconcile_mcp_hub_exact_policies(session: Session) -> dict[str, object]:
|
|
"""Create only the exact MCP Hub deployment policies after state proof."""
|
|
|
|
existing_by_action: dict[str, Policy | None] = {}
|
|
for action in _MCP_HUB_DEPLOYMENT_ACTIONS:
|
|
matches = session.exec(
|
|
select(Policy).where(
|
|
(Policy.action == action) & (Policy.target == _MCP_HUB_APPLICATION_ID)
|
|
)
|
|
).all()
|
|
if len(matches) > 1:
|
|
return {
|
|
"status": "conflict",
|
|
"action": action,
|
|
"created": 0,
|
|
"policyIds": [],
|
|
}
|
|
existing = matches[0] if matches else None
|
|
if existing is not None and (
|
|
existing.manual != "allowed"
|
|
or existing.voice != "blocked"
|
|
or existing.risk != "high"
|
|
):
|
|
return {
|
|
"status": "conflict",
|
|
"action": action,
|
|
"created": 0,
|
|
"policyIds": [],
|
|
}
|
|
existing_by_action[action] = existing
|
|
|
|
policy_ids: list[str] = []
|
|
created = 0
|
|
for action in _MCP_HUB_DEPLOYMENT_ACTIONS:
|
|
existing = existing_by_action[action]
|
|
if existing is not None:
|
|
policy_ids.append(existing.id)
|
|
continue
|
|
suffix = action.rsplit(".", 1)[-1]
|
|
policy_id = f"mcp-hub-reconciled-{suffix}"
|
|
if session.get(Policy, policy_id) is not None:
|
|
return {
|
|
"status": "conflict",
|
|
"action": action,
|
|
"created": 0,
|
|
"policyIds": [],
|
|
}
|
|
session.add(
|
|
Policy(
|
|
id=policy_id,
|
|
action=action,
|
|
target=_MCP_HUB_APPLICATION_ID,
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason=(
|
|
"Exact MCP Hub deployment policy created only after "
|
|
"fail-closed runtime state reconciliation."
|
|
),
|
|
provenance_json={
|
|
"createdBy": "mcp-hub-state-reconciliation/v1",
|
|
"repository": _MCP_HUB_REPOSITORY,
|
|
"policyTemplateVersion": "exact-target/v1",
|
|
},
|
|
)
|
|
)
|
|
policy_ids.append(policy_id)
|
|
created += 1
|
|
session.commit()
|
|
return {"status": "ready", "created": created, "policyIds": policy_ids}
|
|
|
|
|
|
def _ensure_mcp_hub_exact_policies() -> dict[str, object]:
|
|
with Session(db_session.engine) as session:
|
|
return _reconcile_mcp_hub_exact_policies(session)
|
|
|
|
|
|
def _audit_privatemuse_enrollment(enrollment: dict[str, object]) -> None:
|
|
status = str(enrollment.get("status") or "error")
|
|
failure_reason = str(enrollment.get("reason") or "")
|
|
reason = "Bounded startup reconciliation for the existing PrivateMuse deployment target."
|
|
if failure_reason:
|
|
reason = f"{reason} Enrollment result: {status}; {failure_reason}"
|
|
with Session(db_session.engine) as session:
|
|
log_event(
|
|
session,
|
|
source="system",
|
|
identity="appops-startup",
|
|
action_id="deployment.target.enroll",
|
|
target="jens/privatemuse",
|
|
target_type="deployment-target",
|
|
risk="high",
|
|
policy_decision="allowed",
|
|
reason=reason,
|
|
result="success" if status == "enrolled" else "failed",
|
|
after_state={
|
|
"status": status,
|
|
"repository": enrollment.get("repository", "jens/privatemuse"),
|
|
"legacyRevision": enrollment.get("legacy_revision", ""),
|
|
"composeProject": enrollment.get("compose_project", ""),
|
|
"containers": enrollment.get("containers", []),
|
|
},
|
|
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
|
|
)
|
|
|
|
|
|
def _audit_mcp_hub_reconciliation(result: dict[str, object]) -> None:
|
|
status = str(result.get("status") or "error")
|
|
failure_reason = str(result.get("reason") or "")
|
|
reason = "Fail-closed startup reconciliation for the verified MCP Hub deployment state."
|
|
if failure_reason:
|
|
reason = f"{reason} Reconciliation result: {status}; {failure_reason}"
|
|
with Session(db_session.engine) as session:
|
|
log_event(
|
|
session,
|
|
source="system",
|
|
identity="appops-startup",
|
|
action_id="deployment.state.reconcile",
|
|
target="jens/itworx_mcp_hub",
|
|
target_type="deployment-target",
|
|
risk="high",
|
|
policy_decision="allowed",
|
|
reason=reason,
|
|
result="success" if status == "reconciled" else "failed",
|
|
after_state={
|
|
"status": status,
|
|
"repository": result.get("repository", "jens/itworx_mcp_hub"),
|
|
"composeProject": result.get("composeProject", "itworx-mcp-hub"),
|
|
"policyStatus": result.get("policyStatus", "not_checked"),
|
|
"policyIds": result.get("policyIds", []),
|
|
"policiesCreated": result.get("policiesCreated", 0),
|
|
},
|
|
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
|
|
)
|
|
|
|
|
|
def _audit_media_codec_dashboard_reconciliation(result: dict[str, object]) -> None:
|
|
status = str(result.get("status") or "error")
|
|
failure_reason = str(result.get("reason") or "")
|
|
reason = "Fail-closed startup reconciliation for the verified Media Codec Dashboard deployment state."
|
|
if failure_reason:
|
|
reason = f"{reason} Reconciliation result: {status}; {failure_reason}"
|
|
with Session(db_session.engine) as session:
|
|
log_event(
|
|
session,
|
|
source="system",
|
|
identity="appops-startup",
|
|
action_id="deployment.state.reconcile",
|
|
target="jens/media-codec-dashboard",
|
|
target_type="deployment-target",
|
|
risk="high",
|
|
policy_decision="allowed",
|
|
reason=reason,
|
|
result="success" if status == "reconciled" else "failed",
|
|
after_state={
|
|
"status": status,
|
|
"repository": result.get("repository", "jens/media-codec-dashboard"),
|
|
"liveRevision": result.get("live_revision", ""),
|
|
"previousRecordedRevision": result.get("previous_recorded_revision", ""),
|
|
"container": result.get("container", ""),
|
|
},
|
|
redacted_metadata={"failureReason": failure_reason} if failure_reason else None,
|
|
)
|
|
|
|
|
|
def _sync_gitea_projects() -> dict[str, object]:
|
|
with Session(db_session.engine) as session:
|
|
result = GiteaProjectAutoRegistration().sync(session)
|
|
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
|
|
changed = int(summary.get("changedThisSync", 0) or 0)
|
|
if changed:
|
|
log_event(
|
|
session,
|
|
source="system",
|
|
identity="appops-project-sync",
|
|
action_id="deployment.targets.reconcile",
|
|
target="jens/*",
|
|
target_type="deployment-project-catalog",
|
|
risk="medium",
|
|
policy_decision="allowed",
|
|
reason="Automatic Gitea project registration reconciled changed owned repositories.",
|
|
result="success" if result.get("status") == "ok" else "failed",
|
|
after_state={
|
|
"status": result.get("status"),
|
|
"total": summary.get("total", 0),
|
|
"registered": summary.get("registered", 0),
|
|
"knownUnconfigured": summary.get("knownUnconfigured", 0),
|
|
"conflicts": summary.get("conflicts", 0),
|
|
"errors": summary.get("errors", 0),
|
|
"changed": changed,
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
async def _run_gitea_project_sync() -> None:
|
|
try:
|
|
result = await asyncio.to_thread(_sync_gitea_projects)
|
|
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
|
|
if result.get("status") != "ok":
|
|
logger.warning(
|
|
"Gitea project registration sync degraded: status=%s reason=%s",
|
|
result.get("status"),
|
|
result.get("reason", "unspecified"),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"Gitea project registration sync complete: total=%s registered=%s changed=%s",
|
|
summary.get("total", 0),
|
|
summary.get("registered", 0),
|
|
summary.get("changedThisSync", 0),
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001 - catalog sync must never take down AppOps.
|
|
logger.exception("Gitea project registration sync failed")
|
|
|
|
|
|
async def run_periodic_discovery(interval_seconds: int) -> None:
|
|
"""Reconcile deployment project catalog, bounded target state and inventory."""
|
|
await _run_gitea_project_sync()
|
|
|
|
try:
|
|
enrollment = await asyncio.to_thread(PrivateMuseDeploymentEnrollment().run)
|
|
await asyncio.to_thread(_audit_privatemuse_enrollment, enrollment)
|
|
if enrollment.get("status") != "enrolled":
|
|
logger.warning(
|
|
"PrivateMuse deployment enrollment did not complete: status=%s reason=%s",
|
|
enrollment.get("status"),
|
|
enrollment.get("reason", "unspecified"),
|
|
)
|
|
else:
|
|
logger.info("PrivateMuse deployment target is enrolled")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001 - enrollment failure must not take down AppOps.
|
|
logger.exception("PrivateMuse deployment enrollment failed")
|
|
|
|
try:
|
|
reconciliation = await asyncio.to_thread(McpHubDeploymentReconciliation().run)
|
|
if reconciliation.get("status") == "reconciled":
|
|
policy_result = await asyncio.to_thread(_ensure_mcp_hub_exact_policies)
|
|
reconciliation = {
|
|
**reconciliation,
|
|
"policyStatus": policy_result.get("status", "error"),
|
|
"policyIds": policy_result.get("policyIds", []),
|
|
"policiesCreated": policy_result.get("created", 0),
|
|
}
|
|
if policy_result.get("status") != "ready":
|
|
reconciliation = {
|
|
**reconciliation,
|
|
"status": "policy_conflict",
|
|
"reason": "exact MCP Hub deployment policy reconciliation failed closed",
|
|
}
|
|
await asyncio.to_thread(_audit_mcp_hub_reconciliation, reconciliation)
|
|
if reconciliation.get("status") != "reconciled":
|
|
logger.warning(
|
|
"MCP Hub deployment reconciliation did not complete: status=%s reason=%s",
|
|
reconciliation.get("status"),
|
|
reconciliation.get("reason", "unspecified"),
|
|
)
|
|
else:
|
|
logger.info("MCP Hub deployment state is reconciled")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps.
|
|
logger.exception("MCP Hub deployment reconciliation failed")
|
|
|
|
try:
|
|
reconciliation = await asyncio.to_thread(
|
|
MediaCodecDashboardDeploymentReconciliation().run
|
|
)
|
|
await asyncio.to_thread(
|
|
_audit_media_codec_dashboard_reconciliation, reconciliation
|
|
)
|
|
if reconciliation.get("status") != "reconciled":
|
|
logger.warning(
|
|
"Media Codec Dashboard deployment reconciliation did not complete: status=%s reason=%s",
|
|
reconciliation.get("status"),
|
|
reconciliation.get("reason", "unspecified"),
|
|
)
|
|
else:
|
|
logger.info("Media Codec Dashboard deployment state is reconciled")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps.
|
|
logger.exception("Media Codec Dashboard deployment reconciliation failed")
|
|
|
|
if interval_seconds <= 0:
|
|
return
|
|
|
|
await asyncio.sleep(min(10, interval_seconds))
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(run_discovery)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001 - discovery must never take down the gateway.
|
|
logger.exception("Scheduled inventory discovery failed")
|
|
await _run_gitea_project_sync()
|
|
await asyncio.sleep(interval_seconds)
|