Files
Unraid-AppOps-Gateway-Public/backend/app/services/discovery_scheduler.py
T

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)