Files
Unraid-AppOps-Gateway-Public/backend/tests/test_deployment_auto_rollback.py
T

198 lines
5.8 KiB
Python

from __future__ import annotations
import pytest
from sqlmodel import Session, SQLModel, create_engine, select
from backend.app.db.models import DeploymentJob, Policy
from backend.app.schemas.deployment import DeploymentPlanV1
from backend.app.services.deployment_control_plane import (
DeploymentControlError,
DeploymentControlPlane,
)
from backend.app.services.secret_store import set_secret
def _plan() -> DeploymentPlanV1:
return DeploymentPlanV1.model_validate(
{
"application_id": "itworx-mcp-hub",
"repository": "Jens/ITWorx_MCP_Hub",
"revision": "a" * 40,
"release_version": "1.0.0",
"image_repository": "itworx-mcp-hub",
"image_tag": "1.0.0",
"topology": {
"compose_project": "itworx-mcp-hub",
"containers": ["itworx-mcp-hub"],
"proxy_network": "itworx-npm",
"internal_port": 8080,
},
"proxy": {"hostname": "mcp-test.example.com"},
"oidc": None,
"broker_secret_ref": "MCP_HUB_TEST_DEPLOYMENT_BROKER",
}
)
class _Broker:
def __init__(self) -> None:
self.preflight_calls = 0
self.apply_calls: list[str] = []
self.rollback_calls: list[str] = []
def preflight(self, plan, *, secret):
self.preflight_calls += 1
return {"status": "preflight_ok"}
def apply(self, plan, *, secret, idempotency_key):
self.apply_calls.append(idempotency_key)
return {"status": "deployed", "request": "a" * 64}
def rollback(self, plan, *, secret, idempotency_key):
self.rollback_calls.append(idempotency_key)
return {"status": "rolled_back", "request": "b" * 64}
def status(self, request_id, *, secret):
return {"status": "rolled_back", "request": request_id}
class _Portainer:
def list_containers(self):
return {"status": "ok", "containers": []}
class _FailingNpm:
def proxy_hosts(self):
return {"success": True, "items": []}
def ensure_managed_proxy_host(self, **kwargs):
return {"success": False, "status": "failed"}
class _Unraid:
def get_share_summary(self):
return {"status": "ok"}
class _Authentik:
def test_connection(self):
return {"status": "connected"}
def applications(self):
return {"success": True, "items": []}
def _session(tmp_path) -> Session:
engine = create_engine(f"sqlite:///{tmp_path / 'auto-rollback.db'}")
SQLModel.metadata.create_all(engine)
return Session(engine)
def _policy(action: str) -> Policy:
return Policy(
id=f"allow-mcp-hub-{action.replace('.', '-')}",
action=action,
target="itworx-mcp-hub",
voice="blocked",
manual="allowed",
risk="high",
reason="Exact-target no-regression deployment test policy.",
)
def _plane(broker: _Broker) -> DeploymentControlPlane:
return DeploymentControlPlane(
broker=broker,
portainer=_Portainer(),
npm=_FailingNpm(),
authentik=_Authentik(),
unraid=_Unraid(),
)
def _configure(session: Session) -> None:
set_secret(session, "MCP_HUB_TEST_DEPLOYMENT_BROKER", "x" * 32)
session.add(_policy("deployment.apply"))
session.add(_policy("deployment.rollback"))
session.commit()
def test_default_compose_file_survives_durable_plan_roundtrip():
plan = _plan()
restored = DeploymentPlanV1.model_validate(plan.model_dump(mode="json"))
assert restored.topology.compose_file == ".itworx/deployment.yml"
@pytest.mark.parametrize("operation", ["apply", "rollback"])
def test_hub_typed_mutation_is_rejected_before_job_or_broker_queue(
tmp_path, operation
):
broker = _Broker()
plane = _plane(broker)
with _session(tmp_path) as session:
_configure(session)
with pytest.raises(DeploymentControlError, match="signed protected push"):
plane.execute(
session,
plan=_plan(),
idempotency_key=f"mcp-hub-{operation}-typed-0001",
operation=operation,
)
assert session.exec(select(DeploymentJob)).all() == []
assert broker.preflight_calls == 0
assert broker.apply_calls == []
assert broker.rollback_calls == []
def test_hub_read_only_preflight_remains_available(tmp_path):
broker = _Broker()
plane = _plane(broker)
with _session(tmp_path) as session:
_configure(session)
result = plane.execute(
session,
plan=_plan(),
idempotency_key="mcp-hub-preflight-0001",
operation="preflight",
)
assert result["status"] == "preflight_succeeded"
assert broker.preflight_calls == 1
assert broker.apply_calls == []
assert broker.rollback_calls == []
def test_hub_post_apply_failure_records_data_aware_recovery_without_rollback(tmp_path):
broker = _Broker()
plane = _plane(broker)
plan = _plan()
job = DeploymentJob(
id="mcp-hub-existing-apply",
application_id=plan.application_id,
repository=plan.repository,
revision=plan.revision,
plan_sha256="a" * 64,
idempotency_sha256="b" * 64,
status="applying",
plan_json=plan.model_dump(mode="json"),
evidence_json={"brokerStatus": "deployed"},
)
with _session(tmp_path) as session:
_configure(session)
plane._complete_apply_job(session, job, plan)
assert job.status == "failed"
assert job.stage == "external_integration_failed"
assert job.evidence_json["postApplyVerification"]["status"] == "failed"
assert job.evidence_json["recovery"] == {
"status": "manual_required",
"reason": "hub_data_aware_recovery_required",
}
assert "Automatic image-only rollback is disabled" in job.result_summary
assert broker.rollback_calls == []