diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 207564d..100c5f8 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `f8f5bf0a29159bd69accd7e58f6427ec32140640`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `4f99d8f6ea4fb8ae105b83570344b25fa05c31fd`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 849945d..8ef23ae 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "f8f5bf0a29159bd69accd7e58f6427ec32140640", + "sourceRevision": "4f99d8f6ea4fb8ae105b83570344b25fa05c31fd", "files": [ { "path": ".dockerignore", @@ -124,8 +124,8 @@ }, { "path": "backend/app/api/routes_deployments.py", - "sha256": "8a7a2503c38fd1edea002cbb780844b6907830edf88bd74e9f4c6bbbdf94a43e", - "bytes": 4241 + "sha256": "0441f74d508dde2ca0641cae32b8a7f386496c8f6f0f2df80ec619a6409bc9b3", + "bytes": 4955 }, { "path": "backend/app/api/routes_enrollments.py", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "8eb186a7264fd602b019a06e49eae99d91dcce157ae538b30afc7e48320f8df8", - "bytes": 34436 + "sha256": "75779ae0a366cca415c04fc6fe24f38d99c338939874acf17be6616cc12b4f25", + "bytes": 35237 }, { "path": "backend/app/schemas/ai_remediation.py", @@ -432,6 +432,11 @@ "sha256": "cb81e13c924626cfd8d6c91a240d6299b92798e0f760a19aa906d021736e1f0b", "bytes": 334 }, + { + "path": "backend/app/schemas/forge_bootstrap.py", + "sha256": "12db5aa43594bbcc9e4306a173769afdeabb86d364e0939e637b80775ea73629", + "bytes": 1208 + }, { "path": "backend/app/services/__init__.py", "sha256": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", @@ -489,8 +494,8 @@ }, { "path": "backend/app/services/controller_artifact_promoter.py", - "sha256": "a193123dd59de03f19cc14e51e9551e0ab316b741cc8b417ed530f86c539dc13", - "bytes": 16370 + "sha256": "6e740205449af2adc1fbbf9adf15989532fc6a92e252c8dcc81e7b4d644e58cd", + "bytes": 16389 }, { "path": "backend/app/services/controller_bootstrap_import.py", @@ -499,8 +504,8 @@ }, { "path": "backend/app/services/deployment_broker.py", - "sha256": "c8e58f76b1f245553c1a419e33ad919e3366cc54b7f9bdcc8698d46abbff0c6b", - "bytes": 22786 + "sha256": "2feb43b65684f3f6a2dba688a47c18117d907ca34426919270f67e4de42db453", + "bytes": 23266 }, { "path": "backend/app/services/deployment_control_plane.py", @@ -532,6 +537,11 @@ "sha256": "36cf235e02e4289b7b1851bb6240584bf8d6f6fbb9b2ae22ac521ec8012ba025", "bytes": 2538 }, + { + "path": "backend/app/services/forge_bootstrap_preflight.py", + "sha256": "b5ced0a85cc1585386fec21cc1cfa5bc88b5ccecf211beff93f99d79736f413f", + "bytes": 14808 + }, { "path": "backend/app/services/gitea_project_auto_registration.py", "sha256": "0da4469d2b75d1d6c74704b9c402348ce246f3325c1726bd50dcf13b378e060b", @@ -819,8 +829,8 @@ }, { "path": "backend/tests/test_controller_artifact_promoter.py", - "sha256": "590a7155052851e767a048d15f7f6624070a68c020e90b32ed8801ceab11e972", - "bytes": 3672 + "sha256": "671f62d61ec1281644d0b49500141dd4a50ef75b7bf82accbe80dd0bf16d04ec", + "bytes": 3922 }, { "path": "backend/tests/test_controller_bootstrap_import_host.py", @@ -834,13 +844,13 @@ }, { "path": "backend/tests/test_controller_hub_release_volume.py", - "sha256": "0dddc78484ebc4a09bc75e3e71cdca5f48cb3b6afba91c3b36aa16703266d6e0", - "bytes": 21814 + "sha256": "d527b5ac287a02bc5e9f4c89ddf69638b6f8a12c5a34ad2e65db2721b8e05303", + "bytes": 28619 }, { "path": "backend/tests/test_controller_root_procedure_contract.py", - "sha256": "1552a883e13a66e0f154481c6f3cf2b162ad867d6bbaaed7a84cedd45c5935ac", - "bytes": 2793 + "sha256": "ff2c7b80c3bc71b1ab06fc5637fce0c2c73df78e6d6123a47a34b3036f3caa8c", + "bytes": 3299 }, { "path": "backend/tests/test_controller_state_reconciliation.py", @@ -897,6 +907,16 @@ "sha256": "ddd08c246eb0719556f307691ca231093584dc4b85e78bb849bee83ccbde8b1f", "bytes": 8374 }, + { + "path": "backend/tests/test_forge_bootstrap_controller.py", + "sha256": "442a57109bc2dc0e4d4b9eb50c84ae8bac5685f94c9ec41fcfdadd0911352e44", + "bytes": 10829 + }, + { + "path": "backend/tests/test_forge_bootstrap_preflight.py", + "sha256": "b9ef73cd20bcdb99eb4afe42d26604397e82258f24f4b5dbc25d182c1c6a95da", + "bytes": 8518 + }, { "path": "backend/tests/test_frontend_file_boundary.py", "sha256": "beee4046574b89f6543d7a07040b983cfd7a1e561dbd9db4f81c9cd66ab77c32", @@ -979,8 +999,8 @@ }, { "path": "backend/tests/test_mcp_rag_n8n_integrations.py", - "sha256": "0b2891cd4b545ced2e737e4caa1506ceedecb4600247223b3490749213348fba", - "bytes": 48408 + "sha256": "89f557049d6e2c65fada94321503df65bf7c82bd567038241ea3bdad7c866ed1", + "bytes": 50584 }, { "path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py", diff --git a/backend/app/api/routes_deployments.py b/backend/app/api/routes_deployments.py index 089d607..2023a94 100644 --- a/backend/app/api/routes_deployments.py +++ b/backend/app/api/routes_deployments.py @@ -15,11 +15,12 @@ from sqlmodel import Session from ..core.security import require_operator_request from ..db.session import get_session from ..schemas.deployment import DeploymentPlanV1 +from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from ..services.deployment_broker import DeploymentBrokerClient from ..services.deployment_control_plane import ( DeploymentControlError, DeploymentControlPlane, ) -from ..services.deployment_broker import DeploymentBrokerClient def _require_deployment_operator(request: Request) -> str: @@ -95,6 +96,27 @@ def preflight_workmesh_promotion( raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.post("/forge-bootstrap/preflight") +def preflight_forge_bootstrap( + payload: ForgeBootstrapPreflightV1, + session: DeploymentSession, + idempotency_key: DeploymentIdempotencyKey = None, +): + from ..services.forge_bootstrap_preflight import ( + ForgeBootstrapPreflightError, + ForgeBootstrapPreflightService, + ) + + try: + return ForgeBootstrapPreflightService().preflight( + session, + payload, + idempotency_key=_idempotency_header(idempotency_key), + ) + except ForgeBootstrapPreflightError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.get("/workmesh/promotions/{promotion_id}") def get_workmesh_promotion(promotion_id: str, session: DeploymentSession): from ..services.workmesh_promotion import WorkMeshPromotionService diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index f6f1e1f..06e22e1 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -27,6 +27,7 @@ from .db.models import AppSetting, AuditEvent from .db.session import engine from .schemas.deployment import DeploymentPlanV1 from .schemas.enrollment import EnrollmentPlanV1 +from .schemas.forge_bootstrap import ForgeBootstrapPreflightV1 from .services import knowledge_service, mcp_operations, safety_center_service from .services.action_execution import mcp_execution_supported from .services.action_registry import ACTION_RISK, ACTIONS, action_capability @@ -653,8 +654,26 @@ def cancel_job(jobId: str) -> dict[str, Any]: @appops_mcp.tool(name="deployment.preflight", annotations=_READ_ONLY_TOOL) @_tracked("deployment.preflight") def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: - """Validate one strict DeploymentPlanV1 without touching application runtime state.""" + """Validate a strict V1 plan or fixed Forge bootstrap profile read-only.""" try: + if plan.get("api_version") == "itworx.forge-bootstrap-preflight/v1": + from .services.forge_bootstrap_preflight import ( + ForgeBootstrapPreflightService, + ) + + profile = ForgeBootstrapPreflightV1.model_validate(plan) + with Session(engine) as session: + return _json( + ForgeBootstrapPreflightService().preflight( + session, profile, idempotency_key=idempotencyKey + ) + ) + if "api_version" in plan: + return { + "status": "invalid", + "executed": False, + "reason": "Unsupported deployment.preflight profile discriminator.", + } typed_plan = DeploymentPlanV1.model_validate(plan) with Session(engine) as session: return _json( diff --git a/backend/app/schemas/forge_bootstrap.py b/backend/app/schemas/forge_bootstrap.py new file mode 100644 index 0000000..b2687a6 --- /dev/null +++ b/backend/app/schemas/forge_bootstrap.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class ForgeBootstrapPreflightV1(BaseModel): + """Fixed read-only input for Forge production bootstrap readiness.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + api_version: Literal["itworx.forge-bootstrap-preflight/v1"] + revision: str = Field(min_length=40, max_length=40) + workspace_image_digest: str = Field(min_length=71, max_length=71) + frontdoor_image_digest: str = Field(min_length=71, max_length=71) + + @field_validator("revision") + @classmethod + def full_lowercase_revision(cls, value: str) -> str: + if not _REVISION.fullmatch(value): + raise ValueError("revision must be a full lowercase Git SHA") + return value + + @field_validator("workspace_image_digest", "frontdoor_image_digest") + @classmethod + def immutable_oci_digest(cls, value: str) -> str: + if not _DIGEST.fullmatch(value): + raise ValueError("image digest must be a lowercase sha256 OCI digest") + return value diff --git a/backend/app/services/controller_artifact_promoter.py b/backend/app/services/controller_artifact_promoter.py index 2c8feb5..c3b3f27 100644 --- a/backend/app/services/controller_artifact_promoter.py +++ b/backend/app/services/controller_artifact_promoter.py @@ -60,6 +60,7 @@ git --git-dir="$repo" archive --format=tar "$revision" \ scripts/reconcile_controller_state.py \ scripts/run_repopulse_adoption.py \ scripts/prepare_controller_hub_release_volume.py \ + scripts/vendor \ scripts/upgrade_deployment_controller.sh > "$work/controller.tar" chmod 0600 "$work/controller.tar" archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)" diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py index 92b509d..c90cff4 100644 --- a/backend/app/services/deployment_broker.py +++ b/backend/app/services/deployment_broker.py @@ -26,6 +26,7 @@ import httpx from ..core.config import settings from ..core.redaction import redact_message from ..schemas.deployment import DeploymentPlanV1 +from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 _REQUEST_ID = re.compile(r"^[0-9a-f]{64}$") _MAX_DELIVERY_STATUS_BYTES = 512 * 1024 @@ -302,6 +303,17 @@ class DeploymentBrokerClient: payload={"enrollment": enrollment}, ) + def forge_bootstrap_preflight( + self, profile: ForgeBootstrapPreflightV1, *, secret: str + ) -> dict[str, Any]: + """Read fixed Forge source and host prerequisites through bootstrap HMAC.""" + return self._request( + "POST", + "/v1/appops/forge/bootstrap/preflight", + secret=secret, + payload={"profile": profile.model_dump(mode="json")}, + ) + def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]: """Read the bootstrap-protected enrollment controller capability.""" return self._request( diff --git a/backend/app/services/forge_bootstrap_preflight.py b/backend/app/services/forge_bootstrap_preflight.py new file mode 100644 index 0000000..1239ad2 --- /dev/null +++ b/backend/app/services/forge_bootstrap_preflight.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from typing import Any + +from sqlmodel import Session, select + +from ..core.time import utc_now +from ..db.models import DeploymentJob, Policy +from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .enrollment_plane import EnrollmentPlane +from .policy_engine import evaluate_policy +from .secret_store import get_secret, has_secret + +_API_VERSION = "itworx.forge-bootstrap-preflight/v1" +_APPLICATION_ID = "itworx-forge" +_REPOSITORY = "jens/itworx-forge" +_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_CONTROLLER_CHECKS = { + "exact_main", "source_manifest", "managed_appdata", "proxy_network", + "resource_collisions", "push_validation", "image_registry", "oidc", + "secret_delivery", +} +_DETAILS = { + "deployment_apply_policy": "An explicit allowed exact-target deployment policy is required before apply.", + "controller_trust": "Stored controller trust and bootstrap secret are required for host checks.", + "managed_appdata_root_selection": "The managed Docker-root backup acknowledgement and storage selection are required before setup.", + "exact_main": "Requested Forge revision must equal the current main head.", + "source_manifest": "The source manifest must match the fixed Forge six-service topology.", + "managed_appdata": "The fixed managed PostgreSQL directory must have safe owner-only metadata.", + "proxy_network": "The required external proxy network must be present.", + "resource_collisions": "Fixed Forge container names must be free for first installation.", + "push_validation": "Exact-main CI validation cannot be verified from trusted controller configuration.", + "image_registry": "Registry publication and pull authorization require external verification.", + "oidc": "Dedicated Forge Authentik provider, application, and role groups require external verification.", + "secret_delivery": "Forge database and application secret files require external verification.", +} +_STATUS_DETAILS = { + "exact_main": { + "passed": "Requested revision equals the current protected Gitea main head.", + "blocked": "Requested revision is not the current protected Gitea main head.", + "unknown": "Current protected Gitea main head could not be verified.", + }, + "source_manifest": { + "passed": "Committed Forge manifest matches the pinned production topology contract.", + "blocked": "Committed Forge manifest differs from the pinned production topology contract.", + "unknown": "Committed Forge manifest could not be read from the controller mirror.", + }, + "managed_appdata": { + "passed": "Selected managed root and fixed PostgreSQL directory have safe metadata.", + "blocked": "Managed root or fixed PostgreSQL directory is missing or unsafe.", + "unknown": "Managed storage metadata could not be verified.", + }, + "proxy_network": { + "passed": "The required external proxy network exists.", + "blocked": "The required external proxy network is missing.", + "unknown": "Docker network inventory is unavailable.", + }, + "resource_collisions": { + "passed": "No fixed Forge container-name collisions were found.", + "blocked": "One or more fixed Forge container names already exist.", + "unknown": "Docker container inventory is unavailable.", + }, + "push_validation": { + "passed": "Configured required push validation contexts passed for this exact commit.", + "blocked": "A configured required push validation context rejected this exact commit.", + "unknown": "Exact-commit push validation is not configured or cannot be verified.", + }, +} + + +class ForgeBootstrapPreflightError(ValueError): + """A fixed-target Forge preflight request failed closed.""" + + +def _canonical_digest(value: dict[str, Any]) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _idempotency_digest(value: str) -> str: + normalized = value.strip() + if not _IDEMPOTENCY.fullmatch(normalized): + raise ForgeBootstrapPreflightError("Idempotency-Key must contain 8 to 128 safe characters.") + return hashlib.sha256(f"forge-bootstrap-preflight:{normalized}".encode()).hexdigest() + + +def _check(name: str, status: str) -> dict[str, str]: + detail = _STATUS_DETAILS.get(name, {}).get(status, _DETAILS[name]) + return {"name": name, "status": status, "detail": detail} + + +def _checked_controller_result( + result: object, profile: ForgeBootstrapPreflightV1 +) -> tuple[list[dict[str, str]], dict[str, Any]] | None: + if not isinstance(result, dict) or ( + result.get("apiVersion") != _API_VERSION + or result.get("revision") != profile.revision + or result.get("workspaceImageDigest") != profile.workspace_image_digest + or result.get("frontdoorImageDigest") != profile.frontdoor_image_digest + ): + return None + raw_checks = result.get("checks") + if not isinstance(raw_checks, dict) or set(raw_checks) != _CONTROLLER_CHECKS: + return None + checks: list[dict[str, str]] = [] + for name in sorted(_CONTROLLER_CHECKS): + raw = raw_checks.get(name) + if not isinstance(raw, dict) or set(raw) != {"status"} or raw.get("status") not in {"passed", "blocked", "unknown"}: + return None + # CI, registry access, OIDC and secret delivery are intentionally + # unknown until a configured, source-authenticated proof exists. + if name in {"image_registry", "oidc", "secret_delivery"} and raw["status"] != "unknown": + return None + checks.append(_check(name, raw["status"])) + statuses = [item["status"] for item in checks] + expected_status = "preflight_blocked" if "blocked" in statuses else "preflight_unknown" if "unknown" in statuses else "preflight_ok" + if result.get("status") != expected_status: + return None + raw_evidence = result.get("evidence", {}) + if not isinstance(raw_evidence, dict) or set(raw_evidence) - { + "mainRevision", "sourceManifestSha256", "resourceCollisionCount", + "workspaceImagePresent", "frontdoorImagePresent", + }: + return None + evidence: dict[str, Any] = {} + for name in ("mainRevision", "sourceManifestSha256"): + value = raw_evidence.get(name) + if value is not None: + pattern = r"^[0-9a-f]{40}$" if name == "mainRevision" else r"^[0-9a-f]{64}$" + if not isinstance(value, str) or not re.fullmatch(pattern, value): + return None + evidence[name] = value + count = raw_evidence.get("resourceCollisionCount") + if count is not None: + if isinstance(count, bool) or not isinstance(count, int) or not 0 <= count <= 1000: + return None + evidence["resourceCollisionCount"] = count + for name in ("workspaceImagePresent", "frontdoorImagePresent"): + value = raw_evidence.get(name) + if value is not None: + if not isinstance(value, bool): + return None + evidence[name] = value + return checks, evidence + + +class ForgeBootstrapPreflightService: + """Read-only fixed Forge checks; this profile has no apply or rollback path.""" + + def __init__(self, *, broker: DeploymentBrokerClient | None = None): + self.broker = broker or DeploymentBrokerClient() + + def preflight( + self, + session: Session, + profile: ForgeBootstrapPreflightV1, + *, + idempotency_key: str, + ) -> dict[str, Any]: + payload = profile.model_dump(mode="json") + plan_digest = _canonical_digest(payload) + key_digest = _idempotency_digest(idempotency_key) + existing = session.exec( + select(DeploymentJob).where(DeploymentJob.idempotency_sha256 == key_digest) + ).first() + if existing: + if existing.plan_sha256 != plan_digest: + raise ForgeBootstrapPreflightError( + "Idempotency-Key was already used for a different Forge profile." + ) + return self.serialize_job(existing, replay=True) + + exact = session.exec( + select(Policy).where( + (Policy.action == "deployment.apply") & (Policy.target == _APPLICATION_ID) + ) + ).all() + decision = evaluate_policy( + session, action="deployment.apply", target=_APPLICATION_ID, source="manual" + ) + policy_ready = bool( + len(exact) == 1 + and exact[0].manual == "allowed" + and exact[0].voice == "blocked" + and exact[0].risk == "high" + and decision.get("decision") == "allowed" + and decision.get("policy_id") == exact[0].id + ) + checks = [_check("deployment_apply_policy", "passed" if policy_ready else "blocked")] + + trust = EnrollmentPlane.get_trust_config(session) + expected_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + secret = None + if trust is not None: + try: + if has_secret(session, trust.bootstrap_secret_ref): + secret = get_secret(session, trust.bootstrap_secret_ref, touch=False) + except Exception: # noqa: BLE001 - fail closed without exposing secret-store errors. + secret = None + bootstrap_ready = bool( + trust + and isinstance(secret, str) + and len(secret) >= 32 + and secret.isascii() + and secret.isprintable() + ) + checks.append(_check("controller_trust", "passed" if bootstrap_ready else "blocked")) + root_ready = bool( + trust + and trust.trusted_appdata_root == expected_root + and trust.managed_appdata_backup_acknowledged + ) + checks.append( + _check("managed_appdata_root_selection", "passed" if root_ready else "blocked") + ) + + controller_result = None + if bootstrap_ready: + try: + controller_result = self.broker.forge_bootstrap_preflight( + profile, secret=secret or "" + ) + except Exception: # noqa: BLE001 - transport details stay out of the preflight receipt. + controller_result = None + parsed_controller = _checked_controller_result(controller_result, profile) + if parsed_controller is None: + host_checks = [_check(name, "unknown") for name in sorted(_CONTROLLER_CHECKS)] + controller_evidence: dict[str, Any] = {} + else: + host_checks, controller_evidence = parsed_controller + checks.extend(host_checks) + blockers = [item["name"] for item in checks if item["status"] == "blocked"] + unknowns = [item["name"] for item in checks if item["status"] == "unknown"] + outcome = "blocked" if blockers else "unknown" if unknowns else "passed" + job_status = "preflight_succeeded" if outcome == "passed" else "preflight_failed" + now = utc_now() + job = DeploymentJob( + id=f"forge-preflight-{uuid.uuid4().hex[:16]}", + application_id=_APPLICATION_ID, + repository=_REPOSITORY, + revision=profile.revision, + plan_sha256=plan_digest, + idempotency_sha256=key_digest, + status=job_status, + stage="preflight_complete" if outcome == "passed" else "preflight_incomplete", + plan_json=payload, + preflight_json={ + "apiVersion": _API_VERSION, + "status": outcome, + "blockers": blockers, + "unknowns": unknowns, + "checks": checks, + }, + evidence_json={ + "profile": "forge-production-bootstrap", + "workspaceImageDigest": profile.workspace_image_digest, + "frontdoorImageDigest": profile.frontdoor_image_digest, + "controller": controller_evidence, + }, + result_summary=( + "Forge bootstrap checks passed; production apply remains separately gated." + if outcome == "passed" + else "Forge bootstrap preflight is incomplete; see blocked and unknown checks." + ), + created_at=now, + updated_at=now, + completed_at=now, + ) + session.add(job) + session.commit() + session.refresh(job) + log_event( + session, + source="deployment-control-plane", + identity="operator", + action_id="deployment.preflight", + target=_APPLICATION_ID, + target_type="deployment", + risk="high", + policy_decision="allowed" if policy_ready else "blocked", + reason="Fixed Forge bootstrap preflight recorded without requesting a deployment action.", + result="success" if outcome == "passed" else "blocked", + execution_plan={"planSha256": plan_digest, "profile": "forge-production-bootstrap"}, + post_checks={"preflight": job.preflight_json}, + ) + return self.serialize_job(job) + + @staticmethod + def serialize_job(job: DeploymentJob, *, replay: bool = False) -> dict[str, Any]: + return { + "id": job.id, + "applicationId": job.application_id, + "repository": job.repository, + "revision": job.revision, + "status": job.status, + "stage": job.stage, + "preflight": job.preflight_json, + "evidence": job.evidence_json, + "result": job.result_summary, + "brokerRequestId": job.broker_request_id, + "createdAt": job.created_at.isoformat(), + "updatedAt": job.updated_at.isoformat(), + "completedAt": job.completed_at.isoformat() if job.completed_at else None, + "idempotentReplay": replay, + } + + @staticmethod + def refresh(session: Session, job_id: str) -> dict[str, Any] | None: + job = session.get(DeploymentJob, job_id) + if not job or not job.id.startswith("forge-preflight-"): + return None + return ForgeBootstrapPreflightService.serialize_job(job) + + @staticmethod + def list_jobs(session: Session, *, limit: int = 100) -> list[dict[str, Any]]: + rows = session.exec( + select(DeploymentJob) + .where(DeploymentJob.id.startswith("forge-preflight-")) + .order_by(DeploymentJob.created_at.desc()) + .limit(max(1, min(limit, 250))) + ).all() + return [ForgeBootstrapPreflightService.serialize_job(row) for row in rows] diff --git a/backend/tests/test_controller_artifact_promoter.py b/backend/tests/test_controller_artifact_promoter.py index cf6d9e3..baf2bce 100644 --- a/backend/tests/test_controller_artifact_promoter.py +++ b/backend/tests/test_controller_artifact_promoter.py @@ -29,6 +29,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): spec = helper_container_spec(revision=revision, image_id=image_id) assert HELPER_NAME == "itworx-controller-artifact-promoter" + assert CONTROLLER_VERSION == "2.1.13" assert re.fullmatch(r"2\.1\.\d+", CONTROLLER_VERSION) assert spec["Image"] == image_id assert spec["HostConfig"]["Privileged"] is False @@ -53,6 +54,9 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): assert "rm -rf -- \"$work\"" in command assert "chmod 0600 \"$work/controller.tar\"" in command assert "scripts/prepare_controller_hub_release_volume.py" in command + archive_start = command.index('git --git-dir="$repo" archive') + archive_end = command.index('> "$work/controller.tar"', archive_start) + assert "scripts/vendor" in command[archive_start:archive_end] assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command assert f"--image-id '{image_id}'" in command diff --git a/backend/tests/test_controller_hub_release_volume.py b/backend/tests/test_controller_hub_release_volume.py index 87c0608..f224c01 100644 --- a/backend/tests/test_controller_hub_release_volume.py +++ b/backend/tests/test_controller_hub_release_volume.py @@ -264,6 +264,107 @@ def test_previous_private_tls_is_validated_from_the_attested_config_snapshot( assert calls == [] +def test_staged_config_checks_run_new_artifact_with_copied_config_and_bounded_output( + tmp_path, monkeypatch +): + stage_root = tmp_path / "source" + stage_root.mkdir(mode=0o700) + release_tree = ( + { + "status_server.py": (b"new status server", 0o644), + "controller-release.json": (b"new release identity", 0o644), + }, + {"": 0o755}, + ) + config_tree = ( + { + "projects.json": (b"{\"repo\":{}}", 0o600), + "broker-token": (b"private broker token", 0o600), + "gitea-validation.json": (b"{}", 0o600), + "gitea-validation-token": (b"private validation token", 0o600), + "private-tls.json": (b"private TLS config", 0o600), + "webhook-secrets/repo": (b"private webhook secret", 0o600), + }, + {"": 0o755, "webhook-secrets": 0o700}, + ) + calls = [] + validation_root = stage_root / "validation" + + def run(arguments, *, data=None, max_output=module.MAX_BYTES): + calls.append((arguments, max_output)) + assert (validation_root / "status_server.py").read_bytes() == b"new status server" + assert (validation_root / "projects.json").read_bytes() == b"{\"repo\":{}}" + assert (validation_root / "broker-token").read_bytes() == b"private broker token" + assert (validation_root / "webhook-secrets" / "repo").read_bytes() == b"private webhook secret" + return b"validated" + + monkeypatch.setattr(module, "_write_private_tree", _write_test_tree) + monkeypatch.setattr(module, "_run", run) + + validator = getattr(module, "_validate_staged_configuration", None) + assert callable(validator), "staged configuration validation helper is required" + validator(stage_root, release_tree, config_tree) + + assert calls == [ + (["python3", str(validation_root / "status_server.py"), "--check-artifact"], 4096), + (["python3", str(validation_root / "status_server.py"), "--check-config"], 4096), + (["python3", str(validation_root / "status_server.py"), "--check-validation-config"], 4096), + (["python3", str(validation_root / "status_server.py"), "--check-validation-api"], 4096), + (["python3", str(validation_root / "status_server.py"), "--check-private-tls-config"], 4096), + ] + assert not validation_root.exists() + + +def test_staged_config_check_failure_is_sanitized_and_cleans_private_validation_tree( + tmp_path, monkeypatch +): + stage_root = tmp_path / "source" + stage_root.mkdir(mode=0o700) + release_tree = ( + { + "status_server.py": (b"new status server", 0o644), + "controller-release.json": (b"new release identity", 0o644), + }, + {"": 0o755}, + ) + config_tree = ( + { + "projects.json": (b"{}", 0o600), + "broker-token": (b"top-secret-broker", 0o600), + "gitea-validation.json": (b"{}", 0o600), + "gitea-validation-token": (b"top-secret-validation", 0o600), + }, + {"": 0o755}, + ) + + class FailedProcess: + returncode = 1 + stdout = b"token=top-secret-validation" + stderr = b"secret=top-secret-broker" + + monkeypatch.setattr(module, "_write_private_tree", _write_test_tree) + monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: FailedProcess()) + + validator = getattr(module, "_validate_staged_configuration", None) + assert callable(validator), "staged configuration validation helper is required" + with pytest.raises(module.VolumeTransitionError, match="command_failed") as error: + validator(stage_root, release_tree, config_tree) + + assert "top-secret" not in str(error.value) + assert not (stage_root / "validation").exists() + + +def _write_test_tree(path, entries, directories): + path.mkdir(mode=0o700) + for relative, _mode in sorted(directories.items(), key=lambda item: item[0].count("/")): + if relative: + (path / relative).mkdir(parents=True, exist_ok=True) + for relative, (contents, _mode) in entries.items(): + destination = path / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(contents) + + @pytest.mark.skipif(os.name == "nt", reason="private TLS snapshot guard requires POSIX symlink semantics") def test_previous_private_tls_rejects_symlinked_snapshot(tmp_path): previous = tmp_path / "previous-controller" @@ -298,12 +399,67 @@ def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose): module.compose_action(rendered, "stop") module.compose_action(rendered, "up") assert calls[-2][0][-2:] == ["stop", module.SERVICE] - assert calls[-1][0][-5:] == ["up", "-d", "--force-recreate", "--pull", "never", module.SERVICE][-5:] + up_index = calls[-1][0].index("up") + assert calls[-1][0][up_index:] == [ + "up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE + ] assert calls[-2][1] == rendered assert calls[-1][1] == rendered assert baseline["name"] == "gitea-deploy-runners" +def test_rollback_uses_no_dependencies_when_recreating_prior_controller(compose, tmp_path, monkeypatch): + _baseline, calls = compose + previous = REVISION + target = "c" * 40 + previous_compose = module.rendered_model(ORIGINAL_COMPOSE, previous, IMAGE_ID) + target_compose = module.rendered_model(ORIGINAL_COMPOSE, target, IMAGE_ID) + receipt = { + "phase": "activated", + "imageId": IMAGE_ID, + "previousRevision": previous, + "sourceRevision": target, + } + values = { + "controller-before.tar": module._release_tar_bytes( + previous, + ({"controller-release.json": (b"previous release", 0o644)}, {"": 0o755}), + ), + "controller-after.tar": b"unused by isolated rollback test", + "mirror-before.tar": b"unused by isolated rollback test", + "compose-rollback.json": previous_compose, + "compose-after.json": target_compose, + } + current = { + "Mounts": [{ + "Destination": module.EXECUTABLE_TARGET, + "Source": module._expected_revision_path(previous).as_posix(), + }] + } + monkeypatch.setattr(module, "_load_transaction", lambda _backup: (1, receipt, values)) + monkeypatch.setattr(module.os, "close", lambda _descriptor: None) + monkeypatch.setattr(module, "_controller_image_inspect", lambda *args, **kwargs: current) + monkeypatch.setattr(module, "_verify_release_leaf", lambda *args, **kwargs: True) + monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_sync_controller_mirror", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_ensure_root_compose", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_verify_compose_image", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_verify_active_mount", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_set_promotion_phase", lambda *args, **kwargs: None) + + module.rollback_promotion(tmp_path / "backup") + + up_calls = [ + arguments for arguments, _data in calls + if arguments[:2] == ["docker", "compose"] and "up" in arguments + ] + assert len(up_calls) == 1 + up_index = up_calls[0].index("up") + assert up_calls[0][up_index:] == [ + "up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE + ] + + def test_installer_has_no_shared_executable_stage_or_compose_path(): source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8") assert "mktemp -d -p /tmp \"itworx-controller-upgrade-" in source @@ -476,6 +632,11 @@ def test_source_compose_preflight_rejects_unresolvable_alias_keys(): module._preflight_source_compose(source) +def test_source_compose_preflight_bounds_recursive_list_aliases(): + source = b"services: {app: {image: fixed}}\nx-loop: &a [*a]\n" + module._preflight_source_compose(source) + + @pytest.mark.parametrize( "source", [ diff --git a/backend/tests/test_controller_root_procedure_contract.py b/backend/tests/test_controller_root_procedure_contract.py index 31f1e53..8f0665d 100644 --- a/backend/tests/test_controller_root_procedure_contract.py +++ b/backend/tests/test_controller_root_procedure_contract.py @@ -53,3 +53,14 @@ def test_windows_wrapper_supplies_private_archive_and_exact_image_identity(): assert "--image-id '$imageId'" in wrapper assert "--image-id) image_id=\"$2\"" in installer assert '[[ "$archive_parent" =~ ^/tmp/itworx-controller-artifact-promoter-' in installer + + +def test_state_reconciliation_is_committed_only_after_volume_verification(): + installer = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8") + verify = installer.index('python3 "${work}/${PREPARER_NAME}" verify --backup "$backup"') + commit = installer.index( + 'python3 "${work}/reconcile_controller_state.py" --journal-path "$migration_journal" --commit' + ) + + assert verify < commit + assert installer.index("reconciliation_active=0", commit) > commit diff --git a/backend/tests/test_forge_bootstrap_controller.py b/backend/tests/test_forge_bootstrap_controller.py new file mode 100644 index 0000000..e43d34f --- /dev/null +++ b/backend/tests/test_forge_bootstrap_controller.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import hashlib +import http.client +import importlib.util +import json +import subprocess +import sys +import threading +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +STATUS_SERVER = ROOT / "ops" / "gitea-deploy-broker" / "status_server.py" + + +@pytest.fixture +def status_module(): + if "fcntl" not in sys.modules: + fcntl = types.ModuleType("fcntl") + fcntl.LOCK_EX = 2 + fcntl.LOCK_UN = 8 + fcntl.flock = lambda *_args: None + sys.modules["fcntl"] = fcntl + spec = importlib.util.spec_from_file_location("forge_bootstrap_status_test", STATUS_SERVER) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _request(module, server, body: bytes, *, nonce: str, secret: bytes, signature: str | None = None): + path = module.FORGE_BOOTSTRAP_PREFLIGHT_PATH + timestamp = str(int(module.time.time())) + signature = signature or module._request_signature( + secret, + method="POST", + path=path, + timestamp=timestamp, + nonce=nonce, + raw_body=body, + ) + connection = http.client.HTTPConnection(*server.server_address, timeout=2) + connection.request( + "POST", + path, + body=body, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + }, + ) + response = connection.getresponse() + status, payload = response.status, json.loads(response.read()) + connection.close() + return status, payload + + +def test_forge_preflight_http_is_signed_strict_replay_safe_and_read_only( + status_module, tmp_path, monkeypatch +): + secret = b"forge-bootstrap-test-secret-" + b"x" * 40 + secret_path = tmp_path / "bootstrap" + secret_path.write_bytes(secret) + secret_path.chmod(0o600) + nonce_root = tmp_path / "nonces" + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root) + calls = [] + response = { + "apiVersion": status_module.FORGE_BOOTSTRAP_PREFLIGHT_VERSION, + "status": "preflight_unknown", + "revision": "a" * 40, + "workspaceImageDigest": "sha256:" + "b" * 64, + "frontdoorImageDigest": "sha256:" + "c" * 64, + "checks": {"exact_main": {"status": "unknown"}}, + "evidence": {}, + } + monkeypatch.setattr( + status_module, + "_forge_bootstrap_runtime_checks", + lambda profile: calls.append(profile) or response, + ) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + profile = { + "api_version": status_module.FORGE_BOOTSTRAP_PREFLIGHT_VERSION, + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, + } + body = json.dumps({"profile": profile}, sort_keys=True, separators=(",", ":")).encode() + try: + status, payload = _request( + status_module, server, body, nonce="a" * 32, secret=secret + ) + assert status == 200 + assert payload == response + assert calls == [profile] + assert secret.decode() not in json.dumps(payload) + assert len(list(nonce_root.iterdir())) == 1 + + replay_status, replay_payload = _request( + status_module, server, body, nonce="a" * 32, secret=secret + ) + assert replay_status == 403 + assert replay_payload == {"status": "authentication_rejected"} + assert calls == [profile] + + hostile = dict(profile, host_path="/etc", endpoint="https://attacker.invalid") + hostile_body = json.dumps( + {"profile": hostile}, sort_keys=True, separators=(",", ":") + ).encode() + hostile_status, hostile_payload = _request( + status_module, server, hostile_body, nonce="b" * 32, secret=secret + ) + assert hostile_status == 400 + assert hostile_payload == {"status": "invalid_forge_preflight_contract"} + assert calls == [profile] + assert len(list(nonce_root.iterdir())) == 1 + + bad_signature_status, _ = _request( + status_module, + server, + body, + nonce="c" * 32, + secret=secret, + signature="0" * 64, + ) + assert bad_signature_status == 403 + assert calls == [profile] + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_forge_host_preflight_compares_exact_main_and_reports_fixed_runtime_checks( + status_module, tmp_path, monkeypatch +): + revision = "a" * 40 + manifest = b"fixed Forge compose candidate" + managed_root = tmp_path / "managed-root" + postgres_dir = managed_root / "itworx-forge" / "postgres" + postgres_dir.mkdir(parents=True) + (managed_root / "itworx-forge").chmod(0o700) + postgres_dir.chmod(0o700) + app_dir = managed_root / "itworx-forge" + monkeypatch.setattr(status_module, "MANAGED_APPDATA_ROOT", managed_root) + monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: managed_root) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda _root: True) + monkeypatch.setattr( + status_module, "os", types.SimpleNamespace(name="posix", geteuid=lambda: 0) + ) + original_lstat = Path.lstat + + def lstat_with_fixed_service_owners(path): + info = original_lstat(path) + if path == app_dir: + return types.SimpleNamespace( + st_mode=status_module.stat.S_IFDIR | 0o700, st_uid=0, st_gid=0 + ) + if path == postgres_dir: + return types.SimpleNamespace( + st_mode=status_module.stat.S_IFDIR | 0o700, st_uid=70, st_gid=70 + ) + return info + + monkeypatch.setattr(Path, "lstat", lstat_with_fixed_service_owners) + monkeypatch.setattr(status_module, "_forge_gitea_main_revision", lambda: revision) + monkeypatch.setattr(status_module, "_forge_push_validation", lambda _revision: "unknown") + monkeypatch.setattr( + status_module, "FORGE_COMPOSE_SHA256", hashlib.sha256(manifest).hexdigest() + ) + commands = [] + + def run(arguments, **_kwargs): + commands.append(arguments) + if arguments[:2] == ["git", "--git-dir"]: + if "rev-parse" in arguments: + return subprocess.CompletedProcess(arguments, 0, stdout=revision, stderr="") + if "show" in arguments: + return subprocess.CompletedProcess(arguments, 0, stdout=manifest, stderr=b"") + if arguments[:3] == ["docker", "network", "ls"]: + return subprocess.CompletedProcess( + arguments, 0, stdout="itworx-mcp-hub-npm\n", stderr="" + ) + if arguments[:3] == ["docker", "network", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=json.dumps( + {"Name": "itworx-mcp-hub-npm", "Driver": "bridge", "Scope": "local"} + ), + stderr="", + ) + if arguments[:3] == ["docker", "ps", "-a"]: + return subprocess.CompletedProcess(arguments, 0, stdout="", stderr="") + if arguments[:3] == ["docker", "image", "inspect"]: + return subprocess.CompletedProcess( + arguments, 0, stdout="sha256:" + "d" * 64, stderr="" + ) + raise AssertionError(f"unexpected fixed read-only command: {arguments!r}") + + monkeypatch.setattr(status_module.subprocess, "run", run) + profile = { + "revision": revision, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, + } + result = status_module._forge_bootstrap_runtime_checks(profile) + assert result["status"] == "preflight_unknown" + assert result["checks"]["exact_main"] == {"status": "passed"} + assert result["checks"]["source_manifest"] == {"status": "passed"} + assert result["checks"]["managed_appdata"] == {"status": "passed"} + assert result["checks"]["proxy_network"] == {"status": "passed"} + assert result["checks"]["resource_collisions"] == {"status": "passed"} + assert result["checks"]["push_validation"] == {"status": "unknown"} + assert result["evidence"]["mainRevision"] == revision + assert result["evidence"]["sourceManifestSha256"] == hashlib.sha256(manifest).hexdigest() + assert result["evidence"]["workspaceImagePresent"] is True + assert result["evidence"]["frontdoorImagePresent"] is True + assert all(isinstance(command, list) for command in commands) + assert all("shell" not in " ".join(command).lower() for command in commands) + + commands.clear() + non_head = dict(profile, revision="b" * 40) + result = status_module._forge_bootstrap_runtime_checks(non_head) + assert result["checks"]["exact_main"] == {"status": "blocked"} + assert result["status"] == "preflight_blocked" + + postgres_dir.rmdir() + postgres_dir.parent.rmdir() + managed_root.rmdir() + result = status_module._forge_bootstrap_runtime_checks(profile) + assert result["checks"]["managed_appdata"] == {"status": "blocked"} + assert not managed_root.exists() + + +def test_forge_appdata_parent_requires_root_only_metadata(status_module, monkeypatch): + monkeypatch.setattr(status_module, "os", types.SimpleNamespace(name="posix")) + safe = types.SimpleNamespace( + st_mode=status_module.stat.S_IFDIR | 0o700, st_uid=0, st_gid=0 + ) + group_writable = types.SimpleNamespace( + st_mode=status_module.stat.S_IFDIR | 0o770, st_uid=0, st_gid=0 + ) + wrong_owner = types.SimpleNamespace( + st_mode=status_module.stat.S_IFDIR | 0o700, st_uid=1000, st_gid=0 + ) + assert status_module._forge_app_dir_metadata_is_safe(safe) is True + assert status_module._forge_app_dir_metadata_is_safe(group_writable) is False + assert status_module._forge_app_dir_metadata_is_safe(wrong_owner) is False + + +def test_forge_local_image_query_failure_is_unknown(status_module, monkeypatch): + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], 1, stdout="", stderr="daemon unavailable" + ), + ) + assert ( + status_module._forge_local_image_present( + "gitea.example.com/jens/itworx-forge@sha256:" + "a" * 64 + ) + is None + ) diff --git a/backend/tests/test_forge_bootstrap_preflight.py b/backend/tests/test_forge_bootstrap_preflight.py new file mode 100644 index 0000000..15ba3b9 --- /dev/null +++ b/backend/tests/test_forge_bootstrap_preflight.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import DeploymentJob +from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 +from backend.app.schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from backend.app.services.enrollment_plane import EnrollmentPlane +from backend.app.services.forge_bootstrap_preflight import ( + ForgeBootstrapPreflightService, +) + + +def _profile(**overrides): + value = { + "api_version": "itworx.forge-bootstrap-preflight/v1", + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, + } + value.update(overrides) + return ForgeBootstrapPreflightV1.model_validate(value) + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-preflight.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_forge_profile_is_strict_and_cannot_select_runtime_inputs(): + _profile() + for key, value in ( + ("host_path", "/etc"), + ("endpoint", "https://attacker.invalid"), + ("secret_ref", "OTHER_SECRET"), + ("command", "docker compose up"), + ("repository", "other/repo"), + ("target", "other-host"), + ): + try: + _profile(**{key: value}) + except ValueError: + pass + else: # pragma: no cover - assertion clarity + raise AssertionError(f"caller-controlled {key} must be rejected") + + for field, value in ( + ("revision", "a" * 39), + ("workspace_image_digest", "latest"), + ("frontdoor_image_digest", "sha256:" + "D" * 64), + ): + try: + _profile(**{field: value}) + except ValueError: + pass + else: # pragma: no cover - assertion clarity + raise AssertionError(f"invalid {field} must be rejected") + + +class _Broker: + def __init__(self, result): + self.result = result + self.calls = [] + + def forge_bootstrap_preflight(self, profile, *, secret): + self.calls.append((profile.model_dump(mode="json"), secret)) + return self.result + + +def _trust(session, *, root="/var/lib/docker/volumes/itworx-managed-appdata/_data"): + EnrollmentPlane.set_trust_config( + session, + EnrollmentTrustConfigV1( + controller_identity="tower-deployment-controller", + bootstrap_secret_ref="CONTROLLER_BOOTSTRAP", + trusted_appdata_root=root, + managed_appdata_backup_acknowledged=( + root == "/var/lib/docker/volumes/itworx-managed-appdata/_data" + ), + approved_networks=("itworx-mcp-hub-npm",), + ), + ) + + +def test_forge_preflight_uses_stored_trust_and_preserves_unknowns(tmp_path, monkeypatch): + from backend.app.services import forge_bootstrap_preflight as service_module + + broker = _Broker( + { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_blocked", + "revision": "a" * 40, + "workspaceImageDigest": "sha256:" + "b" * 64, + "frontdoorImageDigest": "sha256:" + "c" * 64, + "checks": { + "exact_main": {"status": "passed"}, + "source_manifest": {"status": "passed"}, + "managed_appdata": {"status": "blocked"}, + "proxy_network": {"status": "passed"}, + "resource_collisions": {"status": "passed"}, + "push_validation": {"status": "unknown"}, + "image_registry": {"status": "unknown"}, + "oidc": {"status": "unknown"}, + "secret_delivery": {"status": "unknown"}, + }, + } + ) + monkeypatch.setattr(service_module, "get_secret", lambda *_args, **_kwargs: "s" * 48) + monkeypatch.setattr(service_module, "has_secret", lambda *_args, **_kwargs: True) + + with _session(tmp_path) as session: + _trust(session) + result = ForgeBootstrapPreflightService(broker=broker).preflight( + session, _profile(), idempotency_key="forge-preflight-test-0001" + ) + + assert result["status"] == "preflight_failed" + assert result["preflight"]["status"] == "blocked" + assert "push_validation" in { + item["name"] for item in result["preflight"]["checks"] + if item["status"] == "unknown" + } + assert broker.calls == [(_profile().model_dump(mode="json"), "s" * 48)] + job = session.get(DeploymentJob, result["id"]) + assert job is not None + assert job.status == "preflight_failed" + assert ForgeBootstrapPreflightService().refresh(session, job.id)["id"] == job.id + assert any(item["id"] == job.id for item in ForgeBootstrapPreflightService.list_jobs(session)) + + +def test_forge_preflight_idempotency_rejects_profile_change(tmp_path, monkeypatch): + from backend.app.services import forge_bootstrap_preflight as service_module + + broker = _Broker( + { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_unknown", + "revision": "a" * 40, + "workspaceImageDigest": "sha256:" + "b" * 64, + "frontdoorImageDigest": "sha256:" + "c" * 64, + "checks": { + "exact_main": {"status": "unknown"}, + "source_manifest": {"status": "unknown"}, + "managed_appdata": {"status": "unknown"}, + "proxy_network": {"status": "unknown"}, + "resource_collisions": {"status": "unknown"}, + "push_validation": {"status": "unknown"}, + "image_registry": {"status": "unknown"}, + "oidc": {"status": "unknown"}, + "secret_delivery": {"status": "unknown"}, + }, + } + ) + monkeypatch.setattr(service_module, "get_secret", lambda *_args, **_kwargs: "s" * 48) + monkeypatch.setattr(service_module, "has_secret", lambda *_args, **_kwargs: True) + + with _session(tmp_path) as session: + _trust(session) + service = ForgeBootstrapPreflightService(broker=broker) + service.preflight(session, _profile(), idempotency_key="forge-preflight-test-0002") + replay = service.preflight(session, _profile(), idempotency_key="forge-preflight-test-0002") + assert replay["idempotentReplay"] is True + assert len(broker.calls) == 1 + try: + service.preflight( + session, + _profile(revision="d" * 40), + idempotency_key="forge-preflight-test-0002", + ) + except ValueError as exc: + assert "different Forge profile" in str(exc) + else: # pragma: no cover + raise AssertionError("idempotency key reuse with changed Forge profile must fail") + + +def test_legacy_storage_trust_still_collects_read_only_controller_blockers(tmp_path, monkeypatch): + from backend.app.services import forge_bootstrap_preflight as service_module + + checks = { + name: {"status": "unknown"} + for name in ( + "exact_main", "source_manifest", "managed_appdata", "proxy_network", + "resource_collisions", "push_validation", "image_registry", "oidc", + "secret_delivery", + ) + } + checks["managed_appdata"] = {"status": "blocked"} + broker = _Broker( + { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_blocked", + "revision": "a" * 40, + "workspaceImageDigest": "sha256:" + "b" * 64, + "frontdoorImageDigest": "sha256:" + "c" * 64, + "checks": checks, + } + ) + monkeypatch.setattr(service_module, "get_secret", lambda *_args, **_kwargs: "s" * 48) + monkeypatch.setattr(service_module, "has_secret", lambda *_args, **_kwargs: True) + + with _session(tmp_path) as session: + _trust(session, root="/srv/example/appdata") + result = ForgeBootstrapPreflightService(broker=broker).preflight( + session, _profile(), idempotency_key="forge-legacy-trust-0001" + ) + + assert broker.calls == [(_profile().model_dump(mode="json"), "s" * 48)] + assert "managed_appdata_root_selection" in result["preflight"]["blockers"] + controller_checks = { + item["name"]: item["status"] for item in result["preflight"]["checks"] + } + assert controller_checks["managed_appdata"] == "blocked" + assert controller_checks["proxy_network"] == "unknown" diff --git a/backend/tests/test_mcp_rag_n8n_integrations.py b/backend/tests/test_mcp_rag_n8n_integrations.py index 1b66dd5..e8b7e28 100644 --- a/backend/tests/test_mcp_rag_n8n_integrations.py +++ b/backend/tests/test_mcp_rag_n8n_integrations.py @@ -231,6 +231,59 @@ def test_n8n_uses_the_shared_grounded_knowledge_service(integration_client): ) +def test_forge_bootstrap_preflight_is_read_only_and_rejects_generic_apply(integration_client): + client, _engine = integration_client + from backend.app import mcp_server + + profile = { + "api_version": "itworx.forge-bootstrap-preflight/v1", + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, + } + response = client.post( + "/api/deployments/forge-bootstrap/preflight", + headers={**human_headers(client), "Idempotency-Key": "forge-api-preflight-0001"}, + json=profile, + ) + assert response.status_code == 200 + result = response.json() + assert result["status"] == "preflight_failed" + assert result["preflight"]["status"] == "blocked" + assert "deployment_apply_policy" in result["preflight"]["blockers"] + assert "controller_trust" in result["preflight"]["blockers"] + + refreshed = client.get( + f"/api/deployments/{result['id']}", headers=human_headers(client) + ) + assert refreshed.status_code == 200 + assert refreshed.json()["preflight"]["status"] == "blocked" + listed = client.get("/api/deployments", headers=human_headers(client)) + assert any(item["id"] == result["id"] for item in listed.json()["items"]) + + generic_apply = client.post( + "/api/deployments", + headers={**human_headers(client), "Idempotency-Key": "forge-generic-apply-0001"}, + json={"operation": "apply", "plan": profile}, + ) + assert generic_apply.status_code == 422 + + async def exercise_mcp(): + async with Client(mcp_server.appops_mcp) as mcp_client: + preflight = await mcp_client.call_tool( + "deployment.preflight", + {"plan": profile, "idempotencyKey": "forge-mcp-preflight-0001"}, + ) + assert preflight.structured_content["preflight"]["status"] == "blocked" + apply = await mcp_client.call_tool( + "deployment.apply", + {"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"}, + ) + assert apply.structured_content["status"] == "invalid" + + asyncio.run(exercise_mcp()) + + def test_local_rag_indexes_allowlisted_docs_and_redacts_secrets(tmp_path): from backend.app.services import knowledge_service