diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 5595929..a59e5cc 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 `fe49858778940715742d53eea89241f01878f7ee`. 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 `ad0a390e431166c3198a2ac3bec010c2b44f2dea`. 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 1acb546..1932ae6 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "fe49858778940715742d53eea89241f01878f7ee", + "sourceRevision": "ad0a390e431166c3198a2ac3bec010c2b44f2dea", "files": [ { "path": ".dockerignore", @@ -64,8 +64,8 @@ }, { "path": "backend/app/api/routes_actions.py", - "sha256": "ff5a5fc977e2cd35617b3fec42bc225c40fde896725a1f906e971382f2bbce60", - "bytes": 19745 + "sha256": "f555523d9d285cf1bbb97f59f8ff0cf4c824583fb4c152c86a52fe1c1d47147b", + "bytes": 19807 }, { "path": "backend/app/api/routes_ai.py", @@ -129,8 +129,8 @@ }, { "path": "backend/app/api/routes_enrollments.py", - "sha256": "8efcba1e85c57a439f30a7f5eb663b0140fb425ac32134909a942e02b2a562b8", - "bytes": 3237 + "sha256": "5da9f18add09638f8a2d8dddc7c92c4790a94f76e9ead4639dfb35a9b8f247cb", + "bytes": 9878 }, { "path": "backend/app/api/routes_exposure_settings.py", @@ -419,13 +419,13 @@ }, { "path": "backend/app/schemas/deployment.py", - "sha256": "e6adc82b323591a7f7fa29099dcddb1c0d4efb829a0fadac6d10d4736b4c2ce8", - "bytes": 15857 + "sha256": "39fd2f54073498020871d16397eedc7471942c82ce307e3c137d49fab662e2b4", + "bytes": 16174 }, { "path": "backend/app/schemas/enrollment.py", - "sha256": "e8c360c5b6ed499488854763a60ab9ed820d5d44ebd254de70e44089b10e1082", - "bytes": 16000 + "sha256": "552e6acbf6ec5ebce6683d0b6df39ecbfe5a14bd6c3c528480d42d499f7218cc", + "bytes": 17667 }, { "path": "backend/app/schemas/exposure_preferences.py", @@ -439,13 +439,13 @@ }, { "path": "backend/app/services/action_execution.py", - "sha256": "2095356d5594ac90a5c519da5047df5a6989ef5f406879c66c3fb04420bb77d9", - "bytes": 77357 + "sha256": "fb32be9b89ac8235f826aabd8cb6ca0068fc9f5f2e5fc95a9963dac8c1df2cb3", + "bytes": 83876 }, { "path": "backend/app/services/action_registry.py", - "sha256": "0a574da6365d6e81535ca58b977b85d50eae794c2dff62fb59648542161fdf25", - "bytes": 20298 + "sha256": "b34a8217b0bbdaf1a9cda8d7bc310f5577a3dea0abf4bcc95d106a8227dd8ea9", + "bytes": 20499 }, { "path": "backend/app/services/ai_remediation_planner.py", @@ -489,8 +489,8 @@ }, { "path": "backend/app/services/controller_artifact_promoter.py", - "sha256": "8b6dc4f0773f225b45740bde205876cfe5e961a3dec102b0b3d0c7600dea236a", - "bytes": 16121 + "sha256": "557cc8dfc7fd5f8625dc36eab9bdef730b7a4cc8fa9bf273954b4aabae190ab0", + "bytes": 16381 }, { "path": "backend/app/services/controller_bootstrap_import.py", @@ -499,8 +499,8 @@ }, { "path": "backend/app/services/deployment_broker.py", - "sha256": "cb95116841e3598afc63cd0a104d5359335fb08fa8124cbc8c0ce303b730f6d1", - "bytes": 22320 + "sha256": "c8e58f76b1f245553c1a419e33ad919e3366cc54b7f9bdcc8698d46abbff0c6b", + "bytes": 22786 }, { "path": "backend/app/services/deployment_control_plane.py", @@ -519,8 +519,8 @@ }, { "path": "backend/app/services/enrollment_plane.py", - "sha256": "a60926f639fcce51c60f0aa86772a1b2994014d2567ef94c2361eba6e0aad843", - "bytes": 51512 + "sha256": "079163e8634996f572e573d657eae1ec1a72fa7ac739e86e47510a60f248b3f4", + "bytes": 57487 }, { "path": "backend/app/services/enrollment_profiles.py", @@ -579,8 +579,8 @@ }, { "path": "backend/app/services/mcp_hub_deployment_reconciliation.py", - "sha256": "1833b95ad7663b91f0f54ce6e92950a059efb8cb121d4e2c8af7f821a5525a49", - "bytes": 29680 + "sha256": "d45921d486b7debba7dc7d68a274beb2103c20fc5b2968f9380b5c22dfea6d95", + "bytes": 31417 }, { "path": "backend/app/services/mcp_operations.py", @@ -629,8 +629,8 @@ }, { "path": "backend/app/services/policy_engine.py", - "sha256": "148706d64e81a51cf448729f2a4ef387b282849a0f1c4c3273e9a71cee14fd19", - "bytes": 10026 + "sha256": "8213e8d213498b58c41bf7a9ae2a905e814b6ad9b6cfad2d7752df69d444634b", + "bytes": 10925 }, { "path": "backend/app/services/portfolio_deployment_enrollment.py", @@ -754,8 +754,8 @@ }, { "path": "backend/app/services/user_access.py", - "sha256": "bc952cb4bedddef1b769e2c77848b89b64f182b288273c1a79b3a09e349e633b", - "bytes": 9840 + "sha256": "61c994dc09c413f04838015f8ca0ea6f75d45aec1b63fa21716e3be69adc7c3f", + "bytes": 9918 }, { "path": "backend/app/services/voice_catalog.py", @@ -814,18 +814,28 @@ }, { "path": "backend/tests/test_controller_artifact_promoter.py", - "sha256": "afb12b325d23db5a7d52cdadb68f0b0294da136c678f387b03c4f82d064be110", - "bytes": 3179 + "sha256": "590a7155052851e767a048d15f7f6624070a68c020e90b32ed8801ceab11e972", + "bytes": 3672 }, { "path": "backend/tests/test_controller_bootstrap_import_host.py", "sha256": "541f786746b42447fb738f14343fb6d1cb0097d10a5e4949f0e940e111a6aec3", "bytes": 1577 }, + { + "path": "backend/tests/test_controller_config_paths.py", + "sha256": "9cbe851651abec8c5a792d9843ceca308993e71f67bdcbc081ae7eb9a96a0888", + "bytes": 5207 + }, + { + "path": "backend/tests/test_controller_hub_release_volume.py", + "sha256": "2bc2345aa0ea65aa78471aac85909c671040041c44cdb67d01c127c1be17eb70", + "bytes": 18590 + }, { "path": "backend/tests/test_controller_state_reconciliation.py", - "sha256": "4c6a73fb5afa0b08b32ef7e7d3c344ce9f28108065d693fb05a1b38afee23e88", - "bytes": 10743 + "sha256": "d00b8bcf388e17f0c1b8999d4b511fede7212b8ef83282905328d1b0b4b70f76", + "bytes": 10523 }, { "path": "backend/tests/test_deluge_connector.py", @@ -834,8 +844,8 @@ }, { "path": "backend/tests/test_deploy_broker.py", - "sha256": "4ccb7c184becccf9bbf64220861555b0b237504f51f094bab1ed0ac372c19d53", - "bytes": 287177 + "sha256": "7a59aa36564fb056def3453324f376bb0580fdeee99a32d4a3180a22fd16bebc", + "bytes": 305380 }, { "path": "backend/tests/test_deployment_auto_rollback.py", @@ -869,8 +879,8 @@ }, { "path": "backend/tests/test_enrollment_plane.py", - "sha256": "b6ddf4e4407910a2bb9728993cd53c01624d0c709b5f295b764ed87eeed921d7", - "bytes": 19682 + "sha256": "5979d8922fa03c0954b0c928b263278c1123c48a2f5dda701d543ff4eb1ccffa", + "bytes": 26847 }, { "path": "backend/tests/test_exposure_preferences.py", @@ -917,6 +927,16 @@ "sha256": "52533816bc0147fc9ae51710a37357fd50f05434bb6ecb7df959ecdafb2f2202", "bytes": 12873 }, + { + "path": "backend/tests/test_managed_appdata_root.py", + "sha256": "87c364e61acc980101948be82d0c9ef81cddff6472f40d15b1cae9b7d489794f", + "bytes": 13552 + }, + { + "path": "backend/tests/test_managed_storage_action.py", + "sha256": "5a6638724dbd06384040971beb1513ec186c042c617f5292467e9415bea84c50", + "bytes": 11900 + }, { "path": "backend/tests/test_managed_validation_checkout.py", "sha256": "92239d5c8760034c145a917534dd4622d507af334eaa0c10db3a310b53bae482", @@ -929,14 +949,24 @@ }, { "path": "backend/tests/test_mcp_hub_deployment_reconciliation.py", - "sha256": "54b3859f77cec92ecbe60d455c50524640878161570a648febf9c9037bddccf1", - "bytes": 13332 + "sha256": "0400d0b0ca31a62d869166b9368fa0c018c0e44edba98387d12633976988da29", + "bytes": 14176 + }, + { + "path": "backend/tests/test_mcp_hub_governed_topology.py", + "sha256": "f521c7d843f7d5fe9a5ac3d68bde1f072f38991d5e30e8f9bea72b77061c79f8", + "bytes": 106551 }, { "path": "backend/tests/test_mcp_hub_policy_reconciliation.py", "sha256": "50cf920443570240331d8851195bdd3140eeb93397b938c7f78b35749f6b7399", "bytes": 5041 }, + { + "path": "backend/tests/test_mcp_hub_release_store.py", + "sha256": "91dbd0da6a131f76a04c51f93e31a77f4acd057f788f4b1a6fce75d37c23525e", + "bytes": 18199 + }, { "path": "backend/tests/test_mcp_rag_n8n_integrations.py", "sha256": "0b2891cd4b545ced2e737e4caa1506ceedecb4600247223b3490749213348fba", @@ -1099,8 +1129,8 @@ }, { "path": "backend/tests/test_repopulse_controller_adoption_store.py", - "sha256": "bca0ffdb89b153f667dd6fb055509a2c8503920f22012912e389658bb3cbdacc", - "bytes": 41832 + "sha256": "e63025aea8d587d072026e7e5643c5c5d764b875275a84e23004f4450b24d34b", + "bytes": 41807 }, { "path": "backend/tests/test_repopulse_legacy_adoption_protocol.py", @@ -1229,8 +1259,8 @@ }, { "path": "backend/tests/test_user_auth.py", - "sha256": "e338549947f7f278d379756d78f22e82f554362c30a3bb964d41caddbf6b506b", - "bytes": 10618 + "sha256": "80e96c6544afcc0569245862c877553907c79bbf80cf5cab320e0b15fb3c20d6", + "bytes": 35017 }, { "path": "backend/tests/test_widefrog_connector.py", @@ -1274,13 +1304,13 @@ }, { "path": "pnpm-lock.yaml", - "sha256": "281ba56e326813ea51eb0cc1c6aeee0d0ec221a1f999893ab9785a3660746d15", - "bytes": 215211 + "sha256": "f699c9ed2c5649b0d41f0e01420ef377161b08ea163c58fd00c5d09cf696fe0d", + "bytes": 215991 }, { "path": "pnpm-workspace.yaml", - "sha256": "b059f783c0ec992afc463b9d327611321a44ce054100ea36eac8b7980d2ac45c", - "bytes": 8906 + "sha256": "bd8c09cbbfdf628ec0c5cce6ceee452d6c8b75373f61da28f347b157fb96a7d4", + "bytes": 9120 }, { "path": "postcss.config.js", @@ -1354,8 +1384,8 @@ }, { "path": "src/api/liveClient.ts", - "sha256": "78d28c6778caace577fe465903d9dc7b8f328d261d6f4399aea97a845880f4c6", - "bytes": 61657 + "sha256": "12536177fde01cc870bc6aa24d31d922db9341ef2842c4ef244fe0cee4de98c3", + "bytes": 62080 }, { "path": "src/api/userSession.test.ts", @@ -1417,6 +1447,11 @@ "sha256": "fd4f8deab1c1397e087400cb57d9f11ae0e8bc366f9f2219f6aa2e513584e11b", "bytes": 6514 }, + { + "path": "src/components/ManagedStorageSettings.tsx", + "sha256": "660f3445d3307e08eb54913c0e1665e59bad8a7a1cb42548961cbef372d79bf9", + "bytes": 22209 + }, { "path": "src/components/MediaPipelineFlowDiagram.tsx", "sha256": "0fc9113adc47648cbcf9236ce9ed6cd54a36fa7f078d6010d4d034aa442e9ea6", @@ -2042,6 +2077,16 @@ "sha256": "c303e620f58ec8714e8bed2c77d7cb3cf02c6957bda0d7f3790a0351b71ab59d", "bytes": 845 }, + { + "path": "src/lib/enrollmentStorageView.test.ts", + "sha256": "879253b9abf373052f4b9abea95d0403f83299b8bd0a9408d38f78104d030e97", + "bytes": 8947 + }, + { + "path": "src/lib/enrollmentStorageView.ts", + "sha256": "549163b4ec2c51a62515ccfef293b51876bea7cbabbe8b1ed3ff74dedc956d68", + "bytes": 11255 + }, { "path": "src/lib/exposureView.test.ts", "sha256": "a2b91f8d2f0a3536e1e930b2be8dcc73c0e424d2e43ff0ca8b34c56ee3a9a005", @@ -2654,8 +2699,8 @@ }, { "path": "src/pages/Settings.tsx", - "sha256": "3f1f336a191975547085c24a264023b1263d152bc53308b63eca5ddc848dfb3c", - "bytes": 28127 + "sha256": "14dd631e2cdbeb39e34d3a587106a434a281f71b9d5c86b5aefcac12d8674ad0", + "bytes": 28242 }, { "path": "src/pages/Setup.tsx", @@ -2729,8 +2774,8 @@ }, { "path": "src/types/appops.ts", - "sha256": "4b57009e8fe4bf8e83395da9d02eb88c2b5d841e057d975cbe12938307ccf055", - "bytes": 72561 + "sha256": "5ca792943c3d25d8c6180911c46f1ed8e3ea5e3c1993d7542d01690e81030036", + "bytes": 73737 }, { "path": "src/utils/toast.ts", diff --git a/backend/app/api/routes_actions.py b/backend/app/api/routes_actions.py index 113641a..2456679 100644 --- a/backend/app/api/routes_actions.py +++ b/backend/app/api/routes_actions.py @@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, ConfigDict, Field from sqlmodel import Session, select -from ..db.session import get_session from ..db.models import App, AppSetting, utc_now -from ..services.audit_logger import log_event -from ..services.container_restart_service import execute_container_restart -from ..services.policy_engine import evaluate_policy +from ..db.session import get_session +from ..services.action_execution import ( + action_execution_profile, + execute_governed_action, +) from ..services.action_registry import ( - ACTIONS, ACTION_RISK, + ACTIONS, action_blast_radius, action_capability, action_category, @@ -24,7 +25,9 @@ from ..services.action_registry import ( action_safeguards, ) from ..services.approval_service import create_approval -from ..services.action_execution import action_execution_profile, execute_governed_action +from ..services.audit_logger import log_event +from ..services.container_restart_service import execute_container_restart +from ..services.policy_engine import evaluate_policy from ..services.preflight_service import build_preflight router = APIRouter(prefix='/api/actions') @@ -446,6 +449,7 @@ def execute(payload: ActionRequest, session: Session = Depends(get_session), ide }) if action in { + 'controller.managed-storage.initialize', 'deployment.targets.sync', 'deployment.target.deploy_workmesh', 'deployment.target.register_portfolio', diff --git a/backend/app/api/routes_enrollments.py b/backend/app/api/routes_enrollments.py index e47d047..112528f 100644 --- a/backend/app/api/routes_enrollments.py +++ b/backend/app/api/routes_enrollments.py @@ -2,20 +2,37 @@ from __future__ import annotations +from copy import deepcopy from typing import Annotated, Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict +from sqlalchemy import insert as sql_insert +from sqlalchemy import update as sql_update +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session from ..core.security import require_operator_request +from ..core.time import utc_now +from ..db.models import AppSetting from ..db.session import get_session -from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 -from ..services.enrollment_plane import EnrollmentError, EnrollmentPlane +from ..schemas.enrollment import ( + LEGACY_APPDATA_ROOT, + EnrollmentPlanV1, + EnrollmentStorageRootSelectionV1, + EnrollmentTrustConfigV1, +) +from ..services.audit_logger import log_event +from ..services.enrollment_plane import ( + TRUST_SETTING_KEY, + EnrollmentError, + EnrollmentPlane, +) from ..services.repopulse_adoption_journal import ( RepoPulseAdoptionJournalError, get_repopulse_adoption_status, ) +from ..services.user_access import current_user def _require_operator(request: Request) -> str: @@ -58,10 +75,156 @@ def enrollment_trust_status(session: EnrollmentSession): @router.put("/trust") -def update_enrollment_trust(payload: EnrollmentTrustConfigV1, session: EnrollmentSession): +def update_enrollment_trust( + payload: EnrollmentTrustConfigV1, + session: EnrollmentSession, + request: Request, +): + actor = current_user(request) + if actor.role != "admin": + row = session.get(AppSetting, TRUST_SETTING_KEY) + original_value = deepcopy(row.value_json) if row is not None else None + original_updated_at = row.updated_at if row is not None else None + if row is None or row.is_secret: + existing = None + else: + try: + existing = EnrollmentTrustConfigV1.model_validate(original_value) + except ValueError: + existing = None + if row is None: + # Preserve initial legacy setup for settings.manage users, but the + # fixed managed-root opt-in must be made by an administrator. + selector_changed = ( + payload.trusted_appdata_root != LEGACY_APPDATA_ROOT + or payload.managed_appdata_backup_acknowledged is not False + ) + elif existing is None: + # A corrupt stored trust document has no safe selector baseline. + selector_changed = True + else: + selector_changed = ( + payload.trusted_appdata_root != existing.trusted_appdata_root + or payload.managed_appdata_backup_acknowledged + != existing.managed_appdata_backup_acknowledged + ) + if selector_changed: + raise HTTPException( + status_code=403, + detail="Administrator role is required to change the appdata root.", + ) + try: + if row is None: + # A concurrent administrator may create the trust row after the + # absence check. The unique key makes this insert a safe CAS: + # losing the race is a conflict, never an overwrite. + session.execute( + sql_insert(AppSetting).values( + key=TRUST_SETTING_KEY, + value_json=payload.model_dump(mode="json"), + is_secret=False, + updated_at=utc_now(), + ) + ) + else: + result = session.execute( + sql_update(AppSetting) + .where(AppSetting.key == TRUST_SETTING_KEY) + .where(AppSetting.updated_at == original_updated_at) + .where(AppSetting.value_json == original_value) + .values( + value_json=payload.model_dump(mode="json"), + is_secret=False, + updated_at=utc_now(), + ) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="enrollment_trust_changed") + session.commit() + except HTTPException: + raise + except SQLAlchemyError: + session.rollback() + raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None + session.expire_all() + return EnrollmentPlane.trust_status(session) return EnrollmentPlane.set_trust_config(session, payload) +@router.patch("/trust/storage-root") +def update_enrollment_storage_root( + payload: EnrollmentStorageRootSelectionV1, + session: EnrollmentSession, + request: Request, +): + """Change only the fixed storage selector and persist its backup acknowledgement.""" + if current_user(request).role != "admin": + raise HTTPException(status_code=403, detail="Administrator role is required.") + trust = EnrollmentPlane.get_trust_config(session) + row = session.get(AppSetting, TRUST_SETTING_KEY) + if trust is None or row is None: + raise HTTPException(status_code=409, detail="enrollment_trust_not_configured") + + original_value = deepcopy(row.value_json) + original_updated_at = row.updated_at + try: + stored_trust = EnrollmentTrustConfigV1.model_validate(original_value) + except ValueError: + raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None + if stored_trust.model_dump(mode="json") != trust.model_dump(mode="json"): + raise HTTPException(status_code=409, detail="enrollment_trust_changed") + updated = EnrollmentTrustConfigV1.model_validate({ + **original_value, + **payload.model_dump(mode="json"), + }) + before = { + "trustedAppdataRoot": trust.trusted_appdata_root, + "managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged, + } + after = { + "trustedAppdataRoot": updated.trusted_appdata_root, + "managedAppdataBackupAcknowledged": updated.managed_appdata_backup_acknowledged, + } + try: + result = session.execute( + sql_update(AppSetting) + .where(AppSetting.key == TRUST_SETTING_KEY) + .where(AppSetting.updated_at == original_updated_at) + .where(AppSetting.value_json == original_value) + .values( + value_json=updated.model_dump(mode="json"), + is_secret=False, + updated_at=utc_now(), + ) + .execution_options(synchronize_session=False) + ) + except SQLAlchemyError: + session.rollback() + raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None + if result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="enrollment_trust_changed") + session.expire(row) + log_event( + session, + source="settings", + identity="manual", + action_id="enrollment.trust.storage_root.update", + target="tower/deployment-controller", + risk="high", + policy_decision="allowed", + reason="Authenticated settings.manage operator selected a fixed appdata root and recorded its backup acknowledgement.", + result="updated", + target_type="controller", + before_state=before, + after_state=after, + redacted_metadata={"backupLocation": "dockerRootDir" if updated.managed_appdata_backup_acknowledged else None}, + ) + return EnrollmentPlane.trust_status(session) + + @router.post("") def request_enrollment( payload: EnrollmentRequestV1, diff --git a/backend/app/schemas/deployment.py b/backend/app/schemas/deployment.py index 87ebcc9..08e2962 100644 --- a/backend/app/schemas/deployment.py +++ b/backend/app/schemas/deployment.py @@ -34,7 +34,9 @@ _SAFE_PATH = re.compile( r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?:[A-Za-z0-9]|\.[A-Za-z0-9])[A-Za-z0-9_./-]{0,239}$" ) -TRUSTED_APPCONFIG_ROOT = "/srv/example/appdata" +LEGACY_APPCONFIG_ROOT = "/srv/example/appdata" +MANAGED_APPCONFIG_ROOT = "/var/lib/docker/volumes/itworx-managed-appdata/_data" +TRUSTED_APPCONFIG_ROOTS = (LEGACY_APPCONFIG_ROOT, MANAGED_APPCONFIG_ROOT) class _StrictModel(BaseModel): @@ -177,6 +179,10 @@ class DeploymentPlanV1(_StrictModel): image_repository: str image_tag: str topology: DeploymentTopologyV1 + trusted_appdata_root: Literal[ + "/srv/example/appdata", + "/var/lib/docker/volumes/itworx-managed-appdata/_data", + ] = LEGACY_APPCONFIG_ROOT proxy: ProxyHostV1 | None = None oidc: OidcProvisioningV1 | None = None health_path: str = "/healthz" @@ -257,7 +263,7 @@ class DeploymentPlanV1(_StrictModel): @property def appdata_path(self) -> str: - return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}" + return f"{self.trusted_appdata_root}/{self.application_id}" @property def public_url(self) -> str | None: diff --git a/backend/app/schemas/enrollment.py b/backend/app/schemas/enrollment.py index ec678a5..0178f0c 100644 --- a/backend/app/schemas/enrollment.py +++ b/backend/app/schemas/enrollment.py @@ -29,6 +29,9 @@ DeploymentProfileName = Literal[ "repopulse-authenticated", ] +LEGACY_APPDATA_ROOT = "/srv/example/appdata" +MANAGED_DOCKER_APPDATA_ROOT = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + class _StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -218,6 +221,10 @@ class EnrollmentPlanV1(_StrictModel): storage: EnrollmentStorageV1 network: EnrollmentNetworkV1 artifact: EnrollmentArtifactV1 + trusted_appdata_root: Literal[ + "/srv/example/appdata", + "/var/lib/docker/volumes/itworx-managed-appdata/_data", + ] = "/srv/example/appdata" exposure: EnrollmentExposureV1 | None = None authentication: EnrollmentAuthenticationV1 = EnrollmentAuthenticationV1() secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1() @@ -271,7 +278,8 @@ class EnrollmentPlanV1(_StrictModel): if expects_oidc != self.secrets.oidc_client_secret_required: raise ValueError("OIDC secret requirement must match the selected profile") if self.profile == "repopulse-authenticated" and ( - self.application_id != "repopulse" + self.trusted_appdata_root != "/srv/example/appdata" + or self.application_id != "repopulse" or self.repository != "jens/repopulse" or self.environment != "production" or self.target.container_name != "repopulse" @@ -295,7 +303,7 @@ class EnrollmentPlanV1(_StrictModel): @property def appdata_path(self) -> str: - return f"/srv/example/appdata/{self.application_id}" + return f"{self.trusted_appdata_root}/{self.application_id}" @property def data_path(self) -> str: @@ -311,7 +319,11 @@ class EnrollmentTrustConfigV1(_StrictModel): controller_identity: str bootstrap_secret_ref: str - trusted_appdata_root: Literal["/srv/example/appdata"] = "/srv/example/appdata" + trusted_appdata_root: Literal[ + LEGACY_APPDATA_ROOT, + MANAGED_DOCKER_APPDATA_ROOT, + ] = LEGACY_APPDATA_ROOT + managed_appdata_backup_acknowledged: bool = Field(default=False, strict=True) approved_networks: tuple[str, ...] = () allowed_domain_suffixes: tuple[str, ...] = () autonomous_profiles: tuple[DeploymentProfileName, ...] = () @@ -372,4 +384,26 @@ class EnrollmentTrustConfigV1(_StrictModel): for group in groups: if group is not None and not re.fullmatch(r"[A-Za-z0-9 _.-]{2,100}", group): raise ValueError("OIDC role group name is invalid") + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + if self.trusted_appdata_root == managed_root and not self.managed_appdata_backup_acknowledged: + raise ValueError("managed Docker-root storage requires explicit backup acknowledgement") + if self.trusted_appdata_root != managed_root and self.managed_appdata_backup_acknowledged: + raise ValueError("Docker-root backup acknowledgement is valid only for the managed root") + return self + + +class EnrollmentStorageRootSelectionV1(_StrictModel): + """Narrow operator selection for the fixed managed storage root.""" + + trusted_appdata_root: Literal[ + LEGACY_APPDATA_ROOT, + MANAGED_DOCKER_APPDATA_ROOT, + ] + managed_appdata_backup_acknowledged: bool = Field(strict=True) + + @model_validator(mode="after") + def require_managed_backup_acknowledgement(self) -> EnrollmentStorageRootSelectionV1: + managed = self.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT + if managed != self.managed_appdata_backup_acknowledged: + raise ValueError("managed Docker-root storage requires explicit backup acknowledgement") return self diff --git a/backend/app/services/action_execution.py b/backend/app/services/action_execution.py index 27c5c29..52733a6 100644 --- a/backend/app/services/action_execution.py +++ b/backend/app/services/action_execution.py @@ -21,6 +21,7 @@ from .setup_service import apply_stored_connector_settings GENERIC_EXECUTOR_ACTIONS = { "container.restart", "controller.artifact.promote", + "controller.managed-storage.initialize", "deployment.targets.sync", "deployment.runnerless.provision", "deployment.target.deploy_workmesh", @@ -69,6 +70,10 @@ APPROVAL_EXECUTOR_ACTIONS = { "unraid.vm.pause", } +MCP_TYPED_EXECUTOR_ACTIONS = APPROVAL_EXECUTOR_ACTIONS | { + "controller.managed-storage.initialize", +} + HUMAN_APPROVAL_ONLY_ACTIONS = {"deployment.target.promote_workmesh", "privatemuse.comfyui.install_artifact"} _APPROVAL_STEPS = { @@ -148,7 +153,7 @@ def approval_execution_supported(action: str) -> bool: def mcp_execution_supported(action: str) -> bool: """MCP is a no-human-approval surface; never admit human-only executors.""" - return approval_execution_supported(action) and action not in HUMAN_APPROVAL_ONLY_ACTIONS + return action in MCP_TYPED_EXECUTOR_ACTIONS and action not in HUMAN_APPROVAL_ONLY_ACTIONS def _execute_deployment_target_sync( @@ -1758,7 +1763,7 @@ def execute_governed_action( small allowlist of typed rollback/post-check capable executors below is reachable. """ - if not approval_execution_supported(action): + if not mcp_execution_supported(action): return { "success": False, "status": "failed", @@ -1786,6 +1791,24 @@ def execute_governed_action( original_policy_decision=original_policy_decision, ) + if action == "controller.managed-storage.initialize": + if target != "tower/deployment-controller": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Managed storage initialization accepts only tower/deployment-controller.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_managed_appdata_initialize( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + if action == "runner.fleet.upgrade_v2": if target.strip().lower() != "tower/runner-fleet-v2": return { @@ -2058,6 +2081,161 @@ def execute_governed_action( } +def _execute_managed_appdata_initialize( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + """Initialize only the fixed storage marker after trust and policy opt-in.""" + from .deployment_broker import DeploymentBrokerClient + from .enrollment_plane import EnrollmentPlane + from .secret_store import get_secret + + action = "controller.managed-storage.initialize" + target = "tower/deployment-controller" + trust = EnrollmentPlane.get_trust_config(session) + if ( + trust is None + or trust.trusted_appdata_root != "/var/lib/docker/volumes/itworx-managed-appdata/_data" + or trust.managed_appdata_backup_acknowledged is not True + ): + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="controller", + risk=risk, + policy_decision="allowed", + reason="Managed Docker-root storage requires an explicit trusted selector and backup acknowledgement.", + result="blocked", + redacted_metadata={"executor": action, "trustOptIn": False}, + ) + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Managed storage is not explicitly selected and acknowledged in enrollment trust.", + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False) + if not bootstrap_secret: + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="controller", + risk=risk, + policy_decision="allowed", + reason="Controller bootstrap capability is unavailable by its configured reference.", + result="failed", + redacted_metadata={"executor": action, "secretMaterialReturned": False}, + ) + return { + "success": False, + "status": "failed", + "result": "failed", + "message": "Controller bootstrap capability is unavailable.", + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + steps = [ + "Verify the exact target, trust selector and backup acknowledgement", + "Authenticate the fixed controller request with bootstrap HMAC and replay protection", + "Validate the protected controller, target registry, repository root and fixed Docker volume", + "Initialize the selector only if the managed Docker volume is empty and unchanged", + "Re-read the fixed selector and record sanitized controller readiness", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Validating fixed managed-storage initialization prerequisites.") + try: + result = DeploymentBrokerClient().initialize_managed_appdata(secret=bootstrap_secret) + reported_status = result.get("status") + status = ( + reported_status + if isinstance(reported_status, str) + and reported_status in {"initialized", "already_initialized"} + else "refused" + ) + success = ( + status in {"initialized", "already_initialized"} + and result.get("trustedAppdataRoot") == trust.trusted_appdata_root + and result.get("backupLocation") == "dockerRootDir" + ) + message = ( + "Managed storage selector initialized and verified." + if success + else "Managed storage initialization was refused or unavailable." + ) + except Exception: + result = {"status": "error"} + status = "error" + success = False + message = "Managed storage initialization was refused or unavailable." + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="controller", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "trustedAppdataRoot": trust.trusted_appdata_root if success else None, + "backupLocation": "dockerRootDir" if success else None, + }, + redacted_metadata={ + "executor": action, + "target": target, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretMaterialReturned": False, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + def execute_approved_action(session: Session, approval: Approval) -> dict[str, Any]: if not approval_execution_supported(approval.action): return { diff --git a/backend/app/services/action_registry.py b/backend/app/services/action_registry.py index 820e0b4..6babb87 100644 --- a/backend/app/services/action_registry.py +++ b/backend/app/services/action_registry.py @@ -8,6 +8,7 @@ ACTIONS = [ 'container.stop', 'runner.fleet.upgrade_v2', 'controller.artifact.promote', + 'controller.managed-storage.initialize', 'gateway.clear_cache', 'stack.status', 'stack.redeploy', @@ -214,6 +215,7 @@ ACTION_RISK = { 'container.stop': 'high', 'runner.fleet.upgrade_v2': 'high', 'controller.artifact.promote': 'high', + 'controller.managed-storage.initialize': 'high', 'gateway.clear_cache': 'high', 'stack.status': 'low', 'stack.redeploy': 'high', @@ -525,6 +527,8 @@ def action_category(action: str) -> str: def action_default_target(action: str) -> str: if action == 'controller.artifact.promote': return 'tower/deployment-controller' + if action == 'controller.managed-storage.initialize': + return 'tower/deployment-controller' if action == 'runner.fleet.upgrade_v2': return 'tower/runner-fleet-v2' if action == 'deployment.targets.sync': diff --git a/backend/app/services/controller_artifact_promoter.py b/backend/app/services/controller_artifact_promoter.py index 442e6f1..e7d88ef 100644 --- a/backend/app/services/controller_artifact_promoter.py +++ b/backend/app/services/controller_artifact_promoter.py @@ -39,12 +39,12 @@ def helper_container_spec(*, revision: str, image_id: str) -> dict[str, Any]: raise ValueError("controller_promoter_image_invalid") command = f"""set -Eeuo pipefail +umask 077 revision='{revision}' version='{CONTROLLER_VERSION}' repo='/gitea-repositories/jens/unraid-appops-gateway.git' -work='/mnt/cache/appdata/gitea-deploy-runners/.controller-promoter-{revision[:12]}' -rm -rf -- "$work" -install -d -m 0700 "$work" +work="$(mktemp -d -p /tmp 'itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX')" +[[ -d "$work" && ! -L "$work" && "$(stat -c '%u:%a' "$work")" == '0:700' ]] cleanup() {{ rm -rf -- "$work"; }} trap cleanup EXIT command -v git >/dev/null @@ -59,7 +59,10 @@ git --git-dir="$repo" archive --format=tar "$revision" \ ops/gitea-deploy-broker \ scripts/prepare_controller_artifact.py \ scripts/reconcile_controller_state.py \ - scripts/run_repopulse_adoption.py > "$work/controller.tar" + scripts/run_repopulse_adoption.py \ + scripts/prepare_controller_hub_release_volume.py \ + scripts/upgrade_deployment_controller.sh > "$work/controller.tar" +chmod 0600 "$work/controller.tar" archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)" git --git-dir="$repo" show "$revision:scripts/upgrade_deployment_controller.sh" > "$work/upgrade.sh" chmod 0700 "$work/upgrade.sh" @@ -70,6 +73,7 @@ bash "$work/upgrade.sh" \ --source-revision "$revision" \ --source-tree "$tree" \ --controller-version "$version" \ + --image-id '{image_id}' \ --confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2 """ @@ -88,6 +92,7 @@ bash "$work/upgrade.sh" \ "/mnt/cache:/mnt/cache", "/mnt/user:/mnt/user", "/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro", + "/var/lib/docker:/var/lib/docker:ro", "/var/run/docker.sock:/var/run/docker.sock", ], "NetworkMode": "host", diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py index 3c7a5c6..92b509d 100644 --- a/backend/app/services/deployment_broker.py +++ b/backend/app/services/deployment_broker.py @@ -311,6 +311,18 @@ class DeploymentBrokerClient: payload={}, ) + def initialize_managed_appdata(self, *, secret: str) -> dict[str, Any]: + """Initialize only the fixed managed storage root through bootstrap HMAC.""" + return self._request( + "POST", + "/v1/appops/managed-storage/initialize", + secret=secret, + payload={ + "apiVersion": "itworx.managed-storage-initialize/v1", + "target": "tower/deployment-controller", + }, + ) + def repopulse_adoption_capabilities(self, *, secret: str) -> dict[str, Any]: """Read fixed RepoPulse adoption readiness through bootstrap HMAC v2.""" return self._request( diff --git a/backend/app/services/enrollment_plane.py b/backend/app/services/enrollment_plane.py index cc6eedc..331a245 100644 --- a/backend/app/services/enrollment_plane.py +++ b/backend/app/services/enrollment_plane.py @@ -22,7 +22,12 @@ from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector from ..connectors.portainer import PortainerConnector from ..core.time import utc_now from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy -from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 +from ..schemas.enrollment import ( + LEGACY_APPDATA_ROOT, + MANAGED_DOCKER_APPDATA_ROOT, + EnrollmentPlanV1, + EnrollmentTrustConfigV1, +) from .audit_logger import log_event from .deployment_broker import DeploymentBrokerClient from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary @@ -40,6 +45,47 @@ _TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"} _CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"} _PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"} _OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"} +_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"} + + +def _trusted_appdata_roots_readiness( + controller_capability: dict[str, Any], +) -> list[dict[str, str]]: + roots = (LEGACY_APPDATA_ROOT, MANAGED_DOCKER_APPDATA_ROOT) + unavailable = {root: "unavailable" for root in roots} + reported = controller_capability.get("availableTrustedAppdataRoots") + if isinstance(reported, list): + if len(reported) != len(roots): + return [{"root": root, "status": unavailable[root]} for root in roots] + statuses: dict[str, str] = {} + for item in reported: + if not isinstance(item, dict): + return [{"root": root, "status": unavailable[root]} for root in roots] + root = item.get("root") + status = item.get("status") + if ( + not isinstance(root, str) + or root not in unavailable + or root in statuses + or not isinstance(status, str) + or status not in _APPDATA_ROOT_STATUSES + ): + return [{"root": expected, "status": unavailable[expected]} for expected in roots] + statuses[root] = status + if set(statuses) != set(roots): + return [{"root": root, "status": unavailable[root]} for root in roots] + return [{"root": root, "status": statuses[root]} for root in roots] + + # A legacy V1 controller reports only its configured root. It cannot attest + # independent managed-volume readiness, so only its exact legacy root can + # be marked ready through this compatibility path. + if "availableTrustedAppdataRoots" not in controller_capability: + if ( + controller_capability.get("status") == "ready" + and controller_capability.get("trustedAppdataRoot") == LEGACY_APPDATA_ROOT + ): + unavailable[LEGACY_APPDATA_ROOT] = "ready" + return [{"root": root, "status": unavailable[root]} for root in roots] class EnrollmentError(ValueError): @@ -135,6 +181,8 @@ class EnrollmentPlane: "status": "needs_setup", "blockers": ["enrollment_trust_not_configured"], "configured": False, + "trustedAppdataRoot": LEGACY_APPDATA_ROOT, + "managedAppdataBackupAcknowledged": False, "secretStore": storage, } return { @@ -143,6 +191,7 @@ class EnrollmentPlane: "configured": True, "controllerIdentity": trust.controller_identity, "trustedAppdataRoot": trust.trusted_appdata_root, + "managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged, "approvedNetworks": list(trust.approved_networks), "allowedDomainSuffixes": list(trust.allowed_domain_suffixes), "autonomousProfiles": list(trust.autonomous_profiles), @@ -162,11 +211,43 @@ class EnrollmentPlane: bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False) if bootstrap_secret: controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret) + available_roots = _trusted_appdata_roots_readiness(controller_capability) + if trust is not None: + selected_root = next( + ( + item + for item in available_roots + if item["root"] == trust.trusted_appdata_root + ), + None, + ) + controller_root_matches = bool(selected_root and selected_root.get("status") == "ready") + else: + controller_root_matches = False broker_ready = bool( bootstrap and self.broker.is_configured() and controller_capability.get("status") == "ready" + and controller_root_matches ) + controller_managed_storage = controller_capability.get("managedStorage") + if not isinstance(controller_managed_storage, dict): + controller_managed_storage = {} + if trust is None or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT: + managed_storage_status = "not_selected" + managed_storage_blocker = "" + elif not trust.managed_appdata_backup_acknowledged: + managed_storage_status = "blocked" + managed_storage_blocker = "managed_appdata_backup_acknowledgement_required" + elif controller_root_matches and controller_managed_storage.get("status") == "ready": + managed_storage_status = "ready" + managed_storage_blocker = "" + elif controller_managed_storage.get("initializationAvailable") is True: + managed_storage_status = "initialization_required" + managed_storage_blocker = "managed_appdata_initialization_required" + else: + managed_storage_status = "unavailable" + managed_storage_blocker = "managed_appdata_volume_unavailable" npm_ready = self.npm.is_configured() oidc_configured = bool( trust @@ -193,9 +274,27 @@ class EnrollmentPlane: "policyProvisioning": item(configured, "policy_template_unavailable"), "rollback": item(broker_ready, "deployment_executor_unavailable"), "verification": item(broker_ready, "deployment_executor_unavailable"), + "availableTrustedAppdataRoots": available_roots, + "managedStorage": { + "status": managed_storage_status, + "blockerCode": managed_storage_blocker, + "root": MANAGED_DOCKER_APPDATA_ROOT, + "backupScope": "dockerRootDir", + "backupAcknowledged": bool( + trust and trust.managed_appdata_backup_acknowledged + ), + }, "controller": { - "status": str(controller_capability.get("status") or "unavailable"), - "reason": str(controller_capability.get("reason") or "deployment_executor_unavailable"), + "status": "ready" if broker_ready else "unavailable", + "reason": ( + "" + if broker_ready + else str(controller_capability.get("reason") or ( + "trusted_appdata_root_mismatch" + if controller_capability.get("status") == "ready" and not controller_root_matches + else "deployment_executor_unavailable" + )) + ), }, "profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")], } @@ -233,6 +332,7 @@ class EnrollmentPlane: "container_name": plan.target.container_name, "compose_file": plan.target.compose_file, "appdata_path": plan.appdata_path, + "trusted_appdata_root": plan.trusted_appdata_root, "data_path": plan.data_path, "runtime_uid": plan.storage.runtime_uid, "runtime_gid": plan.storage.runtime_gid, @@ -264,6 +364,17 @@ class EnrollmentPlane: checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary.")) else: checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment.")) + # RepoPulse is an established, fixed legacy-root exception; the + # operator's selected root applies to other new targets only. + expected_appdata_root = ( + LEGACY_APPDATA_ROOT + if plan.profile == "repopulse-authenticated" + else trust.trusted_appdata_root + ) + if plan.trusted_appdata_root == expected_appdata_root: + checks.append(_check("trusted_appdata_root", "passed", "Requested appdata root matches its fixed profile trust boundary.")) + else: + checks.append(_check("trusted_appdata_root_mismatch", "blocked", "Requested appdata root does not match the explicit operator trust boundary.")) if plan.profile in trust.autonomous_profiles: checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment.")) else: @@ -397,7 +508,15 @@ class EnrollmentPlane: checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted.")) if trust: checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured.")) - if has_secret(session, trust.bootstrap_secret_ref): + expected_appdata_root = ( + LEGACY_APPDATA_ROOT + if plan.profile == "repopulse-authenticated" + else trust.trusted_appdata_root + ) + root_matches_trust = plan.trusted_appdata_root == expected_appdata_root + if not root_matches_trust: + checks.append(_check("controller_target", "blocked", "Controller preflight was skipped because the requested appdata root is outside the profile trust boundary.")) + elif has_secret(session, trust.bootstrap_secret_ref): controller = self.broker.enrollment_preflight( self._controller_payload(plan, enrollment_id="enr-00000000000000000000"), secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "", diff --git a/backend/app/services/mcp_hub_deployment_reconciliation.py b/backend/app/services/mcp_hub_deployment_reconciliation.py index 6cb3ff8..9845657 100644 --- a/backend/app/services/mcp_hub_deployment_reconciliation.py +++ b/backend/app/services/mcp_hub_deployment_reconciliation.py @@ -10,6 +10,7 @@ from ..core.redaction import redact_message _CONTROLLER = "gitea-deploy-control" _REPOSITORY = "jens/itworx_mcp_hub" _COMPOSE_PROJECT = "itworx-mcp-hub" +_TOPOLOGY_CONTRACT = "itworx.mcp-hub-topology/17-v1" # One-time fail-closed re-attestation for the existing MCP Hub runtime. The # script never builds, restarts, recreates, stops or deploys a container. It @@ -28,11 +29,12 @@ import sys import time repository, expected_compose_project = sys.argv[1:3] -registry_path = Path("/opt/gitea-deploy/projects.json") -host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +registry_path = Path("/controller-config/projects.json") +host_registry_path = Path("/controller-hub-releases/controller-config/projects.json") state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") owned_image_source = "https://gitea.example.com/jens/itworx-mcp-hub" +topology_contract = "itworx.mcp-hub-topology/17-v1" if repository != "jens/itworx_mcp_hub" or expected_compose_project != "itworx-mcp-hub": raise SystemExit(40) @@ -69,6 +71,10 @@ expected_fixed = { "itworx-mcp-hub-web-1", "itworx-mcp-hub-worker-1", } +expected_runtime = expected_fixed | { + "itworx-mcp-hub-connector-ingestion-1", + "itworx-mcp-hub-ingestion-clamd-1", +} if ( project.get("branch") != "main" or project.get("slug") != expected_compose_project @@ -77,6 +83,7 @@ if ( or set(expected_containers) != expected_fixed ): raise SystemExit(23) +expected_containers = sorted(expected_runtime) state_path = state_root / f"{expected_compose_project}.json" project_lock_path = state_root / "locks" / f"{expected_compose_project}.lock" @@ -310,12 +317,33 @@ with project_lock: receipt = state.get("live_receipt") recorded_artifacts = state.get("live_artifacts") + registered_live_containers = ( + recorded_artifacts.get("containers") + if isinstance(recorded_artifacts, dict) + else None + ) + if isinstance(registered_live_containers, dict) and set(registered_live_containers) == expected_fixed: + print("mcp-hub-topology-migration-required", flush=True) + raise SystemExit(56) + already_reconciled = ( - state.get("live_legacy_provenance") is True - and recorded_status == "success" + recorded_status == "success" and recorded_live_status == "healthy" and re.fullmatch(r"[0-9a-f]{40}", recorded_live) is not None and isinstance(recorded_artifacts, dict) + and state.get("compose_project") == expected_compose_project + and isinstance(registered_live_containers, dict) + and set(registered_live_containers) == expected_runtime + and recorded_artifacts.get("topology_contract") == topology_contract + and ( + state.get("live_legacy_provenance") is True + or ( + isinstance(receipt, dict) + and receipt.get("schema") == "gitea-deploy-receipt.v1" + and receipt.get("repository") == repository + and receipt.get("revision") == recorded_live + ) + ) ) if not already_reconciled and ( recorded_status != "failed" @@ -513,7 +541,11 @@ with project_lock: if not drift_found: raise SystemExit(52) - artifacts = {"containers": container_artifacts, "compose_sha256": ""} + artifacts = { + "containers": container_artifacts, + "compose_sha256": "", + "topology_contract": topology_contract, + } now = int(time.time()) payload = { "schema_version": 3, @@ -678,8 +710,16 @@ class McpHubDeploymentReconciliation: 53: "reconciled MCP Hub deployment state could not be written durably", 54: "reconciled MCP Hub deployment state did not verify after write", 55: "unknown MCP Hub live status was not produced by a proven recoverable snapshot-stage failure", + 56: "MCP Hub 15-to-17 topology migration requires its guarded protected-main deployment", } if exit_code != 0: + if exit_code == 56: + return { + "status": "topology_migration_required", + "repository": _REPOSITORY, + "composeProject": _COMPOSE_PROJECT, + "reason": reasons[exit_code], + } return { "status": "validation_failed", "reason": reasons.get(exit_code, "MCP Hub deployment reconciliation failed closed"), diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py index 4c7d50f..7f128f8 100644 --- a/backend/app/services/policy_engine.py +++ b/backend/app/services/policy_engine.py @@ -142,6 +142,28 @@ def evaluate_policy( 'scope_decision': None, } + if action == "controller.managed-storage.initialize": + exact_policy = session.exec( + select(Policy).where( + (Policy.action == action) & (Policy.target == "tower/deployment-controller") + ) + ).first() + allowed = bool( + target == "tower/deployment-controller" + and source != "voice" + and exact_policy is not None + and exact_policy.manual == "allowed" + ) + return { + "decision": "allowed" if allowed else "blocked", + "reason": ( + "Managed storage initialization requires an exact-target explicit operator policy." + ), + "risk": ACTION_RISK.get(action, "high"), + "policy_id": exact_policy.id if allowed and exact_policy is not None else None, + "scope_decision": "allowed" if allowed else None, + } + if action in _DEFAULT_DECISIONS: reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.') return { diff --git a/backend/app/services/user_access.py b/backend/app/services/user_access.py index 1a03e19..143396a 100644 --- a/backend/app/services/user_access.py +++ b/backend/app/services/user_access.py @@ -170,7 +170,12 @@ def required_permission(path: str, method: str) -> str: return "settings.manage" if path.startswith(("/api/settings/connectors", "/api/connectors", "/api/setup/")): return "connectors.manage" - if path.startswith(("/api/settings/", "/api/system/")) or path in {"/api/ai/rag/reindex", "/api/knowledge/reindex", "/api/enrollments/trust"}: + if path.startswith(("/api/settings/", "/api/system/")) or path in { + "/api/ai/rag/reindex", + "/api/knowledge/reindex", + "/api/enrollments/trust", + "/api/enrollments/trust/storage-root", + }: return "settings.manage" if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")): return "inventory.manage" diff --git a/backend/tests/test_controller_artifact_promoter.py b/backend/tests/test_controller_artifact_promoter.py index a541963..cf6d9e3 100644 --- a/backend/tests/test_controller_artifact_promoter.py +++ b/backend/tests/test_controller_artifact_promoter.py @@ -38,6 +38,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): "/mnt/cache:/mnt/cache", "/mnt/user:/mnt/user", "/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro", + "/var/lib/docker:/var/lib/docker:ro", "/var/run/docker.sock:/var/run/docker.sock", } assert spec["Cmd"][:2] == ["/bin/bash", "-lc"] @@ -47,6 +48,13 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): assert revision in command assert CONTROLLER_VERSION in command assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command + assert f"itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX" in command + assert "work='/mnt/cache" not in command + assert "rm -rf -- \"$work\"" in command + assert "chmod 0600 \"$work/controller.tar\"" in command + assert "scripts/prepare_controller_hub_release_volume.py" in command + assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command + assert f"--image-id '{image_id}'" in command @pytest.mark.parametrize( diff --git a/backend/tests/test_controller_config_paths.py b/backend/tests/test_controller_config_paths.py new file mode 100644 index 0000000..ab55257 --- /dev/null +++ b/backend/tests/test_controller_config_paths.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def load_paths(): + spec = importlib.util.spec_from_file_location( + "controller_config_paths_test", ROOT / "ops/gitea-deploy-broker/controller_paths.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_production_code_uses_fixed_protected_mutable_config(): + paths = load_paths() + code = Path("/opt/gitea-deploy") + assert paths.controller_config_root(code) == Path("/controller-hub-releases/controller-config") + assert paths.controller_readonly_config_root(code) == Path("/controller-config") + assert paths.controller_config_root(code) != code + + +def test_private_install_stage_keeps_its_captured_config(tmp_path): + paths = load_paths() + stage = tmp_path / "controller" + assert paths.controller_config_root(stage) == stage + assert paths.controller_readonly_config_root(stage) == stage + + +def test_config_location_has_no_environment_override(monkeypatch): + paths = load_paths() + monkeypatch.setenv("ITWORX_CONTROLLER_CONFIG_ROOT", "/untrusted") + monkeypatch.setenv("CONTROLLER_CONFIG_ROOT", "/untrusted") + assert paths.controller_config_root(Path("/opt/gitea-deploy")) == Path( + "/controller-hub-releases/controller-config" + ) + + +def test_repopulse_store_keeps_readonly_and_mutable_config_aliases(): + paths = load_paths() + monkeypatch_path = ROOT / "ops/gitea-deploy-broker" + import sys + + if str(monkeypatch_path) not in sys.path: + sys.path.insert(0, str(monkeypatch_path)) + spec = importlib.util.spec_from_file_location( + "repopulse_config_alias_test", monkeypatch_path / "repopulse_adoption_store.py" + ) + store = importlib.util.module_from_spec(spec) + spec.loader.exec_module(store) + code = Path("/opt/gitea-deploy") + assert store.CONTROLLER_READONLY_ROOT == paths.controller_readonly_config_root(code) + assert store.CONTROLLER_RW_ROOT == paths.controller_config_root(code) + assert store.CONTROLLER_READONLY_ROOT != store.CONTROLLER_RW_ROOT + + +def test_registry_updates_use_mutable_config_and_preserve_code(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker")) + import controller_paths + + config = tmp_path / "config" + config.mkdir(mode=0o755) + registry = config / "projects.json" + registry.write_text("{}\n", encoding="utf-8") + registry.chmod(0o600) + code = ROOT / "ops/gitea-deploy-broker" + original_code = (code / "status_server.py").read_bytes() + monkeypatch.setattr(controller_paths, "controller_config_root", lambda _root: config) + spec = importlib.util.spec_from_file_location("mutable_config_controller_test", code / "status_server.py") + controller = importlib.util.module_from_spec(spec) + spec.loader.exec_module(controller) + assert controller.PROJECTS_PATH == registry + assert controller.WEBHOOK_SECRET_ROOT == config / "webhook-secrets" + assert controller.APPOPS_DEPLOYMENT_SECRET_ROOT == config / "appops-deployment-secrets" + assert controller.GITEA_VALIDATION_TOKEN_FILE == config / "gitea-validation-token" + assert controller.PRIVATE_TLS_CONFIG_PATH == config / "private-tls.json" + assert controller.CONTROLLER_RELEASE_PATH == code / "controller-release.json" + controller._write_projects({"jens/example": {"slug": "example"}}) + assert controller._load_projects() == {"jens/example": {"slug": "example"}} + assert registry.stat().st_mode & 0o777 == 0o600 + assert (code / "status_server.py").read_bytes() == original_code + + +def test_status_reads_hub_state_through_the_fixed_pinned_adapter(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker")) + import mcp_hub_release_store + + code = ROOT / "ops/gitea-deploy-broker" + spec = importlib.util.spec_from_file_location("hub_state_controller_test", code / "status_server.py") + controller = importlib.util.module_from_spec(spec) + spec.loader.exec_module(controller) + state = tmp_path / "state" + state.mkdir() + (state / "itworx-mcp-hub.json").write_text('{"live_revision":"untrusted-path"}') + (state / "ordinary-project.json").write_text('{"live_revision":"ordinary"}') + monkeypatch.setattr(controller, "STATE_ROOT", state) + monkeypatch.setattr( + mcp_hub_release_store, "read_hub_state_bytes", lambda: b'{"live_revision":"pinned"}' + ) + assert controller._deployment_state("itworx-mcp-hub") == {"live_revision": "pinned"} + assert controller._deployment_state("ordinary-project") == {"live_revision": "ordinary"} + + def unsafe(): + raise mcp_hub_release_store.ReleaseStoreError("state path changed") + + monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", unsafe) + assert controller._deployment_state("itworx-mcp-hub") == {} + + monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'[]') + assert controller._deployment_state("itworx-mcp-hub") == {} + + monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'\xff') + assert controller._deployment_state("itworx-mcp-hub") == {} diff --git a/backend/tests/test_controller_hub_release_volume.py b/backend/tests/test_controller_hub_release_volume.py new file mode 100644 index 0000000..ae6d1ca --- /dev/null +++ b/backend/tests/test_controller_hub_release_volume.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "controller_hub_volume", ROOT / "scripts/prepare_controller_hub_release_volume.py" +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +REVISION = "a" * 40 +IMAGE_ID = "sha256:" + "b" * 64 +ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n" + + +def volume(): + return { + "Name": module.VOLUME, + "Driver": "local", + "Scope": "local", + "Options": None, + "Labels": dict(module.LABELS), + "Mountpoint": module.MOUNTPOINT.as_posix(), + } + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("Name", "operator-volume"), + ("Driver", "nfs"), + ("Scope", "global"), + ("Options", {"device": "/mnt/cache/appdata"}), + ("Labels", {}), + ("Mountpoint", "/mnt/cache/unsafe"), + ], +) +def test_unknown_or_redirected_volume_is_never_adopted(key, value): + item = volume() + item[key] = value + with pytest.raises(module.VolumeTransitionError, match="identity_invalid"): + module.validate_volume(item) + + +def test_only_the_exact_local_volume_is_accepted(): + module.validate_volume(volume()) + + +def model(): + return { + "name": "gitea-deploy-runners", + "services": { + "deploy-control": { + "container_name": module.CONTAINER, + "image": "immutable-controller", + "volumes": [ + { + "type": "bind", + "source": module.DOCKER_ROOT.as_posix(), + "target": module.DOCKER_ROOT.as_posix(), + "read_only": True, + }, + { + "type": "bind", + "source": module.CONTROLLER_DIR.as_posix(), + "target": module.EXECUTABLE_TARGET, + "read_only": True, + }, + ], + "environment": {"PRIVATE_SETTING": "unchanged"}, + }, + "runner": { + "image": "pinned-runner", + "volumes": [ + {"type": "bind", "source": "/fixed/data", "target": "/data"} + ], + }, + }, + } + + +@pytest.fixture +def compose(monkeypatch): + baseline = model() + calls = [] + + def run(arguments, *, data=None, **_kwargs): + calls.append((arguments, data)) + if arguments[:3] == ["docker", "image", "inspect"]: + return IMAGE_ID.encode("ascii") + if arguments[-2:] == ["--format", "json"]: + return data if data and data.startswith(b"{") else json.dumps(baseline).encode() + return b"" + + monkeypatch.setattr(module, "_run", run) + return baseline, calls + + +def test_compose_uses_protected_code_and_two_config_views(compose): + baseline, calls = compose + rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)) + service = rendered["services"]["deploy-control"] + assert service["image"] == IMAGE_ID + assert service["pull_policy"] == "never" + volumes = service["volumes"] + assert { + "type": "bind", + "source": module._expected_revision_path(REVISION).as_posix(), + "target": module.EXECUTABLE_TARGET, + "read_only": True, + } in volumes + assert { + "type": "volume", + "source": module.VOLUME, + "target": module.TARGET, + "volume": {"nocopy": True}, + } in volumes + assert { + "type": "bind", + "source": module._expected_config_path().as_posix(), + "target": module.CONFIG_TARGET, + "read_only": True, + } in volumes + assert len(volumes) == 4 + volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)) + volumes.remove(next(item for item in volumes if item["target"] == module.TARGET)) + volumes.remove(next(item for item in volumes if item["target"] == module.CONFIG_TARGET)) + assert rendered["volumes"] == { + module.VOLUME: {"name": module.VOLUME, "external": True} + } + assert rendered["services"]["runner"] == baseline["services"]["runner"] + assert service["environment"] == baseline["services"]["deploy-control"]["environment"] + compose_calls = [ + arguments for arguments, _ in calls if arguments[:2] == ["docker", "compose"] + ] + assert compose_calls + assert all("--project-directory" in arguments for arguments in compose_calls) + assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls) + assert all("--env-file" in arguments and "/dev/null" in arguments for arguments in compose_calls) + assert all("-f" in arguments and "-" in arguments for arguments in compose_calls) + assert calls[0][1] == ORIGINAL_COMPOSE + + +@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"]) +def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage): + baseline, _ = compose + if damage == "target_collision": + baseline["services"]["deploy-control"]["volumes"].append( + {"type": "bind", "source": "/unsafe", "target": module.TARGET} + ) + elif damage == "foreign_volume": + baseline["services"]["runner"]["volumes"].append( + {"type": "volume", "source": module.VOLUME, "target": "/foreign"} + ) + elif damage == "graph_rw": + baseline["services"]["deploy-control"]["volumes"][0]["read_only"] = False + elif damage == "relative_bind": + baseline["services"]["runner"]["volumes"][0]["source"] = "./unsafe" + else: + baseline["services"]["deploy-control"]["env_file"] = [".env"] + with pytest.raises(module.VolumeTransitionError): + module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + + +def test_compose_tag_replacement_cannot_change_controller_startup_image(compose, monkeypatch): + _baseline, calls = compose + original_run = module._run + + def replaced_tag(arguments, **kwargs): + if arguments[:3] == ["docker", "image", "inspect"]: + return ("sha256:" + "c" * 64).encode("ascii") + return original_run(arguments, **kwargs) + + monkeypatch.setattr(module, "_run", replaced_tag) + with pytest.raises(module.VolumeTransitionError, match="image_reference_changed"): + module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + assert not any( + arguments[-1:] == ["deploy-control"] and "up" in arguments + for arguments, _ in calls + ) + + +def test_config_snapshot_has_exact_allowlist_and_broker_token(): + entries = { + "projects.json": (b"{}", 0o600), + "broker-token": (b"secret", 0o600), + "gitea-validation.json": (b"{}", 0o600), + "gitea-validation-token": (b"secret", 0o600), + "private-tls.json": (b"{}", 0o600), + "appops-enrollment-bootstrap": (b"secret", 0o600), + "webhook-secrets/forge": (b"secret", 0o600), + "appops-deployment-secrets/repopulse": (b"secret", 0o600), + "status_server.py": (b"code", 0o644), + } + directories = { + "": 0o755, + "webhook-secrets": 0o700, + "appops-deployment-secrets": 0o700, + } + config, config_dirs = module._config_tree(entries, directories) + assert set(config) == set(entries) - {"status_server.py"} + assert "broker-token" in config + assert config_dirs == directories + + +def test_unknown_config_file_is_rejected(): + entries = { + "projects.json": (b"{}", 0o600), + "broker-token": (b"secret", 0o600), + "gitea-validation.json": (b"{}", 0o600), + "gitea-validation-token": (b"secret", 0o600), + "shell.py": (b"unexpected", 0o600), + } + with pytest.raises(module.VolumeTransitionError, match="config_snapshot_unknown"): + module._config_tree(entries, {"": 0o755}) + + +def test_partial_config_tree_is_accepted_only_for_crash_resume(): + partial = {"projects.json": (b"{}", 0o600), "broker-token": (b"secret", 0o600)} + assert module._config_tree(partial, {"": 0o755}, allow_partial=True) == ( + partial, {"": 0o755} + ) + with pytest.raises(module.VolumeTransitionError, match="snapshot_incomplete"): + module._config_tree(partial, {"": 0o755}) + + +def test_config_snapshot_requires_private_mode_for_every_file(): + entries = { + "projects.json": (b"{}", 0o644), + "broker-token": (b"secret", 0o600), + "gitea-validation.json": (b"{}", 0o600), + "gitea-validation-token": (b"secret", 0o600), + } + with pytest.raises(module.VolumeTransitionError, match="config_file_unsafe"): + module._config_tree(entries, {"": 0o755}) + + +def test_previous_private_tls_is_validated_from_the_attested_config_snapshot( + tmp_path, monkeypatch +): + previous = tmp_path / "previous-controller" + previous.mkdir(mode=0o700) + (previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8") + (previous / "private-tls.json").write_bytes(b"captured private config") + calls = [] + monkeypatch.setattr(module, "_run", lambda arguments, **kwargs: calls.append((arguments, kwargs)) or b"") + + module._validate_previous_private_tls(tmp_path) + + assert calls == [( + ["python3", str(previous / "status_server.py"), "--check-private-tls-config"], + {"max_output": 4096}, + )] + (previous / "private-tls.json").unlink() + calls.clear() + module._validate_previous_private_tls(tmp_path) + assert calls == [] + + +@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" + previous.mkdir(mode=0o700) + (previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8") + target = tmp_path / "tls.json" + target.write_bytes(b"unsafe") + (previous / "private-tls.json").symlink_to(target) + + with pytest.raises(module.VolumeTransitionError, match="private_tls_unsafe"): + module._validate_previous_private_tls(tmp_path) + + +@pytest.mark.parametrize("kind", ["artifacts", "config"]) +def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind): + command = module._writer_spec(IMAGE_ID, REVISION, kind=kind) + assert command[0:3] == ["docker", "create", "--name"] + assert module.WRITER_NAME in command + assert IMAGE_ID in command + assert "--network" in command and command[command.index("--network") + 1] == "none" + assert "--read-only" in command and "--cap-drop" in command + assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command + assert "--mount" in command + mount = command[command.index("--mount") + 1] + assert mount == f"type=volume,source={module.VOLUME},target={module.TARGET},volume-nocopy" + assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command) + + +def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose): + baseline, calls = compose + rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + 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:] + assert calls[-2][1] == rendered + assert calls[-1][1] == rendered + assert baseline["name"] == "gitea-deploy-runners" + + +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 + assert 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE"' not in source + assert 'python3 "${work}/${PREPARER_NAME}"' in source + assert 'mv -- "$CONTROLLER_DIR"' not in source + assert 'cp -a -- "$CONTROLLER_DIR"' not in source + assert "ops/gitea-deploy-broker/mcp_hub_release_store.py" in source + + +def test_installer_release_order_matches_renderer_contract(): + renderer_spec = importlib.util.spec_from_file_location( + "controller_renderer", ROOT / "scripts/prepare_controller_artifact.py" + ) + renderer = importlib.util.module_from_spec(renderer_spec) + renderer_spec.loader.exec_module(renderer) + assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES) + assert module.RELEASE_FILES[:3] == ( + "controller_paths.py", "mcp_hub_release_store.py", "deploy.py" + ) + + +@pytest.mark.skipif(os.name == "nt", reason="dirfd swap test requires POSIX openat") +def test_mirror_updates_stay_on_the_open_directory_after_path_swap(tmp_path, monkeypatch): + original = tmp_path / "controller" + moved = tmp_path / "controller-pinned" + original.mkdir() + before_entries = { + "controller_paths.py": (b"prior paths", 0o644), + "mcp_hub_release_store.py": (b"prior Hub helper", 0o644), + } + for name, (value, _mode) in before_entries.items(): + (original / name).write_bytes(value) + prior = { + name: (f"prior:{name}".encode(), 0o644) + for name in module.LEGACY_RELEASE_FILES_2_1_13 + } + prior["controller-release.json"] = (b"prior manifest", 0o644) + updated = { + name: (f"updated:{name}".encode(), 0o644) + for name in module.RELEASE_FILES + } + updated["controller-release.json"] = (b"updated manifest", 0o644) + prior_tar = module._release_tar_bytes("b" * 40, (prior, {"": 0o755})) + updated_tar = module._release_tar_bytes("c" * 40, (updated, {"": 0o755})) + mirror_tar = module._release_tar_bytes("a" * 40, (before_entries, {"": 0o755})) + current_path = original + monkeypatch.setattr( + module, "_directory", + lambda _path, _anchor: os.open(current_path, os.O_RDONLY | os.O_DIRECTORY), + ) + read_item = module._file_info_at_optional + swapped = False + + def swap_after_first_read(descriptor, name): + nonlocal swapped + value = read_item(descriptor, name) + if not swapped: + original.rename(moved) + original.mkdir() + swapped = True + return value + + monkeypatch.setattr(module, "_file_info_at_optional", swap_after_first_read) + module._sync_controller_mirror( + prior_tar, updated_tar, mirror_tar, direction="forward" + ) + assert swapped and list(original.iterdir()) == [] + assert (moved / "controller_paths.py").read_bytes() == b"updated:controller_paths.py" + assert (moved / "mcp_hub_release_store.py").read_bytes() == b"updated:mcp_hub_release_store.py" + + current_path = moved + monkeypatch.setattr(module, "_file_info_at_optional", read_item) + module._sync_controller_mirror( + updated_tar, prior_tar, mirror_tar, direction="rollback" + ) + assert {path.name: path.read_bytes() for path in moved.iterdir()} == { + name: value for name, (value, _mode) in before_entries.items() + } + + +@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="private /tmp ownership validation requires root on POSIX") +def test_private_stage_rejects_symlinked_source_directory(): + import shutil + import uuid + + work = Path("/tmp") / f"itworx-controller-upgrade-{uuid.uuid4().hex[:12]}.Ab3xY7" + source = work / "source" + source.mkdir(parents=True, mode=0o700) + source.chmod(0o700) + work.chmod(0o700) + try: + module._validate_private_stage(source) + source.rename(work / "source-real") + source.symlink_to(work / "source-real", target_is_directory=True) + with pytest.raises(module.VolumeTransitionError, match="private_stage_invalid"): + module._validate_private_stage(source) + finally: + shutil.rmtree(work) + + +def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose): + _baseline, calls = compose + unsafe = ( + b"name: gitea-deploy-runners\nservices:\n deploy-control:\n" + b" image: x\n env_file: /tmp/secret\n" + ) + with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"): + module.rendered_model(unsafe, REVISION, IMAGE_ID) + assert calls == [] + + +def test_capture_program_is_fixed_descriptor_based_and_compiles(): + legacy = module._controller_capture_source(config_root_in_code=True) + protected = module._controller_capture_source(config_root_in_code=False) + compile(legacy, "legacy-controller-capture", "exec") + compile(protected, "protected-controller-capture", "exec") + assert "config_root='/opt/gitea-deploy'" in legacy + assert "config_root='/controller-config'" in protected + assert "os.O_NOFOLLOW" in legacy and "os.fstat(fd)" in legacy + assert "open(path,'rb')" not in legacy + + +def test_prior_release_capture_must_match_protected_git_tree(monkeypatch): + files = module.LEGACY_RELEASE_FILES_2_1_5 + entries = {name: (f"source:{name}".encode(), 0o644) for name in files} + source_revision = "32a6eece0e3e1276946abdc04196c819daf02e2a" + source_tree = "e0d7e80b79504b964d968e51e0d21c288cac8f0d" + manifest = { + "apiVersion": "itworx.controller-artifact/v1", + "controllerVersion": "2.1.5", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": source_revision, + "sourceTree": source_tree, + "artifactSha256": module._artifact_digest(entries, files), + "builtAt": "2026-10-01T00:00:00.000Z", + } + entries["controller-release.json"] = ( + json.dumps(manifest, separators=(",", ":")).encode(), 0o644 + ) + identity = module._release_identity(entries) + outputs = {"rev-parse": (source_tree + "\n").encode()} + outputs.update({ + f"ops/gitea-deploy-broker/{name}": entries[name][0] + for name in files if name != "run_repopulse_adoption.py" + }) + outputs["scripts/run_repopulse_adoption.py"] = entries["run_repopulse_adoption.py"][0] + + def run(arguments, **_kwargs): + return outputs[arguments[-1].split(":", 1)[-1]] if arguments[-2] == "show" else outputs["rev-parse"] + + monkeypatch.setattr(module, "_run", run) + module._verify_git_source_identity(entries, identity) + outputs["ops/gitea-deploy-broker/deploy.py"] = b"replaced source" + with pytest.raises(module.VolumeTransitionError, match="source_mismatch"): + module._verify_git_source_identity(entries, identity) diff --git a/backend/tests/test_controller_state_reconciliation.py b/backend/tests/test_controller_state_reconciliation.py index 6baf48c..d07bfe2 100644 --- a/backend/tests/test_controller_state_reconciliation.py +++ b/backend/tests/test_controller_state_reconciliation.py @@ -181,60 +181,55 @@ def test_successful_reconciliation_can_be_rolled_back_before_controller_swap(tmp assert not journal.exists() -def test_upgrade_stops_old_writer_before_locking_cache_and_reconciles_while_stopped(): +def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation(): script = UPGRADE_PATH.read_text(encoding="utf-8") - cache_lock = 'flock -x "$cache_lock_fd"' - stop = 'writers_stopped=1\ndocker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' - reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root' - backup = 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' - swap = 'mv -- "$CONTROLLER_DIR" "$previous"' + prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\' + stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"' + reconcile = 'python3 "${work}/reconcile_controller_state.py" \\\n --legacy-root' + activate = 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"' assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script - assert script.index(stop) < script.index(cache_lock) - assert script.index(stop) < script.index(backup) - assert script.index(cache_lock) < script.index(backup) - assert script.index(backup) < script.index(reconcile) - assert script.index(reconcile) < script.index(swap) - assert 'flock -u "$cache_lock_fd"' not in script + assert script.index(prepare) < script.index(stop) + assert script.index(stop) < script.index(reconcile) + assert script.index(reconcile) < script.index(activate) + assert 'flock -x "$promotion_lock_fd"' in script + assert 'mv -- "$CONTROLLER_DIR"' not in script + assert 'cp -a -- "$CONTROLLER_DIR"' not in script -def test_upgrade_retains_and_validates_optional_private_tls_before_stopping_controller(): +def test_upgrade_prepares_and_validates_captured_optional_private_tls_before_stopping_controller(): script = UPGRADE_PATH.read_text(encoding="utf-8") - stage_copy = 'cp -a -- "$CONTROLLER_DIR" "${stage}/controller"' - tls_gate = ( - 'if [[ -e "${stage}/controller/private-tls.json" ' - '|| -L "${stage}/controller/private-tls.json" ]]; then\n' - ' python3 "${stage}/controller/status_server.py" --check-private-tls-config\n' - 'fi' - ) - stop = ( - 'writers_stopped=1\ndocker compose --project-directory "$ROOT" ' - '-f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' - ) + prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\' + stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"' + preparer = (ROOT / "scripts/prepare_controller_hub_release_volume.py").read_text(encoding="utf-8") - assert stage_copy in script - assert script.index(stage_copy) < script.index(tls_gate) - assert script.index(tls_gate) < script.index(stop) - assert 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' in script + assert "_validate_previous_private_tls(stage_root)" in preparer + assert script.index(prepare) < script.index(stop) def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer(): script = UPGRADE_PATH.read_text(encoding="utf-8") restore = script[script.index("restore_previous() {") : script.index("cleanup() {")] - stop = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' + stop = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup" --stop-only; then' rollback = '--rollback' - restore_controller = 'mv -- "$previous" "$CONTROLLER_DIR"' - start = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" up -d --force-recreate "$COMPOSE_SERVICE"' + restore_controller = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup"; then' assert restore.index(stop) < restore.index(rollback) assert restore.index(rollback) < restore.index(restore_controller) - assert restore.index(restore_controller) < restore.index(start) - assert "the old writer will remain stopped" in script - reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root' - assert script.index("reconciliation_active=1", script.index("writers_stopped=1")) < script.index(reconcile) + assert "controller remains stopped" in script + assert 'mv -- "$previous"' not in restore + assert 'docker compose --project-directory "$ROOT"' not in restore + reconcile = 'python3 "${work}/reconcile_controller_state.py"' + stopped = script.index("writers_stopped=1", script.index(stop)) + reconcile_index = script.index(reconcile, stopped) + activate_index = script.index( + 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"', + reconcile_index, + ) + assert stopped < reconcile_index < activate_index assert '[[ -f "$migration_journal" && ! -L "$migration_journal" ]]' in restore assert '[[ -e "$migration_journal" || -L "$migration_journal" ]]' in restore diff --git a/backend/tests/test_deploy_broker.py b/backend/tests/test_deploy_broker.py index 59ca4af..4871382 100644 --- a/backend/tests/test_deploy_broker.py +++ b/backend/tests/test_deploy_broker.py @@ -2226,6 +2226,96 @@ def test_managed_repopulse_render_accepts_only_its_read_only_source_mount( deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse") +def test_managed_appdata_roots_are_fixed_and_bound_to_each_registration( + deploy_module, monkeypatch, tmp_path +): + legacy_root = tmp_path / "legacy-appdata" + managed_root = tmp_path / "managed-volume" / "_data" + monkeypatch.setattr(deploy_module, "LEGACY_MANAGED_APPCONFIG_ROOT", legacy_root) + monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_ROOT", legacy_root) + monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_VOLUME_ROOT", managed_root) + monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: False) + + assert deploy_module._registered_managed_appdata_root({}) == legacy_root + assert deploy_module._registered_managed_appdata_root( + {"managed_appdata_root": legacy_root.as_posix()} + ) == legacy_root + with pytest.raises(deploy_module.DeployError, match="not initialized and available"): + deploy_module._registered_managed_appdata_root( + {"managed_appdata_root": managed_root.as_posix()} + ) + with pytest.raises(deploy_module.DeployError, match="fixed registered roots"): + deploy_module._registered_managed_appdata_root( + {"managed_appdata_root": (tmp_path / "arbitrary").as_posix()} + ) + + monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True) + assert deploy_module._registered_managed_appdata_root( + {"managed_appdata_root": managed_root.as_posix()} + ) == managed_root + + for root, host in ((legacy_root, "legacy.example"), (managed_root, "managed.example")): + env_path = root / "demo" / "deploy.env" + env_path.parent.mkdir(parents=True) + env_path.write_text(f"APPOPS_PUBLIC_URL=https://{host}\n", encoding="utf-8") + if os.name != "nt": + env_path.chmod(0o600) + + project = { + "appops_managed_target": True, + "managed_plan_version": "itworx.deployment/v1", + "managed_profile": "internal-webapp", + "managed_application_id": "demo", + "managed_runtime_environment_keys": ["APPOPS_PUBLIC_URL"], + "env_source": (legacy_root / "demo" / "deploy.env").as_posix(), + } + assert deploy_module.managed_runtime_environment(project) == { + "APPOPS_PUBLIC_URL": "https://legacy.example" + } + project["managed_appdata_root"] = managed_root.as_posix() + project["env_source"] = (managed_root / "demo" / "deploy.env").as_posix() + assert deploy_module.managed_runtime_environment(project) == { + "APPOPS_PUBLIC_URL": "https://managed.example" + } + + +def test_managed_project_path_is_derived_from_its_stored_appdata_root( + deploy_module, monkeypatch +): + monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True) + monkeypatch.setattr(deploy_module, "git_path_exists", lambda *_args: True) + project = { + "containers": ["demo"], + "source_dir": (deploy_module.EXACT_SOURCE_ROOT / "demo").as_posix(), + "exact_source": True, + "compose_dir": ".", + "compose_files": ["compose.yml"], + "compose_project": "demo", + "managed_plan_version": "itworx.deployment/v1", + "managed_application_id": "demo", + "managed_appdata_path": "/srv/example/appdata/demo", + "managed_image_repository": "demo", + "managed_image_tag": "production", + "managed_proxy_network": "itworx-proxy", + "managed_internal_port": 8000, + "appops_deployment_secret_file": "jens__demo", + } + + deploy_module.validate_project_config(Path("repo.git"), project, "a" * 40) + managed = { + **project, + "managed_appdata_root": deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT.as_posix(), + "managed_appdata_path": ( + deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT / "demo" + ).as_posix(), + } + deploy_module.validate_project_config(Path("repo.git"), managed, "a" * 40) + + remapped = {**managed, "managed_appdata_path": "/srv/example/appdata/demo"} + with pytest.raises(deploy_module.DeployError, match="not derived"): + deploy_module.validate_project_config(Path("repo.git"), remapped, "a" * 40) + + def test_repopulse_project_registry_requires_its_fixed_build_identity( deploy_module, monkeypatch ): @@ -4582,6 +4672,25 @@ def _appops_plan(revision: str) -> dict[str, object]: } +def test_appops_plan_root_must_match_the_registered_target_root(status_module): + project = _appops_project() + plan = _appops_plan("a" * 40) + assert status_module._appops_plan({"jens/repo-a": project}, plan) == ( + "jens/repo-a", "a" * 40, project, + ) + + project["managed_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + project["managed_appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repo-a" + alternate_plan = {**plan, "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data"} + assert status_module._appops_plan({"jens/repo-a": project}, alternate_plan) == ( + "jens/repo-a", "a" * 40, project, + ) + assert status_module._appops_plan({"jens/repo-a": project}, plan) is None + + drifted = {**project, "managed_appdata_path": "/srv/example/appdata/repo-a"} + assert status_module._appops_plan({"jens/repo-a": drifted}, alternate_plan) is None + + def _write_appops_secret(root: Path, name: str, value: bytes) -> None: root.mkdir(mode=0o700) root.chmod(0o700) @@ -5838,6 +5947,7 @@ def _controller_enrollment_payload(*, credential: bool = False) -> dict[str, obj "profile": "internal-webapp-oidc", "container_name": "wsbc-portal", "compose_file": ".itworx/deployment.yml", + "trusted_appdata_root": "/srv/example/appdata", "appdata_path": "/srv/example/appdata/wsbc-portal", "data_path": "/srv/example/appdata/wsbc-portal/data", "runtime_uid": 1654, @@ -5871,6 +5981,7 @@ def _repopulse_controller_enrollment_payload(*, credential: bool = False) -> dic "profile": "repopulse-authenticated", "container_name": "repopulse", "compose_file": ".itworx/deployment.yml", + "trusted_appdata_root": "/srv/example/appdata", "appdata_path": "/srv/example/appdata/repopulse", "data_path": "/srv/example/appdata/repopulse/data", "runtime_uid": 10001, @@ -5929,8 +6040,10 @@ def test_controller_repopulse_runtime_seal_fixes_gitea_url_and_hides_values(stat env_path = app_root / "deploy.managed.env" env_path.write_text("", encoding="utf-8") monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root) + monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root) monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources") payload = _repopulse_controller_enrollment_payload(credential=True) + payload["trusted_appdata_root"] = appdata_root.as_posix() payload["appdata_path"] = app_root.as_posix() payload["data_path"] = (app_root / "data").as_posix() enrollment = status_module._controller_enrollment(payload, require_credential=True) @@ -6106,6 +6219,76 @@ def test_controller_enrollment_contract_rejects_arbitrary_compose_and_paths(stat assert status_module._controller_enrollment(unsafe, require_credential=True) is None +def test_controller_enrollment_derives_paths_from_the_selected_fixed_root(status_module, monkeypatch): + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path(managed_root)) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root) + alternate = _controller_enrollment_payload() + alternate["trusted_appdata_root"] = managed_root + alternate["appdata_path"] = managed_root + "/wsbc-portal" + alternate["data_path"] = managed_root + "/wsbc-portal/data" + + enrollment = status_module._controller_enrollment(alternate, require_credential=False) + assert enrollment is not None + project = status_module._enrollment_project(enrollment) + assert project["managed_appdata_root"] == managed_root + assert project["managed_appdata_path"] == managed_root + "/wsbc-portal" + + unsafe_path = {**alternate, "data_path": "/srv/example/appdata/wsbc-portal/data"} + assert status_module._controller_enrollment(unsafe_path, require_credential=False) is None + + unsupported_root = {**alternate, "trusted_appdata_root": "/srv/example/other"} + assert status_module._controller_enrollment(unsupported_root, require_credential=False) is None + + +def test_controller_accepts_legacy_requests_after_managed_marker_is_initialized(status_module, monkeypatch): + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root)) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root) + legacy = _controller_enrollment_payload() + assert legacy["trusted_appdata_root"] == "/srv/example/appdata" + + enrollment = status_module._controller_enrollment(legacy, require_credential=False) + assert enrollment is not None + assert enrollment["trusted_appdata_root"] == "/srv/example/appdata" + assert "managed_appdata_root" not in status_module._enrollment_project(enrollment) + + +def test_controller_accepts_legacy_v1_payload_without_root_field(status_module): + legacy = _controller_enrollment_payload() + legacy.pop("trusted_appdata_root") + + enrollment = status_module._controller_enrollment(legacy, require_credential=False) + + assert enrollment is not None + assert enrollment["trusted_appdata_root"] == "/srv/example/appdata" + assert "managed_appdata_root" not in status_module._enrollment_project(enrollment) + + +def test_repopulse_enrollment_remains_bound_to_legacy_appdata_root(status_module, monkeypatch): + monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path("/var/lib/docker/volumes/itworx-managed-appdata/_data")) + payload = _repopulse_controller_enrollment_payload() + payload["trusted_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + payload["appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse" + payload["data_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse/data" + + assert status_module._controller_enrollment(payload, require_credential=False) is None + + +def test_fixed_repopulse_profile_stays_legacy_with_managed_marker(status_module, monkeypatch): + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root)) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root) + + enrollment = status_module._controller_enrollment( + _repopulse_controller_enrollment_payload(), + require_credential=False, + ) + + assert enrollment is not None + assert enrollment["trusted_appdata_root"] == "/srv/example/appdata" + + def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module, tmp_path, monkeypatch): appdata_root = tmp_path / "appdata" app_root = appdata_root / "wsbc-portal" @@ -6113,8 +6296,10 @@ def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module, env_path = app_root / "deploy.env" env_path.write_text("", encoding="utf-8") monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root) + monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root) monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources") payload = _controller_enrollment_payload(credential=True) + payload["trusted_appdata_root"] = appdata_root.as_posix() payload["appdata_path"] = app_root.as_posix() payload["data_path"] = (app_root / "data").as_posix() enrollment = status_module._controller_enrollment(payload, require_credential=True) @@ -6154,6 +6339,7 @@ def test_controller_enrollment_replay_requires_the_existing_target_credential(st projects_path.write_text(json.dumps({"jens/windows-support-bundle-collector": project}), encoding="utf-8") monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secrets_root) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda _root: True) enrollment["target_credential"] = "y" * 64 status, _evidence = status_module._enroll_controller_target(enrollment) @@ -7316,3 +7502,249 @@ def test_workmesh_backup_receipt_requires_bound_manifest_and_verified_restore_by assert status_module._verified_workmesh_backup_receipt( request_id, candidate, preflight, inputs ) is None + + +def _managed_storage_http_request( + status_module, + server, + *, + body: bytes, + nonce: str, + secret: bytes | None, + signature: str | None = None, + signed_path: str | None = None, +): + path = status_module.MANAGED_APPDATA_INITIALIZE_PATH + timestamp = str(int(status_module.time.time())) + headers = {"Content-Type": "application/json", "Content-Length": str(len(body))} + if nonce: + headers["X-ITWorx-Deployment-Nonce"] = nonce + if timestamp: + headers["X-ITWorx-Deployment-Timestamp"] = timestamp + if signature is None and secret is not None: + signature = status_module._request_signature( + secret, + method="POST", + path=signed_path or path, + timestamp=timestamp, + nonce=nonce, + raw_body=body, + ) + if signature is not None: + headers["X-ITWorx-Deployment-Signature"] = signature + + connection = http.client.HTTPConnection(*server.server_address, timeout=2) + connection.request("POST", path, body=body, headers=headers) + response = connection.getresponse() + status = response.status + payload = json.loads(response.read()) + connection.close() + return status, payload, headers + + +def test_managed_appdata_initializer_http_requires_exact_signed_one_time_request( + status_module, tmp_path, monkeypatch +): + secret = b"managed-storage-bootstrap-" + b"x" * 48 + 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) + monkeypatch.setattr( + status_module.Handler, + "_managed_storage_initialization_prerequisites", + lambda _handler: True, + ) + initialized = [] + monkeypatch.setattr( + status_module, + "ensure_managed_appdata_root", + lambda: initialized.append(True) or "initialized", + ) + monkeypatch.setattr( + status_module, + "_refresh_appdata_root_selection", + lambda: status_module.MANAGED_APPDATA_ROOT, + ) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + body = json.dumps( + { + "apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION, + "target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET, + }, + separators=(",", ":"), + ).encode("utf-8") + try: + success_status, success_payload, success_headers = _managed_storage_http_request( + status_module, + server, + body=body, + nonce="a" * 32, + secret=secret, + ) + assert success_status == 200 + assert success_payload == { + "status": "initialized", + "trustedAppdataRoot": status_module.MANAGED_APPDATA_ROOT.as_posix(), + "backupLocation": "dockerRootDir", + } + assert initialized == [True] + assert secret.decode("ascii") not in json.dumps(success_payload) + + replay = http.client.HTTPConnection(*server.server_address, timeout=2) + replay.request( + "POST", + status_module.MANAGED_APPDATA_INITIALIZE_PATH, + body=body, + headers=success_headers, + ) + replay_response = replay.getresponse() + replay_payload = json.loads(replay_response.read()) + replay.close() + assert replay_response.status == 403 + assert replay_payload == {"status": "authentication_rejected"} + assert initialized == [True] + + invalid_requests = [ + _managed_storage_http_request( + status_module, + server, + body=body, + nonce="b" * 32, + secret=secret, + signature="0" * 64, + ), + _managed_storage_http_request( + status_module, + server, + body=body, + nonce="c" * 32, + secret=secret, + signed_path="/v1/appops/enrollments/capabilities", + ), + _managed_storage_http_request( + status_module, + server, + body=body, + nonce="d" * 32, + secret=None, + ), + ] + assert [status for status, _payload, _headers in invalid_requests] == [403, 403, 403] + assert initialized == [True] + + for index, payload in enumerate( + [ + { + "apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION, + "target": "tower/other-controller", + }, + { + "apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION, + "target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET, + "path": "C:/private/sentinel", + }, + ], + start=1, + ): + raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + rejected_status, rejected_payload, _headers = _managed_storage_http_request( + status_module, + server, + body=raw_body, + nonce=f"{index:032x}", + secret=secret, + ) + assert rejected_status == 400 + assert rejected_payload == {"status": "invalid_managed_storage_request"} + assert "C:/private/sentinel" not in json.dumps(rejected_payload) + oversized_status, oversized_payload, _headers = _managed_storage_http_request( + status_module, + server, + body=b"x" * 1025, + nonce="e" * 32, + secret=None, + ) + assert oversized_status == 413 + assert oversized_payload == {"status": "invalid_body_size"} + assert initialized == [True] + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_managed_appdata_initializer_http_prerequisite_and_refusal_are_sanitized( + status_module, tmp_path, monkeypatch +): + secret = b"managed-storage-bootstrap-" + b"y" * 48 + secret_path = tmp_path / "bootstrap" + secret_path.write_bytes(secret) + secret_path.chmod(0o600) + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + prerequisites = {"ready": False} + monkeypatch.setattr( + status_module.Handler, + "_managed_storage_initialization_prerequisites", + lambda _handler: prerequisites["ready"], + ) + calls = [] + monkeypatch.setattr( + status_module, + "ensure_managed_appdata_root", + lambda: calls.append(True) or "initialized", + ) + monkeypatch.setattr( + status_module, + "_refresh_appdata_root_selection", + lambda: status_module.MANAGED_APPDATA_ROOT, + ) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + body = json.dumps( + { + "apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION, + "target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET, + }, + separators=(",", ":"), + ).encode("utf-8") + try: + status, payload, _headers = _managed_storage_http_request( + status_module, server, body=body, nonce="f" * 32, secret=secret + ) + assert status == 503 + assert payload == {"status": "managed_storage_prerequisite_unavailable"} + assert calls == [] + + prerequisites["ready"] = True + + def refuse_with_sensitive_diagnostic(): + raise status_module.UnsafeManagedAppdataRoot( + "secret/path sentinel C:/private/appdata" + ) + + monkeypatch.setattr( + status_module, "ensure_managed_appdata_root", refuse_with_sensitive_diagnostic + ) + status, payload, _headers = _managed_storage_http_request( + status_module, server, body=body, nonce="1" * 32, secret=secret + ) + assert status == 409 + assert payload == {"status": "managed_storage_initialization_refused"} + assert "secret/path sentinel" not in json.dumps(payload) + assert "C:/private/appdata" not in json.dumps(payload) + assert secret.decode("ascii") not in json.dumps(payload) + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) diff --git a/backend/tests/test_enrollment_plane.py b/backend/tests/test_enrollment_plane.py index b691a79..0192d56 100644 --- a/backend/tests/test_enrollment_plane.py +++ b/backend/tests/test_enrollment_plane.py @@ -74,12 +74,14 @@ def _trust(): class _Broker: + appdata_root = "/srv/example/appdata" + def is_configured(self): return True def enrollment_capabilities(self, *, secret): assert secret == "b" * 64 - return {"status": "ready"} + return {"status": "ready", "trustedAppdataRoot": self.appdata_root} def enrollment_preflight(self, enrollment, *, secret): assert secret == "b" * 64 @@ -251,6 +253,25 @@ def test_enrollment_schema_rejects_plaintext_secrets_and_untrusted_topology(): raise AssertionError("wildcard hostname must be rejected") +def test_appdata_root_defaults_to_legacy_and_allows_only_the_fixed_managed_root(): + plan = _plan() + assert plan.trusted_appdata_root == "/srv/example/appdata" + assert plan.appdata_path == "/srv/example/appdata/wsbc-portal" + + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + managed = _plan(trusted_appdata_root=managed_root) + assert managed.appdata_path == managed_root + "/wsbc-portal" + + plan_payload = _plan().model_dump() + plan_payload["trusted_appdata_root"] = "/srv/example/other" + trust_payload = _trust().model_dump() + trust_payload["trusted_appdata_root"] = "/srv/example/other" + with pytest.raises(ValueError): + EnrollmentPlanV1.model_validate(plan_payload) + with pytest.raises(ValueError): + EnrollmentTrustConfigV1.model_validate(trust_payload) + + def test_repopulse_profile_accepts_only_its_fixed_authenticated_target(): plan = _repopulse_plan() assert plan.application_id == "repopulse" @@ -291,6 +312,34 @@ def test_repopulse_preflight_requires_existing_secret_and_trusted_npm_access_lis assert session.exec(select(EnrollmentJob)).all() == [] +def test_repopulse_legacy_profile_remains_usable_when_new_target_root_is_managed(tmp_path): + class RecordingBroker(_RepoPulseBroker): + def __init__(self): + super().__init__() + self.preflight_payloads = [] + + def enrollment_preflight(self, enrollment, *, secret): + self.preflight_payloads.append(dict(enrollment)) + return super().enrollment_preflight(enrollment, secret=secret) + + broker = RecordingBroker() + with _session(tmp_path) as session: + _configure(session) + set_secret(session, "REPOPULSE_GITEA_TOKEN", "opaque-gitea-token") + EnrollmentPlane.set_trust_config( + session, + _trust().model_copy(update={ + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + }), + ) + result = _plane(broker=broker, npm=_RepoPulseNpm()).preflight(session, _repopulse_plan()) + + assert result["status"] == "ready" + assert "trusted_appdata_root_mismatch" not in result["blockers"] + assert broker.preflight_payloads[0]["trusted_appdata_root"] == "/srv/example/appdata" + + def test_repopulse_enrollment_seals_exact_runtime_values_without_exposing_secrets(tmp_path): with _session(tmp_path) as session: _configure(session) @@ -347,6 +396,126 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path): assert result["controller"]["status"] == "ready" +def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path): + class AlternateRootBroker(_Broker): + appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker=AlternateRootBroker()).capabilities(session) + + assert result["targetEnrollment"]["status"] == "unavailable" + assert result["controller"]["status"] == "unavailable" + assert result["controller"]["reason"] == "trusted_appdata_root_mismatch" + + +def test_capabilities_treat_roots_as_independent_availability_not_global_selection(tmp_path): + legacy_root = "/srv/example/appdata" + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + + class MultiRootBroker(_Broker): + def enrollment_capabilities(self, *, secret): + assert secret == "b" * 64 + return { + "status": "ready", + "trustedAppdataRoot": managed_root, + "availableTrustedAppdataRoots": [ + {"root": legacy_root, "status": "ready", "reason": ""}, + {"root": managed_root, "status": "ready", "reason": ""}, + ], + "managedStorage": {"status": "ready", "initializationAvailable": False}, + } + + with _session(tmp_path) as session: + _configure(session) + plane = _plane(broker=MultiRootBroker()) + legacy = plane.capabilities(session) + assert legacy["targetEnrollment"]["status"] == "ready" + assert legacy["managedStorage"]["status"] == "not_selected" + assert legacy["availableTrustedAppdataRoots"] == [ + {"root": legacy_root, "status": "ready"}, + {"root": managed_root, "status": "ready"}, + ] + + EnrollmentPlane.set_trust_config( + session, + _trust().model_copy(update={ + "trusted_appdata_root": managed_root, + "managed_appdata_backup_acknowledged": True, + }), + ) + managed = plane.capabilities(session) + + assert managed["targetEnrollment"]["status"] == "ready" + assert managed["managedStorage"]["status"] == "ready" + assert managed["availableTrustedAppdataRoots"] == [ + {"root": legacy_root, "status": "ready"}, + {"root": managed_root, "status": "ready"}, + ] + + +def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path): + class MalformedRootsBroker(_Broker): + def enrollment_capabilities(self, *, secret): + return { + "status": "ready", + "trustedAppdataRoot": "/srv/example/appdata", + "availableTrustedAppdataRoots": [ + {"root": "/srv/example/appdata", "status": "ready"}, + {"root": "/etc/private", "status": "ready", "reason": "sensitive controller path"}, + ], + } + + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker=MalformedRootsBroker()).capabilities(session) + + assert result["targetEnrollment"]["status"] == "unavailable" + assert result["availableTrustedAppdataRoots"] == [ + {"root": "/srv/example/appdata", "status": "unavailable"}, + {"root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "status": "unavailable"}, + ] + assert "/etc/private" not in repr(result) + assert "sensitive controller path" not in repr(result) + + +def test_enrollment_preflight_binds_root_to_trust_and_controller_payload(tmp_path): + class RecordingBroker(_Broker): + def __init__(self): + self.preflight_payloads = [] + + def enrollment_preflight(self, enrollment, *, secret): + self.preflight_payloads.append(dict(enrollment)) + return super().enrollment_preflight(enrollment, secret=secret) + + broker = RecordingBroker() + with _session(tmp_path) as session: + _configure(session) + plane = _plane(broker=broker) + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + alternate = _plan(trusted_appdata_root=managed_root) + blocked = plane.preflight(session, alternate) + assert blocked["status"] == "blocked" + assert "trusted_appdata_root_mismatch" in blocked["blockers"] + assert broker.preflight_payloads == [] + + EnrollmentPlane.set_trust_config( + session, + _trust().model_copy(update={ + "trusted_appdata_root": managed_root, + "managed_appdata_backup_acknowledged": True, + }), + ) + payload = plane._controller_payload( + alternate, + enrollment_id="enr-00000000000000000000", + ) + + assert payload["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data" + assert payload["appdata_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal" + assert payload["data_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal/data" + + def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempotent(tmp_path): with _session(tmp_path) as session: _configure(session) diff --git a/backend/tests/test_managed_appdata_root.py b/backend/tests/test_managed_appdata_root.py new file mode 100644 index 0000000..c08fe8f --- /dev/null +++ b/backend/tests/test_managed_appdata_root.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import importlib.util +import os +import shutil +import stat +import uuid +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER_ROOT = ROOT / "ops" / "gitea-deploy-broker" +INSTALLER_PATH = BROKER_ROOT / "prepare_managed_appdata_root.py" + + +def _load_module(name: str, path: Path) -> ModuleType | None: + helper_path = BROKER_ROOT / "managed_appdata_paths.py" + if not path.is_file() or not helper_path.is_file(): + return None + if str(BROKER_ROOT) not in os.sys.path: + os.sys.path.insert(0, str(BROKER_ROOT)) + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def root_owned_test_tree(): + if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0: + pytest.skip("requires a POSIX root-owned test tree") + parent = Path("/root") + if parent.is_symlink() or not parent.is_dir(): + pytest.skip("requires the fixed root-owned /root test parent") + base = parent / f"appops-managed-root-test-{uuid.uuid4().hex}" + base.mkdir(mode=0o700) + base.chmod(0o700) + try: + yield base + finally: + if base.parent == parent and base.name.startswith("appops-managed-root-test-"): + shutil.rmtree(base) + + +def _root_safe_path(base: Path, *components: str) -> Path: + current = base + for component in components: + current = current / component + current.mkdir(mode=0o700) + current.chmod(0o700) + return current + + +def test_open_fixed_directory_walks_real_owned_components_without_symlinks( + root_owned_test_tree, monkeypatch +): + paths = _load_module("managed_appdata_open_real_chain_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + target = _root_safe_path(root_owned_test_tree, "docker", "volumes", "itworx-managed-appdata", "_data") + monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target) + + descriptor = paths._open_fixed_directory(target) + try: + info = os.fstat(descriptor) + assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700) + finally: + os.close(descriptor) + + +def test_open_fixed_directory_rejects_symlink_component(root_owned_test_tree, monkeypatch): + paths = _load_module("managed_appdata_open_symlink_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + real_target = _root_safe_path(root_owned_test_tree, "real", "managed") + alias = root_owned_test_tree / "alias" + alias.symlink_to(real_target.parent, target_is_directory=True) + target = alias / real_target.name + monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target) + + with pytest.raises(OSError): + paths._open_fixed_directory(target) + + +def test_open_fixed_directory_rejects_world_writable_ancestor(root_owned_test_tree, monkeypatch): + paths = _load_module("managed_appdata_open_world_writable_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + unsafe = root_owned_test_tree / "unsafe" + unsafe.mkdir(mode=0o777) + unsafe.chmod(0o777) + target = _root_safe_path(unsafe, "managed") + monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target) + + with pytest.raises(paths.UnsafeManagedAppdataRoot): + paths._open_fixed_directory(target) + + +@pytest.mark.parametrize("owner_field", ["uid", "gid"]) +def test_open_fixed_directory_rejects_wrong_owner_or_group( + root_owned_test_tree, monkeypatch, owner_field +): + paths = _load_module(f"managed_appdata_open_wrong_{owner_field}_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + owner = root_owned_test_tree / "wrong-owner" + target = _root_safe_path(root_owned_test_tree, "wrong-owner", "managed") + if not hasattr(os, "chown"): + pytest.skip("requires POSIX chown for the owned test directory") + if owner_field == "uid": + os.chown(owner, 65534, 0) + else: + os.chown(owner, 0, 65534) + monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target) + + with pytest.raises(paths.UnsafeManagedAppdataRoot): + paths._open_fixed_directory(target) + + +@pytest.mark.parametrize("acl_name", ["system.posix_acl_access", "system.posix_acl_default"]) +def test_open_fixed_directory_rejects_reported_acl(root_owned_test_tree, monkeypatch, acl_name): + paths = _load_module("managed_appdata_open_acl_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + acl_component = root_owned_test_tree / "acl" + target = _root_safe_path(root_owned_test_tree, "acl", "managed") + acl_stat = acl_component.stat() + original_listxattr = os.listxattr + + def listxattr(path, *args, **kwargs): + if isinstance(path, int): + info = os.fstat(path) + if (info.st_dev, info.st_ino) == (acl_stat.st_dev, acl_stat.st_ino): + return [acl_name] + return original_listxattr(path, *args, **kwargs) + + monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target) + monkeypatch.setattr(paths.os, "listxattr", listxattr) + + with pytest.raises(paths.UnsafeManagedAppdataRoot, match="unreviewed ACL"): + paths._open_fixed_directory(target) + + +def test_fixed_controller_installer_creates_private_root_and_refuses_drift(tmp_path, monkeypatch): + installer = _load_module("managed_appdata_installer_test", INSTALLER_PATH) + assert installer is not None, "the fixed managed-appdata installer must ship with the controller artifact" + if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0: + pytest.skip("requires POSIX root ownership semantics") + + target = tmp_path / "volume-root" + target.mkdir(mode=0o755) + target.chmod(0o755) + monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target) + monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True) + monkeypatch.setattr(installer, "configured_appdata_root", lambda: target) + monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY)) + + assert installer.ensure_managed_appdata_root() == "initialized" + info = target.lstat() + assert stat.S_ISDIR(info.st_mode) + assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700) + marker = target / installer.MANAGED_ROOT_MARKER + assert marker.read_bytes() == installer.MANAGED_ROOT_MARKER_BYTES + assert stat.S_IMODE(marker.lstat().st_mode) == 0o400 + assert installer.ensure_managed_appdata_root() == "already_initialized" + + target.chmod(0o777) + with pytest.raises(installer.UnsafeManagedAppdataRoot): + installer.ensure_managed_appdata_root() + assert stat.S_IMODE(target.lstat().st_mode) == 0o777 + + +def test_fixed_controller_installer_refuses_existing_data_without_deleting_it(tmp_path, monkeypatch): + installer = _load_module("managed_appdata_installer_reject_test", INSTALLER_PATH) + assert installer is not None + if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0: + pytest.skip("requires POSIX root ownership semantics") + + target = tmp_path / "volume-root" + target.mkdir(mode=0o755) + target.chmod(0o755) + monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target) + monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True) + monkeypatch.setattr(installer, "configured_appdata_root", lambda: target) + monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY)) + + (target / "existing-data").write_text("keep", encoding="utf-8") + with pytest.raises(installer.UnsafeManagedAppdataRoot): + installer.ensure_managed_appdata_root() + assert (target / "existing-data").read_text(encoding="utf-8") == "keep" + assert not (target / installer.MANAGED_ROOT_MARKER).exists() + + +def test_initialized_volume_is_idempotent_without_enumerating_application_data(tmp_path, monkeypatch): + installer = _load_module("managed_appdata_installer_idempotent_test", INSTALLER_PATH) + assert installer is not None + if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0: + pytest.skip("requires POSIX root ownership semantics") + + target = tmp_path / "volume-root" + target.mkdir(mode=0o700) + (target / installer.MANAGED_ROOT_MARKER).write_bytes(installer.MANAGED_ROOT_MARKER_BYTES) + (target / installer.MANAGED_ROOT_MARKER).chmod(0o400) + (target / "app-data").mkdir() + monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target) + monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True) + monkeypatch.setattr(installer, "configured_appdata_root", lambda: target) + monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY)) + monkeypatch.setattr(installer.os, "listdir", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("data must not be enumerated"))) + + assert installer.ensure_managed_appdata_root() == "already_initialized" + + +def _valid_mounts(paths): + return [ + { + "Type": "volume", + "Name": paths.VOLUME_NAME, + "Source": paths.VOLUME_HOST_ROOT.as_posix(), + "Destination": paths.MANAGED_APPDATA_ROOT.as_posix(), + "Driver": "local", + "RW": True, + }, + { + "Type": "bind", + "Name": "", + "Source": paths.DOCKER_ROOT.as_posix(), + "Destination": paths.DOCKER_ROOT.as_posix(), + "Driver": "", + "RW": False, + }, + ] + + +def test_controller_mount_inventory_accepts_only_exact_volume_and_readonly_docker_root(): + paths = _load_module("managed_appdata_mounts_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + mounts = _valid_mounts(paths) + + paths.validate_controller_mounts( + mounts, + controller_id_before="a" * 64, + controller_id_after="a" * 64, + ) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda m: m[0].update(Name="other-volume"), + lambda m: m[0].update(Type="bind"), + lambda m: m[0].update(Driver="nfs"), + lambda m: m[0].update(Source="/var/lib/docker/volumes/other/_data"), + lambda m: m[0].update(Destination="/managed-appdata"), + lambda m: m[0].update(RW=False), + lambda m: m[1].update(RW=True), + lambda m: m.append(dict(m[0])), + lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes", "Destination": "/var/lib/docker/volumes", "RW": True}), + lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "Destination": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "RW": True}), + lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker", "RW": False}), + ], +) +def test_controller_mount_inventory_fails_closed_on_mount_drift(mutate): + paths = _load_module("managed_appdata_mounts_reject_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + mounts = _valid_mounts(paths) + mutate(mounts) + + with pytest.raises(paths.UnsafeManagedAppdataRoot): + paths.validate_controller_mounts( + mounts, + controller_id_before="b" * 64, + controller_id_after="b" * 64, + ) + + +def test_controller_mount_inventory_rejects_container_replacement_during_inspection(): + paths = _load_module("managed_appdata_mounts_race_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + + with pytest.raises(paths.UnsafeManagedAppdataRoot): + paths.validate_controller_mounts( + _valid_mounts(paths), + controller_id_before="c" * 64, + controller_id_after="d" * 64, + ) + + +@pytest.mark.parametrize( + "override", + [ + {"Name": "other"}, + {"Driver": "nfs"}, + {"Scope": "global"}, + {"Mountpoint": "/var/lib/docker/volumes/other/_data"}, + {"Options": {"type": "nfs"}}, + {"Labels": {"itworx.managed-by": "other"}}, + ], +) +def test_volume_metadata_rejects_drift(override): + paths = _load_module("managed_appdata_volume_reject_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None + document = { + "Name": paths.VOLUME_NAME, + "Driver": "local", + "Scope": "local", + "Mountpoint": paths.VOLUME_HOST_ROOT.as_posix(), + "Options": None, + "Labels": dict(paths.VOLUME_LABELS), + } + document.update(override) + + with pytest.raises(paths.UnsafeManagedAppdataRoot): + paths.validate_volume_metadata(document, paths.DOCKER_ROOT.as_posix()) + + +def test_controller_rejects_world_writable_legacy_root_without_mutating_it(tmp_path): + paths = _load_module("managed_appdata_paths_test", BROKER_ROOT / "managed_appdata_paths.py") + assert paths is not None, "the controller must centralize fixed managed-appdata path checks" + if os.name != "posix": + pytest.skip("requires POSIX permission bits") + + root = tmp_path / "legacy" + root.mkdir() + paths.LEGACY_APPDATA_ROOT = root + root.chmod(0o777) + assert paths.appdata_root_is_safe(root) is False + assert stat.S_IMODE(root.lstat().st_mode) == 0o777 + + root.chmod(0o750) + assert paths.appdata_root_is_safe(root) is True diff --git a/backend/tests/test_managed_storage_action.py b/backend/tests/test_managed_storage_action.py new file mode 100644 index 0000000..f32abfa --- /dev/null +++ b/backend/tests/test_managed_storage_action.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.db.models import AppSetting, AuditEvent, JobRun, Policy +from backend.app.services.action_execution import ( + approval_execution_supported, + execute_governed_action, + mcp_execution_supported, +) +from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, +) +from backend.app.services.policy_engine import evaluate_policy + +ACTION = "controller.managed-storage.initialize" +TARGET = "tower/deployment-controller" + + +def test_managed_storage_initialization_is_typed_high_risk_and_fixed_target(): + assert ACTION in ACTIONS + assert ACTION_RISK[ACTION] == "high" + assert action_default_target(ACTION) == TARGET + assert not approval_execution_supported(ACTION) + assert mcp_execution_supported(ACTION) + + +def test_managed_storage_policy_defaults_to_blocked_and_requires_exact_manual_allow(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-policy.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked" + session.add(Policy( + id="managed-storage-wildcard", + action=ACTION, + target="*", + voice="blocked", + manual="allowed", + risk="high", + )) + session.commit() + assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked" + + session.add(Policy( + id="managed-storage-exact", + action=ACTION, + target=TARGET, + voice="allowed", + manual="allowed", + risk="high", + )) + session.commit() + assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "allowed" + assert evaluate_policy(session, ACTION, "tower/other-controller", "manual")["decision"] == "blocked" + assert evaluate_policy(session, ACTION, TARGET, "voice")["decision"] == "blocked" + + engine.dispose() + + +def _executor_engine(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-executor.db'}") + SQLModel.metadata.create_all(engine) + return engine + + +def _managed_trust(): + from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 + + return EnrollmentTrustConfigV1.model_validate({ + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": None, + "oidc_role_claim": "roles", + "administrator_group": None, + "support_agent_group": None, + "read_only_group": None, + }) + + +def _execute_initializer(session): + return execute_governed_action( + session, + action=ACTION, + target=TARGET, + source="chatgpt-mcp", + risk="high", + reason="Operator explicitly selected managed storage and acknowledged its backup scope.", + original_policy_decision="allowed", + ) + + +@pytest.mark.parametrize("trust_state", ["missing", "legacy"]) +def test_managed_storage_executor_refuses_missing_selection_or_ack_without_broker_call( + tmp_path, monkeypatch, trust_state +): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + engine = _executor_engine(tmp_path) + if trust_state == "legacy": + legacy = _managed_trust().model_copy(update={ + "trusted_appdata_root": "/srv/example/appdata", + "managed_appdata_backup_acknowledged": False, + }) + with Session(engine) as setup: + setup.add(AppSetting( + key="deployment_enrollment:trust", + value_json=legacy.model_dump(mode="json"), + is_secret=False, + )) + setup.commit() + + monkeypatch.setattr( + DeploymentBrokerClient, + "initialize_managed_appdata", + lambda *_args, **_kwargs: pytest.fail("broker must not be called without managed trust opt-in"), + ) + with Session(engine) as session: + result = _execute_initializer(session) + assert result["success"] is False + assert result["result"] == "blocked" + assert result["auditId"] + assert session.exec(select(JobRun)).all() == [] + event = session.exec(select(AuditEvent)).one() + assert event.result == "blocked" + assert event.redacted_metadata == {"executor": ACTION, "trustOptIn": False} + engine.dispose() + + +def test_managed_storage_executor_requires_bootstrap_secret_without_broker_call(tmp_path, monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.enrollment_plane import EnrollmentPlane + + engine = _executor_engine(tmp_path) + monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust())) + monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + DeploymentBrokerClient, + "initialize_managed_appdata", + lambda *_args, **_kwargs: pytest.fail("broker must not be called without bootstrap secret"), + ) + with Session(engine) as session: + result = _execute_initializer(session) + assert result["success"] is False + assert result["result"] == "failed" + assert result["auditId"] + assert session.exec(select(JobRun)).all() == [] + event = session.exec(select(AuditEvent)).one() + assert event.result == "failed" + assert event.redacted_metadata == {"executor": ACTION, "secretMaterialReturned": "[REDACTED]"} + engine.dispose() + + +@pytest.mark.parametrize("status", ["initialized", "already_initialized"]) +def test_managed_storage_executor_records_durable_success_without_secret_material( + tmp_path, monkeypatch, status +): + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.enrollment_plane import EnrollmentPlane + + engine = _executor_engine(tmp_path) + monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust())) + monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret") + broker_calls = [] + + def initialize(_client, *, secret): + broker_calls.append(secret) + return { + "status": status, + "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "backupLocation": "dockerRootDir", + } + + monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", initialize) + with Session(engine) as session: + result = _execute_initializer(session) + assert result["success"] is True + assert result["status"] == "success" + assert result["jobId"] and result["auditId"] + assert broker_calls == ["temporary-bootstrap-secret"] + + with Session(engine) as session: + job = session.get(JobRun, result["jobId"]) + event = session.get(AuditEvent, result["auditId"]) + assert job is not None and job.status == "succeeded" + assert event is not None and event.result == "success" + assert event.after_state == { + "status": status, + "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "backupLocation": "dockerRootDir", + } + assert event.redacted_metadata["secretMaterialReturned"] == "[REDACTED]" + assert "temporary-bootstrap-secret" not in repr(event.model_dump()) + assert "temporary-bootstrap-secret" not in repr(result) + engine.dispose() + + +@pytest.mark.parametrize( + "broker_result", + [ + {"status": "initialized", "trustedAppdataRoot": "/tmp/wrong-root", "backupLocation": "dockerRootDir"}, + {"status": "initialized", "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "backupLocation": "external-share"}, + { + "status": "secret-sentinel-/var/lib/private", + "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "backupLocation": "dockerRootDir", + }, + ], + ids=["wrong-root", "wrong-backup-location", "untrusted-status"], +) +def test_managed_storage_executor_fails_closed_on_mismatched_broker_attestation( + tmp_path, monkeypatch, broker_result +): + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.enrollment_plane import EnrollmentPlane + + engine = _executor_engine(tmp_path) + monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust())) + monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret") + monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", lambda *_args, **_kwargs: broker_result) + with Session(engine) as session: + result = _execute_initializer(session) + assert result["success"] is False + assert result["result"] == "failed" + assert "temporary-bootstrap-secret" not in repr(result) + assert "/tmp/wrong-root" not in repr(result) + assert "secret-sentinel-/var/lib/private" not in repr(result) + event = session.get(AuditEvent, result["auditId"]) + assert event is not None and event.result == "failed" + assert event.after_state["trustedAppdataRoot"] is None + assert event.after_state["backupLocation"] is None + if broker_result["status"].startswith("secret-sentinel"): + assert event.after_state["status"] == "refused" + assert "/tmp/wrong-root" not in repr(event.model_dump()) + assert "temporary-bootstrap-secret" not in repr(event.model_dump()) + assert "secret-sentinel-/var/lib/private" not in repr(event.model_dump()) + job = session.get(JobRun, result["jobId"]) + assert job is not None and job.status == "failed" + assert "secret-sentinel-/var/lib/private" not in repr(job.model_dump()) + engine.dispose() + + +def test_managed_storage_executor_sanitizes_broker_exception(tmp_path, monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.enrollment_plane import EnrollmentPlane + + engine = _executor_engine(tmp_path) + monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust())) + monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret") + + def raise_broker_error(*_args, **_kwargs): + raise RuntimeError("temporary-bootstrap-secret failed under /var/lib/private") + + monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", raise_broker_error) + with Session(engine) as session: + result = _execute_initializer(session) + assert result["success"] is False + assert result["message"] == "Managed storage initialization was refused or unavailable." + assert "temporary-bootstrap-secret" not in repr(result) + assert "/var/lib/private" not in repr(result) + event = session.get(AuditEvent, result["auditId"]) + assert event is not None and event.result == "failed" + assert "temporary-bootstrap-secret" not in repr(event.model_dump()) + assert "/var/lib/private" not in repr(event.model_dump()) + engine.dispose() diff --git a/backend/tests/test_mcp_hub_deployment_reconciliation.py b/backend/tests/test_mcp_hub_deployment_reconciliation.py index 1f6dc9f..6dcad69 100644 --- a/backend/tests/test_mcp_hub_deployment_reconciliation.py +++ b/backend/tests/test_mcp_hub_deployment_reconciliation.py @@ -61,8 +61,8 @@ def test_reconciliation_is_fixed_to_mcp_hub_and_reconciles_only_verified_failed_ assert create[2]["json"]["AttachStderr"] is False script = command[2] - assert "/opt/gitea-deploy/projects.json" in script - assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script + assert "/controller-config/projects.json" in script + assert "/controller-hub-releases/controller-config/projects.json" in script assert "os.path.samefile(registry_path, host_registry_path)" in script assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script @@ -102,7 +102,9 @@ def test_reconciliation_proves_complete_live_compose_topology_and_preserves_actu assert 'not runtime_state.get("Running")' in script assert 'health in {"starting", "unhealthy"}' in script assert 'image_id = str(container.get("Image") or "")' in script - assert 'artifacts = {"containers": container_artifacts, "compose_sha256": ""}' in script + assert '"containers": container_artifacts' in script + assert '"compose_sha256": ""' in script + assert '"topology_contract": topology_contract' in script assert '"live_legacy_provenance": True' in script assert '"live_artifacts": artifacts' in script assert '"live_revision": recorded_live' in script @@ -123,6 +125,23 @@ def test_reconciliation_recognizes_only_the_fixed_mcp_hub_oci_source_as_owned(): assert '"live_revision": attempt_revision' not in script +def test_reconciliation_requires_the_controller_17_service_topology_migration(): + result = McpHubDeploymentReconciliation(FakePortainer(exit_code=56)).run() + assert result == { + "status": "topology_migration_required", + "repository": "jens/itworx_mcp_hub", + "composeProject": "itworx-mcp-hub", + "reason": "MCP Hub 15-to-17 topology migration requires its guarded protected-main deployment", + } + + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + assert 'topology_contract = "itworx.mcp-hub-topology/17-v1"' in script + assert 'recorded_artifacts.get("topology_contract") == topology_contract' in script + assert '"topology_contract": topology_contract' in script + + def test_owned_image_ancestry_can_fail_closed_to_the_canonical_gitea_store(): fake = FakePortainer() McpHubDeploymentReconciliation(fake).run() diff --git a/backend/tests/test_mcp_hub_governed_topology.py b/backend/tests/test_mcp_hub_governed_topology.py new file mode 100644 index 0000000..750b9e3 --- /dev/null +++ b/backend/tests/test_mcp_hub_governed_topology.py @@ -0,0 +1,2649 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import io +import json +import os +import stat +import subprocess +import sys +import tarfile +import types +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CONTROLLER = ROOT / "ops" / "gitea-deploy-broker" / "deploy.py" + + +def _load_controller(): + 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("mcp_hub_topology_controller_test", CONTROLLER) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def controller(): + return _load_controller() + + +def _legacy_target(controller): + return { + "branch": "main", + "slug": controller.MCP_HUB_PROJECT, + "compose_project": controller.MCP_HUB_PROJECT, + "containers": list(controller.MCP_HUB_LEGACY_CONTAINERS), + "exact_source": True, + "verify_oci_revision": True, + "manual": False, + "deploy_script": "", + "archive_deploy_script": "", + "live_probe": None, + "managed_plan_version": "", + "source_dir": (controller.EXACT_SOURCE_ROOT / "itworx-mcp-hub").as_posix(), + } + + +def test_projects_registry_uses_fixed_config_alias_only_for_protected_install(controller): + assert controller.CONFIG_PATH == controller.ROOT / "projects.json" + assert controller.controller_config_root(Path("/opt/gitea-deploy")) == Path( + "/controller-hub-releases/controller-config" + ) + assert controller._CONTROLLER_PATHS.controller_readonly_config_root( + Path("/opt/gitea-deploy") + ) == Path("/controller-config") + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX ACL inspection is unavailable on Windows") +def test_state_trust_fails_closed_when_acl_metadata_cannot_be_inspected(controller, monkeypatch): + def unavailable(*_args, **_kwargs): + raise OSError("filesystem ACL metadata is unavailable") + + monkeypatch.setattr(controller.os, "listxattr", unavailable) + with pytest.raises(controller.DeployError, match="cannot inspect filesystem ACLs"): + controller._mcp_hub_require_no_posix_acl(Path("/tmp")) + + +@pytest.mark.skipif(os.name == "nt", reason="backup ownership contract requires POSIX ownership and ACL metadata") +def test_hub_backup_child_is_created_with_fixed_parent_owner_and_private_mode( + controller, tmp_path, monkeypatch +): + appdata_root = tmp_path / "hub-appdata" + backup_root = appdata_root / "backups" + appdata_root.mkdir(mode=0o700) + backup_root.mkdir(mode=0o700) + os.chown(appdata_root, 99, 100) + os.chown(backup_root, 99, 100) + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", appdata_root) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root) + + attempt = backup_root / "attempt" + controller._mcp_hub_private_directory(attempt, create=True, root=backup_root) + + metadata = attempt.stat() + assert (metadata.st_uid, metadata.st_gid) == (99, 100) + assert stat.S_IMODE(metadata.st_mode) == 0o700 + controller._mcp_hub_private_directory(attempt, root=backup_root) + + +@pytest.mark.skipif(os.name == "nt", reason="backup root contract requires POSIX mode and ACL metadata") +def test_hub_backup_root_rejects_group_readable_root(controller, tmp_path, monkeypatch): + appdata_root = tmp_path / "hub-appdata" + backup_root = appdata_root / "backups" + appdata_root.mkdir(mode=0o700) + backup_root.mkdir(mode=0o750) + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", appdata_root) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root) + + with pytest.raises(controller.DeployError, match="fixed private owner and mode"): + controller._mcp_hub_private_directory(backup_root, root=backup_root) + + +@pytest.mark.skipif(os.name == "nt", reason="release receipt storage requires POSIX mode and ACL metadata") +def test_hub_backup_receipt_directory_is_guarded_by_its_release_volume_parent( + controller, tmp_path, monkeypatch +): + volume_root = tmp_path / "controller-hub-releases" + state_root = volume_root / "state" + receipt_root = state_root / "backup-receipts" + receipt_root.mkdir(mode=0o700, parents=True) + volume_root.chmod(0o700) + state_root.chmod(0o700) + monkeypatch.setattr(controller, "MCP_HUB_RELEASE_STORE_STATE_ROOT", state_root) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_RECEIPT_ROOT", receipt_root) + + controller._mcp_hub_private_directory(receipt_root, root=state_root) + with pytest.raises(controller.DeployError, match="escapes its fixed parent"): + controller._mcp_hub_private_directory(receipt_root, root=controller.STATE_ROOT) + + +def _valid_model(controller): + services = {} + for service_name in controller.MCP_HUB_SERVICES: + if service_name == "contextforge": + image = "itworx-mcp-hub/contextforge-schema:0123456789ab" + elif service_name == "postgres": + image = controller.MCP_HUB_POSTGRES_IMAGE + elif service_name == "ingestion-clamd": + image = controller.MCP_HUB_INTAKE_CLAMAV_IMAGE + else: + image = f"registry.example/{service_name}@sha256:{'a' * 64}" + service = {"image": image, "volumes": [], "secrets": [], "tmpfs": []} + if service_name == "postgres": + service["environment"] = {"PGDATA": controller.MCP_HUB_POSTGRES_PGDATA} + if service_name in controller.MCP_HUB_INTAKE_SERVICES: + service["profiles"] = ["knowledge-intake"] + if service_name in {"control-api", "worker"}: + service["environment"] = {"INGESTION_CONNECTOR_ENABLED": "true"} + if service_name == "contextforge": + service["environment"] = {"MCPGATEWAY_SKIP_MIGRATIONS": "true"} + services[service_name] = service + return {"services": services, "volumes": {}, "secrets": {}} + + +def _patch_release_volume_paths(controller, monkeypatch, tmp_path): + releases = tmp_path / "protected-volume" / "releases" + monkeypatch.setattr(controller, "MCP_HUB_RELEASES_ROOT", releases) + monkeypatch.setattr( + controller, + "_mcp_hub_release_directory", + lambda revision: releases / revision, + ) + + def require_release_anchor(*, create=False): + if create: + releases.mkdir(mode=0o700, parents=True, exist_ok=True) + if not releases.is_dir() or releases.is_symlink(): + raise controller.DeployError("MCP Hub protected release anchor is unavailable") + return releases + + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_release_anchor", require_release_anchor) + return releases + + +def test_exact_legacy_target_derives_only_the_fixed_17_service_contract(controller): + target = _legacy_target(controller) + + result = controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, target) + + assert set(result["containers"]) == set(controller.MCP_HUB_CONTAINERS) + assert result["compose_files"] == list(controller.MCP_HUB_COMPOSE_FILES) + assert result["_compose_profiles"] == ["knowledge-intake"] + assert result["legacy_containers"] == [] + assert target["containers"] == list(controller.MCP_HUB_LEGACY_CONTAINERS) + assert controller.effective_mcp_hub_project("jens/other", target) is target + + +def test_hub_compose_command_preserves_candidate_build_metadata_and_fixed_intake_profile( + controller, monkeypatch, tmp_path +): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + project["source_dir"] = str(tmp_path) + project["compose_dir"] = "infrastructure/compose" + built_at = "2026-10-01T19:04:44Z" + project["_deployment_metadata"] = { + "repository": controller.MCP_HUB_REPOSITORY, + "revision": "a" * 40, + "builtAt": built_at, + } + compose_directory = tmp_path / "infrastructure" / "compose" + compose_directory.mkdir(parents=True) + for filename in controller.MCP_HUB_COMPOSE_FILES: + (compose_directory / filename).write_text("services: {}\n", encoding="utf-8") + overlay = tmp_path / "private-overlay.yaml" + monkeypatch.setattr( + controller, + "_mcp_hub_compose_override", + lambda _project, _compose_dir, _base_command: overlay, + ) + monkeypatch.setenv("COMPOSE_PROFILES", "observability,inspector") + + _, command = controller.compose_command(project) + environment = controller.deployment_environment( + project, "a" * 40, controller.MCP_HUB_REPOSITORY + ) + + profile_arguments = [ + command[index + 1] + for index, argument in enumerate(command[:-1]) + if argument == "--profile" + ] + assert profile_arguments == ["knowledge-intake"] + assert environment["COMPOSE_PROFILES"] == "knowledge-intake" + assert environment["GITEA_COMMIT_SHA"] == "a" * 40 + assert environment["APPOPS_BUILD_DATE"] == built_at + project_directory_index = command.index("--project-directory") + assert command[project_directory_index + 1] == str( + compose_directory + ) + assert command[-2:] == ["--file", str(overlay)] + + +def _built_hub_candidate(controller, tmp_path, revision="a" * 40): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + project["source_dir"] = str(tmp_path) + project["compose_dir"] = "infrastructure/compose" + project["_deployment_metadata"] = { + "id": revision[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": revision, + "tree": "b" * 40, + "treeDigest": "c" * 64, + "builtAt": "2026-10-02T10:00:00.000Z", + } + model = _valid_model(controller) + image_ids = {} + labels = { + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.source-tree": project["_deployment_metadata"]["tree"], + "org.opencontainers.image.version": revision[:12], + "org.opencontainers.image.created": project["_deployment_metadata"]["builtAt"], + } + for index, service_name in enumerate(controller.MCP_HUB_APPLICATION_SERVICES, 1): + service = model["services"][service_name] + service["build"] = {"context": "."} + service["image"] = f"itworx-mcp-hub/{service_name}:{revision[:12]}" + image_ids[service["image"]] = "sha256:" + f"{index:064x}" + return project, model, image_ids, labels + + +def test_hub_source_builds_are_inspected_then_started_by_immutable_ids( + controller, monkeypatch, tmp_path +): + project, model, image_ids, labels = _built_hub_candidate(controller, tmp_path) + for filename in controller.MCP_HUB_COMPOSE_FILES: + (tmp_path / "infrastructure" / "compose" / filename).parent.mkdir( + parents=True, exist_ok=True + ) + (tmp_path / "infrastructure" / "compose" / filename).write_text( + "services: {}\n", encoding="utf-8" + ) + catalog_override = tmp_path / "catalog.yaml" + catalog_override.write_text("services: {}\n", encoding="utf-8") + inspections = dict(image_ids) + monkeypatch.setattr( + controller, + "inspect_image", + lambda reference: { + "Id": inspections.get(reference, reference), + "Config": {"Labels": labels}, + }, + ) + monkeypatch.setattr( + controller, + "_mcp_hub_compose_override", + lambda *_args, **_kwargs: catalog_override, + ) + monkeypatch.setattr(controller, "deployment_environment", lambda *_args: {}) + monkeypatch.setattr(controller, "mcp_hub_wait_services", lambda *_args, **_kwargs: None) + launched = [] + + def record_compose(command, **_kwargs): + pin_index = next( + index + for index, argument in enumerate(command[:-1]) + if argument == "--file" and Path(command[index + 1]).name == "image-pins.json" + ) + pin_path = Path(command[pin_index + 1]) + override = json.loads(pin_path.read_text(encoding="ascii")) + launched.append((command, override)) + return None + + monkeypatch.setattr(controller, "run", record_compose) + controller._mcp_hub_pin_built_image_ids(project, model) + expected_pins = { + name: {"image": project["_mcp_hub_rendered_model"]["services"][name]["image"]} + for name in sorted(controller.MCP_HUB_APPLICATION_SERVICES) + } + + # Simulate mutable tags being retargeted after inspection. Compose must use + # only the image IDs captured from the successful build inspection. + for index, tag in enumerate(image_ids): + inspections[tag] = "sha256:" + f"{index + 101:064x}" + controller.mcp_hub_up_services( + project, + project["_deployment_metadata"]["revision"], + controller.MCP_HUB_REPOSITORY, + controller.MCP_HUB_APPLICATION_SERVICES, + ) + + assert len(launched) == 1 + command, override = launched[0] + assert command[command.index("up") + 1:command.index("up") + 6] == [ + "--detach", "--no-build", "--pull", "never", "control-api" + ] + assert override == {"services": expected_pins} + assert all( + project["_mcp_hub_rendered_model"]["services"][name]["image"] + == image_ids[f"itworx-mcp-hub/{name}:aaaaaaaaaaaa"] + for name in controller.MCP_HUB_APPLICATION_SERVICES + ) + + +def test_hub_source_build_rejects_wrong_image_revision_before_pinning( + controller, monkeypatch, tmp_path +): + project, model, image_ids, labels = _built_hub_candidate(controller, tmp_path) + wrong_labels = {**labels, "org.opencontainers.image.revision": "d" * 40} + monkeypatch.setattr( + controller, + "inspect_image", + lambda reference: { + "Id": image_ids[reference], + "Config": {"Labels": wrong_labels}, + }, + ) + + with pytest.raises(controller.DeployError, match="not bound to the candidate source"): + controller._mcp_hub_pin_built_image_ids(project, model) + assert "_mcp_hub_rendered_model" not in project + + +def test_hub_recorded_source_builds_repin_the_exact_receipt_image_ids(controller): + model = _valid_model(controller) + containers = {} + expected = {} + for index, service_name in enumerate(controller.MCP_HUB_APPLICATION_SERVICES, 1): + model["services"][service_name]["build"] = {"context": "."} + image_id = "sha256:" + f"{index:064x}" + container_name = f"itworx-mcp-hub-{service_name}-1" + containers[container_name] = {"image_id": image_id, "image_ref": image_id} + expected[service_name] = image_id + + pinned = controller._mcp_hub_pin_recorded_build_images(model, containers) + + assert pinned == expected + assert all( + model["services"][name]["image"] == image_id + for name, image_id in expected.items() + ) + containers["itworx-mcp-hub-control-api-1"]["image_ref"] = "mutable:tag" + with pytest.raises(controller.DeployError, match="not pinned to its image ID"): + controller._mcp_hub_pin_recorded_build_images(model, containers) + + +def test_hub_runtime_rejects_controller_state_mount_and_host_capabilities(controller): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + model = _valid_model(controller) + model["services"]["control-api"]["volumes"] = [{ + "type": "bind", + "source": controller.STATE_ROOT.as_posix(), + "target": "/controller-state", + "read_only": True, + }] + with pytest.raises(controller.DeployError, match="only a protected catalog leaf read-only"): + controller.validate_mcp_hub_compose_model(project, model) + + model["services"]["control-api"]["volumes"] = [] + model["services"]["control-api"]["privileged"] = True + with pytest.raises(controller.DeployError, match="host filesystem or remount capabilities"): + controller.validate_mcp_hub_compose_model(project, model) + model["services"]["control-api"].pop("privileged") + model["services"]["control-api"]["cap_add"] = ["SYS_ADMIN"] + with pytest.raises(controller.DeployError, match="host filesystem or remount capabilities"): + controller.validate_mcp_hub_compose_model(project, model) + + +def test_hub_reconciler_requires_the_exact_four_file_release_bind_inventory(controller): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + revision = "d" * 40 + project["_mcp_hub_required_release_revision"] = revision + store = controller._MCP_HUB_RELEASE_STORE + model = _valid_model(controller) + for service, suffix, target, _kind in store.RELEASE_BIND_CONTRACT: + model["services"][service]["volumes"].append( + { + "type": "bind", + "source": f"{store.logical_release_path(revision)}/{suffix}", + "target": target, + "read_only": True, + "mode": "ro", + } + ) + + assert controller.validate_mcp_hub_compose_model( + project, + model, + require_catalog_mounts=True, + catalog_source="logical", + ) is model + host_model = store.translate_release_mounts(model, revision) + assert controller.validate_mcp_hub_compose_model( + project, + host_model, + require_catalog_mounts=True, + catalog_source="host", + ) is host_model + + missing = copy.deepcopy(model) + missing["services"]["redis"]["volumes"].pop(0) + with pytest.raises(controller.DeployError, match="in-release bind inventory"): + controller.validate_mcp_hub_compose_model( + project, + missing, + require_catalog_mounts=True, + catalog_source="logical", + ) + writable = copy.deepcopy(model) + writable["services"]["contextforge"]["volumes"][0]["read_only"] = False + with pytest.raises(controller.DeployError, match="in-release bind inventory"): + controller.validate_mcp_hub_compose_model( + project, + writable, + require_catalog_mounts=True, + catalog_source="logical", + ) + broad = copy.deepcopy(model) + broad["services"]["control-api"]["volumes"][0]["source"] = store.logical_release_path(revision) + with pytest.raises(controller.DeployError, match="in-release bind inventory"): + controller.validate_mcp_hub_compose_model( + project, + broad, + require_catalog_mounts=True, + catalog_source="logical", + ) + + +@pytest.mark.skipif(os.name == "nt", reason="release publication requires POSIX ownership and directory fsync") +def test_hub_release_source_stage_is_immutable_and_idempotent( + controller, monkeypatch, tmp_path +): + state_root = tmp_path / "controller-state" + state_root.mkdir(mode=0o700) + monkeypatch.setattr(controller, "STATE_ROOT", state_root) + releases = _patch_release_volume_paths(controller, monkeypatch, tmp_path) + revision = "a" * 40 + project = { + "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, + "slug": controller.MCP_HUB_PROJECT, + "branch": "main", + } + monkeypatch.setattr(controller, "validate_commit", lambda *_args: revision) + monkeypatch.setattr(controller, "output", lambda *_args: revision) + extracted = [] + + def extract(_repo, _revision, target): + extracted.append(target) + (target / "catalog").mkdir() + (target / "catalog" / "items.json").write_text("{}", encoding="utf-8") + + monkeypatch.setattr(controller, "extract_revision", extract) + monkeypatch.setattr( + controller, + "exact_source_matches_revision", + lambda _repo, _revision, target: ( + (target / "catalog" / "items.json").is_file() + and (target / "catalog" / "items.json").read_text(encoding="utf-8") == "{}" + ), + ) + + release = controller.mcp_hub_stage_release_source(project, tmp_path / "hub.git", revision) + assert release == releases / revision + assert release.joinpath("catalog", "items.json").read_text(encoding="utf-8") == "{}" + assert stat.S_IMODE(release.stat().st_mode) == 0o555 + assert stat.S_IMODE(release.joinpath("catalog").stat().st_mode) == 0o555 + assert stat.S_IMODE(release.joinpath("catalog", "items.json").stat().st_mode) == 0o444 + controller.mcp_hub_stage_release_source(project, tmp_path / "hub.git", revision) + assert len(extracted) == 1 + + release.joinpath("catalog", "items.json").write_text("tampered", encoding="utf-8") + with pytest.raises(controller.DeployError, match="differs from its protected Git tree"): + controller.mcp_hub_stage_release_source(project, tmp_path / "hub.git", revision) + + +@pytest.mark.skipif(os.name == "nt", reason="release publication requires POSIX directory fsync") +def test_hub_release_stage_syncs_tree_before_rename_and_parent_after( + controller, monkeypatch, tmp_path +): + releases = _patch_release_volume_paths(controller, monkeypatch, tmp_path) + revision = "a" * 40 + project = { + "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, + "slug": controller.MCP_HUB_PROJECT, + "branch": "main", + } + monkeypatch.setattr(controller, "validate_commit", lambda *_args: revision) + monkeypatch.setattr(controller, "output", lambda *_args: revision) + + def extract(_repo, _revision, target): + catalog = target / "catalog" + catalog.mkdir() + (catalog / "items.json").write_text("{}", encoding="utf-8") + + monkeypatch.setattr(controller, "extract_revision", extract) + monkeypatch.setattr(controller, "exact_source_matches_revision", lambda *_args: True) + events = [] + original_fsync = controller.os.fsync + original_directory_sync = controller._mcp_hub_fsync_release_directory + original_rename = Path.rename + original_parent_sync = controller._fsync_directory + + def record_fsync(descriptor): + if stat.S_ISREG(controller.os.fstat(descriptor).st_mode): + events.append(("file", "")) + original_fsync(descriptor) + + def record_directory_sync(path): + events.append(("directory", Path(path).name)) + original_directory_sync(path) + + def record_rename(path, target): + events.append(("rename", str(target))) + return original_rename(path, target) + + def record_parent_sync(path): + if Path(path) == releases: + events.append(("release-parent", "")) + original_parent_sync(path) + + monkeypatch.setattr(controller.os, "fsync", record_fsync) + monkeypatch.setattr(controller, "_mcp_hub_fsync_release_directory", record_directory_sync) + monkeypatch.setattr(Path, "rename", record_rename) + monkeypatch.setattr(controller, "_fsync_directory", record_parent_sync) + + controller.mcp_hub_stage_release_source(project, tmp_path / "hub.git", revision) + + rename_index = next(index for index, event in enumerate(events) if event[0] == "rename") + assert any(event[0] == "file" for event in events[:rename_index]) + assert any(event == ("directory", "catalog") for event in events[:rename_index]) + assert events.index(("release-parent", "")) > rename_index + + +@pytest.mark.skipif(os.name == "nt", reason="release publication requires POSIX ownership and directory fsync") +def test_prior_release_can_only_be_staged_from_protected_main_ancestry( + controller, monkeypatch, tmp_path +): + releases = _patch_release_volume_paths(controller, monkeypatch, tmp_path) + prior_revision = "a" * 40 + protected_head = "b" * 40 + project = { + "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, + "slug": controller.MCP_HUB_PROJECT, + "branch": "main", + } + monkeypatch.setattr(controller, "validate_commit", lambda _repo, _branch, revision: revision) + monkeypatch.setattr(controller, "output", lambda *_args: protected_head) + monkeypatch.setattr( + controller, + "extract_revision", + lambda _repo, _revision, target: (target / "catalog").mkdir(), + ) + monkeypatch.setattr(controller, "exact_source_matches_revision", lambda *_args: True) + monkeypatch.setattr( + controller.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0), + ) + + staged = controller._mcp_hub_stage_protected_release_source( + project, tmp_path / "hub.git", prior_revision, protected_head=protected_head + ) + + assert staged == releases / prior_revision + assert not (controller.MCP_HUB_APPDATA_ROOT / "deployment" / "releases").exists() + + monkeypatch.setattr( + controller.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 1), + ) + with pytest.raises(controller.DeployError, match="not an ancestor of exact protected main"): + controller._mcp_hub_stage_protected_release_source( + project, tmp_path / "hub.git", "c" * 40, protected_head=protected_head + ) + + +@pytest.mark.skipif(os.name == "nt", reason="release metadata checks require POSIX mode bits") +def test_hub_release_anchor_and_tree_reject_writable_metadata( + controller, monkeypatch, tmp_path +): + volume_root = tmp_path / "protected-volume" + releases = volume_root / "releases" + release = releases / ("a" * 40) + release.mkdir(parents=True) + (release / "source.py").write_text("safe", encoding="utf-8") + volume_root.chmod(0o700) + releases.chmod(0o700) + release.chmod(0o555) + monkeypatch.setattr(controller, "MCP_HUB_RELEASES_ROOT", releases) + monkeypatch.setattr(controller, "_root_owned", lambda _path: True) + + with pytest.raises(controller.DeployError, match="outside its fixed Docker-local volume path"): + controller._mcp_hub_require_trusted_release_anchor() + + releases.chmod(0o700) + monkeypatch.setattr( + controller, + "_mcp_hub_require_trusted_release_anchor", + lambda **_kwargs: releases, + ) + (release / "source.py").chmod(0o644) + with pytest.raises(controller.DeployError, match="writable or not canonical"): + controller._mcp_hub_verify_release_tree_metadata(release) + + +def test_hub_rollback_mount_contract_ignores_only_release_generation_sha(controller): + old = [{ + "type": "bind", + "source": (controller.MCP_HUB_RELEASES_ROOT / ("a" * 40) / "services/runtime/worker.py").as_posix(), + "destination": "/opt/hub/worker.py", + "rw": False, + "name": "", + }] + candidate = [{ + **old[0], + "source": (controller.MCP_HUB_RELEASES_ROOT / ("b" * 40) / "services/runtime/worker.py").as_posix(), + }] + assert controller._mcp_hub_mount_contract_equal(old, candidate) + candidate[0]["source"] = ( + controller.MCP_HUB_RELEASES_ROOT / ("b" * 40) / "services/runtime/other.py" + ).as_posix() + assert not controller._mcp_hub_mount_contract_equal(old, candidate) + candidate[0]["source"] = "/srv/example/appdata/other/worker.py" + assert not controller._mcp_hub_mount_contract_equal(old, candidate) + + +def test_legacy_release_source_is_only_a_canonical_captured_path_identity( + controller, monkeypatch, tmp_path +): + appdata = tmp_path / "runtime-writable-appdata" + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", appdata) + revision = "a" * 40 + legacy = (appdata / "deployment" / "releases" / revision / "catalog").as_posix() + + assert controller._mcp_hub_legacy_release_source(legacy) == (revision, "catalog") + assert controller._mcp_hub_legacy_release_source(legacy + "/../catalog") is None + assert controller._mcp_hub_release_source(legacy) is None + + +def test_live_mount_release_generation_is_bound_to_each_service_image(controller, monkeypatch, tmp_path): + image_revision = "a" * 40 + candidate_revision = "c" * 40 + monkeypatch.setattr(controller, "_mcp_hub_release_tree_verified", lambda *_args: True) + monkeypatch.setattr( + controller, + "_mcp_hub_is_ancestor", + lambda _p, ancestor, descendant: ancestor in {image_revision, "b" * 40} + and descendant == candidate_revision, + ) + monkeypatch.setattr(controller, "exact_source_matches_revision", lambda *_args: True) + image_id = "sha256:" + "a" * 64 + image = { + "Id": image_id, + "Config": {"Labels": { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision, + }}, + } + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + model = { + "services": {"control-api": { + "volumes": [{ + "type": "bind", + "source": f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path(candidate_revision)}/catalog", + "target": "/opt/hub/catalog", + "read_only": True, + }], + "secrets": [], "tmpfs": [], + }}, + "volumes": {}, "secrets": {}, + } + container = { + "Image": image_id, + "Mounts": [{ + "Type": "bind", + "Source": f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path(image_revision)}/catalog", + "Destination": "/opt/hub/catalog", + "RW": False, + }], + } + project = { + "_repository_path": "/fixed/hub.git", + "_deployment_metadata": {"revision": candidate_revision}, + } + + attested = controller.mcp_hub_attested_live_mounts( + model, "control-api", controller.MCP_HUB_PROJECT, container, project=project + ) + + assert attested[0]["source"] == ( + f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path(image_revision)}/catalog" + ) + container["Mounts"][0]["Source"] = f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path('b' * 40)}/catalog" + with pytest.raises(controller.DeployError, match="source tree differs from its OCI image revision"): + controller.mcp_hub_attested_live_mounts( + model, "control-api", controller.MCP_HUB_PROJECT, container, project=project + ) + + +def test_legacy_live_release_mount_is_rebased_to_protected_tree_only_in_transition( + controller, monkeypatch, tmp_path +): + image_revision = "a" * 40 + candidate_revision = "c" * 40 + monkeypatch.setattr(controller, "_mcp_hub_release_tree_verified", lambda *_args: True) + monkeypatch.setattr( + controller, + "_mcp_hub_is_ancestor", + lambda _project, ancestor, descendant: ancestor in {image_revision, "b" * 40} + and descendant == candidate_revision, + ) + image_id = "sha256:" + "a" * 64 + monkeypatch.setattr( + controller, + "inspect_image", + lambda _image_id: { + "Id": image_id, + "Config": {"Labels": { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision, + }}, + }, + ) + model = { + "services": {"control-api": { + "volumes": [{ + "type": "bind", + "source": f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path(candidate_revision)}/catalog", + "target": "/opt/hub/catalog", + "read_only": True, + }], + "secrets": [], "tmpfs": [], + }}, + "volumes": {}, "secrets": {}, + } + captured_legacy_source = "/srv/example/appdata/itworx-mcp-hub/deployment/releases/" + image_revision + "/catalog" + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", Path("/srv/example/appdata/itworx-mcp-hub")) + container = { + "Image": image_id, + "Mounts": [{ + "Type": "bind", + "Source": captured_legacy_source, + "Destination": "/opt/hub/catalog", + "RW": False, + }], + } + project = { + "_repository_path": "/fixed/hub.git", + "_deployment_metadata": {"revision": candidate_revision}, + "_mcp_hub_allow_legacy_live_mounts": True, + } + + attested = controller.mcp_hub_attested_live_mounts( + model, "control-api", controller.MCP_HUB_PROJECT, container, project=project + ) + + assert attested[0]["source"] == captured_legacy_source + assert not (controller.MCP_HUB_APPDATA_ROOT / "deployment" / "releases").exists() + + container["Mounts"][0]["Source"] = ( + controller.MCP_HUB_APPDATA_ROOT + / "deployment" + / "releases" + / ("b" * 40) + / "catalog" + ).as_posix() + with pytest.raises(controller.DeployError, match="source tree differs from its OCI image revision"): + controller.mcp_hub_attested_live_mounts( + model, "control-api", controller.MCP_HUB_PROJECT, container, project=project + ) + + container["Mounts"][0]["Source"] = captured_legacy_source + project.pop("_mcp_hub_allow_legacy_live_mounts") + with pytest.raises(controller.DeployError, match="live mount set|release bind|release tree"): + controller.mcp_hub_attested_live_mounts( + model, "control-api", controller.MCP_HUB_PROJECT, container, project=project + ) + + +@pytest.mark.parametrize( + "field,value", + [ + ("branch", "release"), + ("compose_project", "other-project"), + ("source_dir", "/tmp/untrusted-source"), + ("manual", True), + ("deploy_script", "run-anything.sh"), + ("live_probe", {"type": "unexpected"}), + ("containers", ["itworx-mcp-hub-postgres-1"]), + ], +) +def test_hub_topology_derivation_rejects_any_target_outside_the_fixed_legacy_contract( + controller, field, value +): + target = _legacy_target(controller) + target[field] = value + + with pytest.raises(controller.DeployError, match="exact legacy 15-container contract"): + controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, target) + + +def test_rendered_model_requires_the_exact_17_services_and_intake_safety_gates(controller): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + model = _valid_model(controller) + + assert controller.validate_mcp_hub_compose_model(project, model) is model + + missing_intake = _valid_model(controller) + missing_intake["services"].pop("connector-ingestion") + with pytest.raises(controller.DeployError, match="fixed 17-service contract"): + controller.validate_mcp_hub_compose_model(project, missing_intake) + + wrong_profile = _valid_model(controller) + wrong_profile["services"]["connector-ingestion"]["profiles"] = [] + with pytest.raises(controller.DeployError, match="knowledge-intake"): + controller.validate_mcp_hub_compose_model(project, wrong_profile) + + intake_disabled_in_core = _valid_model(controller) + intake_disabled_in_core["services"]["worker"]["environment"][ + "INGESTION_CONNECTOR_ENABLED" + ] = "false" + with pytest.raises(controller.DeployError, match="preserve the existing enabled"): + controller.validate_mcp_hub_compose_model(project, intake_disabled_in_core) + + mutable_database = _valid_model(controller) + mutable_database["services"]["postgres"]["image"] = "postgres:latest" + with pytest.raises(controller.DeployError, match="PostgreSQL image"): + controller.validate_mcp_hub_compose_model(project, mutable_database) + + +def test_live_enabled_connector_flags_must_match_all_17_verified_services( + controller, monkeypatch +): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + model = _valid_model(controller) + containers = {} + for name in controller.MCP_HUB_CONTAINERS: + service_name = name.removeprefix("itworx-mcp-hub-").removesuffix("-1") + service = model["services"][service_name] + environment = ( + ["INGESTION_CONNECTOR_ENABLED=true"] + if service_name in {"control-api", "worker"} + else [] + ) + containers[name] = { + "Id": "c" * 64, + "Image": "sha256:" + "a" * 64, + "Config": { + "Image": service["image"], + "Env": environment, + "Labels": { + "com.docker.compose.project": controller.MCP_HUB_PROJECT, + "com.docker.compose.service": service_name, + }, + }, + "State": {"Running": True, "Health": {"Status": "healthy"}}, + } + monkeypatch.setattr( + controller, "compose_project_container_names", lambda _project: set(containers) + ) + monkeypatch.setattr(controller, "inspect_container", lambda name: containers.get(name)) + monkeypatch.setattr(controller, "mcp_hub_attested_live_mounts", lambda *_args, **_kwargs: []) + + result = controller.verify_mcp_hub_live_topology(project, model) + assert set(result) == set(controller.MCP_HUB_CONTAINERS) + + containers["itworx-mcp-hub-worker-1"]["Config"]["Env"] = [ + "INGESTION_CONNECTOR_ENABLED=false" + ] + with pytest.raises(controller.DeployError, match="live connector state differs"): + controller.verify_mcp_hub_live_topology(project, model) + + controller._mcp_hub_require_connector_state_preserved( + {"environment": {"INGESTION_CONNECTOR_ENABLED": "true"}}, + {"environment": {"INGESTION_CONNECTOR_ENABLED": "true"}}, + ) + with pytest.raises(controller.DeployError, match="changes the existing connector enablement"): + controller._mcp_hub_require_connector_state_preserved( + {"environment": {"INGESTION_CONNECTOR_ENABLED": "true"}}, + {"environment": {"INGESTION_CONNECTOR_ENABLED": "false"}}, + ) + + +def test_backup_inputs_match_the_secret_free_live_contract(controller): + recent_status = { + "schemaVersion": "1", + "completedAt": datetime.now(timezone.utc).isoformat(), + "restoreVerified": True, + "artifactCount": 1, + "artifacts": [{"name": "hub.dump", "bytes": 42, "sha256": "a" * 64}], + } + controller._mcp_hub_validate_backup_status(json.dumps(recent_status).encode()) + controller._mcp_hub_validate_revocations( + json.dumps({"schemaVersion": "1.0", "revokedJtis": ["a" * 32]}).encode() + ) + + stale_status = {**recent_status, "completedAt": (datetime.now(timezone.utc) - timedelta(hours=27)).isoformat()} + with pytest.raises(controller.DeployError, match="stale"): + controller._mcp_hub_validate_backup_status(json.dumps(stale_status).encode()) + + unverified_status = {**recent_status, "restoreVerified": False} + with pytest.raises(controller.DeployError, match="secret-free schema"): + controller._mcp_hub_validate_backup_status(json.dumps(unverified_status).encode()) + + malformed_revocations = {"schemaVersion": "1.0", "revokedJtis": ["not-a-jti"]} + with pytest.raises(controller.DeployError, match="revocation state is invalid"): + controller._mcp_hub_validate_revocations(json.dumps(malformed_revocations).encode()) + + +def test_registered_target_archive_projection_accepts_only_the_broker_schema(controller): + registrations = { + "schemaVersion": "1.0", + "containers": [ + { + "registeredId": "hub-control-api", + "project": "itworx-mcp-hub", + "environment": "prod", + "match": {"composeProject": "itworx-mcp-hub", "service": "control-api"}, + "allowedReads": ["inspect", "health"], + } + ], + "networks": [], + "defaultDecision": "deny", + } + + controller._mcp_hub_validate_registered_targets(json.dumps(registrations).encode()) + + with_token = json.loads(json.dumps(registrations)) + with_token["containers"][0]["token"] = "must-not-be-archived" + with pytest.raises(controller.DeployError, match="registration schema"): + controller._mcp_hub_validate_registered_targets(json.dumps(with_token).encode()) + + with_unknown_scope = json.loads(json.dumps(registrations)) + with_unknown_scope["containers"][0]["allowedReads"] = ["exec"] + with pytest.raises(controller.DeployError, match="registration schema"): + controller._mcp_hub_validate_registered_targets(json.dumps(with_unknown_scope).encode()) + + +def test_backup_mount_inventory_includes_exact_registry_source(controller, monkeypatch, tmp_path): + config_root = tmp_path / "appdata" / "config" + config_root.mkdir(parents=True) + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", config_root.parent) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_STATUS_SOURCE", config_root / "backup-status.json") + monkeypatch.setattr(controller, "MCP_HUB_REVOCATIONS_SOURCE", config_root / "demo-token-revocations.json") + monkeypatch.setattr(controller, "MCP_HUB_REGISTERED_TARGETS_SOURCE", config_root / "registered-targets.json") + model = _valid_model(controller) + model["services"]["control-api"]["volumes"] = [ + { + "type": "bind", + "source": str(config_root / "backup-status.json"), + "target": controller.MCP_HUB_BACKUP_STATUS_TARGET, + "read_only": True, + } + ] + model["services"]["dataplane-router"]["volumes"] = [ + { + "type": "bind", + "source": str(config_root), + "target": "/etc/itworx", + "read_only": True, + } + ] + registry_mount = { + "type": "bind", + "source": str(config_root / "registered-targets.json"), + "target": "/etc/itworx/registered-targets.json", + "read_only": True, + } + model["services"]["docker-read-broker"]["volumes"] = [registry_mount] + model["services"]["connector-unraid"]["volumes"] = [registry_mount] + + inputs = controller.mcp_hub_fixed_backup_inputs(model) + + assert inputs["registered_targets"] == config_root / "registered-targets.json" + + model["services"]["connector-unraid"]["volumes"][0]["source"] = str(config_root / "other-targets.json") + with pytest.raises(controller.DeployError, match="registration mount"): + controller.mcp_hub_fixed_backup_inputs(model) + + +def test_all_secret_file_mounts_are_fixed_private_metadata_only_inputs( + controller, monkeypatch, tmp_path +): + appdata_root = tmp_path / "appdata" + secrets_root = appdata_root / "secrets" + secrets_root.mkdir(parents=True) + secrets_root.chmod(0o700) + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", appdata_root) + monkeypatch.setattr(controller, "MCP_HUB_SECRETS_ROOT", secrets_root) + model = _valid_model(controller) + model["secrets"] = {} + secret_owner = secrets_root.stat() + model["services"]["control-api"]["environment"].update( + { + "SECRET_EXPECTED_UID": str(getattr(secret_owner, "st_uid", 1000)), + "SECRET_EXPECTED_GID": str(getattr(secret_owner, "st_gid", 1000)), + } + ) + for name in controller.MCP_HUB_REQUIRED_SECRET_NAMES: + secret_file = secrets_root / name + secret_file.write_bytes(b"fixture-only-secret-value") + secret_file.chmod(0o600) + model["secrets"][name] = {"file": str(secret_file)} + + assert controller.mcp_hub_validate_secret_files(model) == len( + controller.MCP_HUB_REQUIRED_SECRET_NAMES + ) + + wrong_path = secrets_root / "alternate" + wrong_path.write_bytes(b"fixture-only-secret-value") + wrong_path.chmod(0o600) + model["secrets"][controller.MCP_HUB_REQUIRED_SECRET_NAMES[0]]["file"] = str(wrong_path) + with pytest.raises(controller.DeployError, match="fixed secret-file contract"): + controller.mcp_hub_validate_secret_files(model) + + model["secrets"][controller.MCP_HUB_REQUIRED_SECRET_NAMES[0]]["file"] = str( + secrets_root / controller.MCP_HUB_REQUIRED_SECRET_NAMES[0] + ) + missing_name = controller.MCP_HUB_REQUIRED_SECRET_NAMES[-1] + (secrets_root / missing_name).unlink() + with pytest.raises(controller.DeployError, match="secret file is unavailable"): + controller.mcp_hub_validate_secret_files(model) + + missing_path = secrets_root / missing_name + missing_path.write_bytes(b"fixture-only-secret-value") + missing_path.chmod(0o600) + if os.name != "nt": + valid_secret = secrets_root / controller.MCP_HUB_REQUIRED_SECRET_NAMES[0] + valid_secret.chmod(0o640) + with pytest.raises(controller.DeployError, match="metadata is unsafe"): + controller.mcp_hub_validate_secret_files(model) + + metadata = types.SimpleNamespace( + st_mode=stat.S_IFREG | 0o600, + st_size=1, + st_uid=1000, + st_gid=1000, + ) + assert controller._mcp_hub_secret_metadata_matches( + metadata, 1000, 1000, platform_name="posix" + ) + assert not controller._mcp_hub_secret_metadata_matches( + metadata, 1001, 1000, platform_name="posix" + ) + + +def test_database_backup_capacity_uses_dump_and_restore_size_headroom(controller, monkeypatch): + sizes = {"itworx_mcp_hub": 1024**3, "contextforge": 2 * 1024**3} + required_before = ( + sum(sizes.values()) * controller.MCP_HUB_BACKUP_SIZE_MULTIPLIER + + controller.MCP_HUB_MIN_BACKUP_FREE_BYTES + ) + monkeypatch.setattr( + controller.shutil, + "disk_usage", + lambda _path: types.SimpleNamespace(free=required_before - 1), + ) + with pytest.raises(controller.DeployError, match="dump and restore capacity"): + controller._mcp_hub_require_backup_space(sizes) + + controller._mcp_hub_require_backup_space(sizes, after_dumps=True) + monkeypatch.setattr( + controller.shutil, + "disk_usage", + lambda _path: types.SimpleNamespace( + free=(sum(sizes.values()) * 2 + controller.MCP_HUB_MIN_BACKUP_FREE_BYTES) + ), + ) + controller._mcp_hub_require_backup_space(sizes, after_dumps=True) + + +def test_database_size_query_is_limited_to_two_fixed_database_names(controller, monkeypatch): + captured = {} + + def docker(*arguments, timeout): + captured["arguments"] = arguments + captured["timeout"] = timeout + return types.SimpleNamespace( + stdout=b"contextforge:23\nitworx_mcp_hub:42\n", + returncode=0, + ) + + monkeypatch.setattr(controller, "_mcp_hub_docker", docker) + assert controller._mcp_hub_live_database_sizes() == { + "contextforge": 23, + "itworx_mcp_hub": 42, + } + query = captured["arguments"][-1] + assert "contextforge" in query and "itworx_mcp_hub" in query + assert "pg_database_size" in query + assert captured["timeout"] == 30 + + +def test_failed_backup_attempt_removes_only_its_unique_private_directory( + controller, monkeypatch, tmp_path +): + backup_root = tmp_path / "backups" + backup_root.mkdir(mode=0o700) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root) + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", tmp_path) + private_directory = controller._mcp_hub_private_directory + monkeypatch.setattr( + controller, + "_mcp_hub_private_directory", + lambda path, **kwargs: private_directory(path, root=backup_root, **kwargs), + ) + monkeypatch.setattr(controller, "mcp_hub_validate_secret_files", lambda _model: 21) + monkeypatch.setattr( + controller, + "mcp_hub_fixed_backup_inputs", + lambda _model: { + "backup_status": Path("status"), + "demo_token_revocations": Path("revocations"), + "registered_targets": Path("registered-targets"), + }, + ) + monkeypatch.setattr(controller, "_mcp_hub_fixed_input_file", lambda *_args, **_kwargs: b"safe") + monkeypatch.setattr(controller, "_mcp_hub_validate_backup_status", lambda _data: None) + monkeypatch.setattr(controller, "_mcp_hub_validate_revocations", lambda _data: None) + monkeypatch.setattr(controller, "_mcp_hub_validate_registered_targets", lambda _data: None) + monkeypatch.setattr(controller, "_mcp_hub_live_database_sizes", lambda: { + "contextforge": 1, + "itworx_mcp_hub": 1, + }) + monkeypatch.setattr(controller, "_mcp_hub_require_backup_space", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + controller, + "_capture_mcp_hub_backup_attempt", + lambda *_args, **_kwargs: (_ for _ in ()).throw(controller.DeployError("fixture failure")), + ) + project = {"_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT} + + with pytest.raises(controller.DeployError, match="fixture failure"): + controller.capture_mcp_hub_backup( + project, + "a" * 40, + {}, + {}, + runtime={}, + image_facts={}, + ) + + assert list(backup_root.iterdir()) == [] + + +def test_rollback_keeps_digest_refs_immutable_and_only_tags_local_refs(controller, monkeypatch): + image_id = "sha256:" + "a" * 64 + digest = "sha256:" + "b" * 64 + image = { + "Id": image_id, + "RepoDigests": [f"registry.example/hub/service@{digest}"], + } + tagged = [] + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + monkeypatch.setattr(controller, "run", lambda command: tagged.append(command)) + + controller._mcp_hub_prepare_rollback_image( + image_id, f"registry.example/hub/service@{digest}" + ) + assert tagged == [] + + controller._mcp_hub_prepare_rollback_image(image_id, "itworx-mcp-hub/service:old") + assert tagged == [["docker", "image", "tag", image_id, "itworx-mcp-hub/service:old"]] + + +def test_connector_ingestion_requires_both_digest_and_hub_source_ancestry(controller, monkeypatch): + image_id = "sha256:" + "a" * 64 + digest = "sha256:" + "b" * 64 + image_ref = f"registry.example/hub/connector-ingestion@{digest}" + revision = "1" * 40 + candidate = "2" * 40 + image = { + "Id": image_id, + "RepoDigests": [f"registry.example/hub/connector-ingestion@{digest}"], + "Config": { + "Labels": { + "org.opencontainers.image.source": "https://untrusted.example/hub", + "org.opencontainers.image.revision": revision, + } + }, + } + container = { + "Id": "c" * 64, + "Image": image_id, + "Config": {"Image": image_ref, "Labels": {}}, + } + project = {"_repository_path": "/fixed/hub.git"} + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: True) + with pytest.raises(controller.DeployError, match="provenance"): + controller._mcp_hub_live_image_proven( + project, + "itworx-mcp-hub-connector-ingestion-1", + container, + image_ref, + revision, + candidate, + require_current_provenance=True, + ) + + image["Config"]["Labels"]["org.opencontainers.image.source"] = ( + "https://gitea.example.com/jens/itworx-mcp-hub" + ) + result = controller._mcp_hub_live_image_proven( + project, + "itworx-mcp-hub-connector-ingestion-1", + container, + image_ref, + revision, + candidate, + require_current_provenance=True, + ) + assert result["image_revision"] == revision + + +def test_owned_image_revision_may_be_older_than_the_mixed_controller_receipt( + controller, monkeypatch +): + image_id = "sha256:" + "a" * 64 + digest = "sha256:" + "b" * 64 + image_ref = f"registry.example/hub/control-api@{digest}" + older_service_revision = "a" * 40 + controller_receipt_revision = "b" * 40 + candidate_revision = "c" * 40 + image = { + "Id": image_id, + "RepoDigests": [f"registry.example/hub/control-api@{digest}"], + "Config": {"Labels": { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": older_service_revision, + }}, + } + container = { + "Id": "d" * 64, + "Image": image_id, + "Config": {"Image": image_ref, "Labels": {}}, + } + ancestor_checks = [] + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + monkeypatch.setattr( + controller, + "_mcp_hub_is_ancestor", + lambda _project, ancestor, descendant: ( + ancestor_checks.append((ancestor, descendant)) or ancestor == older_service_revision + and descendant == candidate_revision + ), + ) + + proven = controller._mcp_hub_live_image_proven( + {"_repository_path": "/fixed/hub.git"}, + "itworx-mcp-hub-control-api-1", + container, + image_ref, + controller_receipt_revision, + candidate_revision, + require_current_provenance=True, + ) + + assert proven["image_revision"] == older_service_revision + assert ancestor_checks == [(older_service_revision, candidate_revision)] + + +@pytest.mark.parametrize( + ("container_name", "image_ref"), + [ + ("itworx-mcp-hub-contextforge-1", "itworx-mcp-hub/contextforge-schema:abc123456789"), + ("itworx-mcp-hub-dataplane-router-1", "itworx-mcp-hub/dataplane:sessionless-final"), + ], +) +def test_compatibility_tags_do_not_prove_candidate_image_bytes( + controller, monkeypatch, container_name, image_ref +): + image_id = "sha256:" + "a" * 64 + image = { + "Id": image_id, + "RepoDigests": [], + "Config": {"Labels": { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": "b" * 40, + }}, + } + container = { + "Id": "c" * 64, + "Image": image_id, + "Config": {"Image": image_ref, "Labels": {}}, + } + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: True) + monkeypatch.setattr(controller, "_mcp_hub_git_output", lambda *_args: "") + + with pytest.raises(controller.DeployError, match="provenance"): + controller._mcp_hub_live_image_proven( + {"_repository_path": "/fixed/hub.git"}, + container_name, + container, + image_ref, + "d" * 40, + "e" * 40, + require_current_provenance=True, + ) + + +def test_contextforge_candidate_requires_protected_digest_and_hub_image_provenance( + controller, monkeypatch +): + image_id = "sha256:" + "a" * 64 + digest = "sha256:" + "b" * 64 + image_ref = f"itworx-mcp-hub/contextforge-schema:abc123456789@{digest}" + candidate = "e" * 40 + image_revision = "d" * 40 + manifest = "\n".join( + ( + "CONTEXTFORGE_UPSTREAM_COMMIT=" + "1" * 40, + "CONTEXTFORGE_ALEMBIC_HEAD=abc123456789", + "CONTEXTFORGE_MCP_SDK=1.2.3", + "CONTEXTFORGE_LOCAL_IMAGE=itworx-mcp-hub/contextforge-schema:abc123456789", + f"CONTEXTFORGE_IMAGE_DIGEST={digest}", + ) + ) + builder = "\n".join( + ( + 'git -C "$SRC" fetch --depth 1 origin "$CONTEXTFORGE_UPSTREAM_COMMIT"', + 'git -C "$SRC" checkout --detach FETCH_HEAD', + "alembic -c mcpgateway/alembic.ini heads", + 'docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"', + ) + ) + image = { + "Id": image_id, + "RepoDigests": [f"itworx-mcp-hub/contextforge-schema@{digest}"], + "Config": {"Labels": { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision, + }}, + } + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: True) + monkeypatch.setattr( + controller, + "_mcp_hub_git_output", + lambda _project, _operation, path: manifest if path.endswith("compat-image.env") else builder, + ) + project = {"_repository_path": "/fixed/hub.git"} + + assert controller._mcp_hub_contextforge_compat_proven( + project, candidate, image_ref, image_id + ) + + for labels, image_reference, contents in ( + ( + {"org.opencontainers.image.source": "https://untrusted.example/hub", + "org.opencontainers.image.revision": image_revision}, + image_ref, + manifest, + ), + ( + {"org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": "invalid"}, + image_ref, + manifest, + ), + ( + {"org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision}, + image_ref, + manifest.replace(f"CONTEXTFORGE_IMAGE_DIGEST={digest}", ""), + ), + ( + {"org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision}, + image_ref, + manifest.replace(digest, "sha256:" + "c" * 64), + ), + ): + image["Config"]["Labels"] = labels + monkeypatch.setattr( + controller, + "_mcp_hub_git_output", + lambda _project, _operation, path, contents=contents: ( + contents if path.endswith("compat-image.env") else builder + ), + ) + assert not controller._mcp_hub_contextforge_compat_proven( + project, candidate, image_reference, image_id + ) + + image["Config"]["Labels"] = { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": image_revision, + } + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: False) + monkeypatch.setattr( + controller, + "_mcp_hub_git_output", + lambda _project, _operation, path: manifest if path.endswith("compat-image.env") else builder, + ) + assert not controller._mcp_hub_contextforge_compat_proven( + project, candidate, image_ref, image_id + ) + + +@pytest.mark.parametrize( + ("image_source", "image_revision", "ancestor"), + [ + ("https://gitea.example.com/jens/itworx-mcp-hub", "c" * 40, True), + ("https://untrusted.example/hub", "c" * 40, True), + ("https://gitea.example.com/jens/itworx-mcp-hub", "invalid", True), + ("https://gitea.example.com/jens/itworx-mcp-hub", "a" * 40, False), + ], +) +def test_dataplane_candidate_requires_exact_source_and_revision_with_the_fix( + controller, monkeypatch, image_source, image_revision, ancestor +): + image_id = "sha256:" + "a" * 64 + digest = "sha256:" + "b" * 64 + image_ref = f"itworx-mcp-hub/dataplane:sessionless-final@{digest}" + candidate = "d" * 40 + required_commit = "b519a8c4cf17cd9df00a575acfe096838aab7789" + image = { + "Id": image_id, + "RepoDigests": [f"itworx-mcp-hub/dataplane@{digest}"], + "Config": {"Labels": { + "org.opencontainers.image.source": image_source, + "org.opencontainers.image.revision": image_revision, + }}, + } + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + checks = [] + + def is_ancestor(_project, base, head): + checks.append((base, head)) + return ancestor and ( + (base == required_commit and head == image_revision) + or (base == image_revision and head == candidate) + ) + + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", is_ancestor) + monkeypatch.setattr( + controller, + "_mcp_hub_git_output", + lambda _project, _operation, path: ( + 'MCP_TRANSPORT_STATE_HEADERS = frozenset({b"mcp-session-id"})\n' + 'del response.headers["mcp-session-id"]\n2025-11-25 2026-07-28 mcp-session-id' + if path.endswith("router.py") or path.endswith("test_dataplane_session_headers.py") + else f"{required_commit} itworx-mcp-hub/dataplane:sessionless-final permanently enforce stateless public transport" + ), + ) + + proven = controller._mcp_hub_dataplane_compat_proven( + {"_repository_path": "/fixed/hub.git"}, + candidate, + "itworx-mcp-hub-dataplane-router-1", + image_ref, + image_id, + ) + assert proven is (image_source == controller.MCP_HUB_OCI_IMAGE_SOURCE + and image_revision != "invalid" and ancestor) + if proven: + assert checks == [ + (required_commit, image_revision), + (image_revision, candidate), + ] + + +def test_unchanged_compatibility_image_requires_exact_legacy_receipt_identity( + controller, monkeypatch +): + image_id = "sha256:" + "a" * 64 + image_ref = "itworx-mcp-hub/dataplane:sessionless-final" + container_id = "c" * 64 + image = {"Id": image_id, "RepoDigests": [], "Config": {"Labels": {}}} + container = { + "Id": container_id, + "Image": image_id, + "Config": {"Image": image_ref, "Labels": {}}, + } + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + receipt_identity = { + "container_id": container_id, + "image_id": image_id, + "image_ref": image_ref, + } + + observed = controller._mcp_hub_live_image_proven( + {"_mcp_hub_legacy_receipt": True}, + "itworx-mcp-hub-dataplane-router-1", + container, + image_ref, + "b" * 40, + "c" * 40, + require_current_provenance=False, + prior_receipt_identity=receipt_identity, + ) + assert observed["image_id"] == image_id + + changed = {**container, "Id": "d" * 64} + with pytest.raises(controller.DeployError, match="exact verified receipt"): + controller._mcp_hub_live_image_proven( + {"_mcp_hub_legacy_receipt": True}, + "itworx-mcp-hub-dataplane-router-1", + changed, + image_ref, + "b" * 40, + "c" * 40, + require_current_provenance=False, + prior_receipt_identity=receipt_identity, + ) + + +def test_catalog_archiver_accepts_only_bounded_regular_catalog_members(controller, monkeypatch): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:") as archive: + item = tarfile.TarInfo("catalog/approved.json") + item.size = 2 + archive.addfile(item, io.BytesIO(b"{}")) + monkeypatch.setattr(controller, "repository_path_from_project", lambda _project: "/fixed/hub.git") + monkeypatch.setattr( + controller.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0, stdout=stream.getvalue()), + ) + payload = controller._mcp_hub_git_catalog_archive({"slug": "itworx-mcp-hub"}, "a" * 40) + assert payload == stream.getvalue() + + unsafe = io.BytesIO() + with tarfile.open(fileobj=unsafe, mode="w:") as archive: + item = tarfile.TarInfo("catalog/../outside") + item.size = 1 + archive.addfile(item, io.BytesIO(b"x")) + monkeypatch.setattr( + controller.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0, stdout=unsafe.getvalue()), + ) + with pytest.raises(controller.DeployError, match="unsafe member"): + controller._mcp_hub_git_catalog_archive({"slug": "itworx-mcp-hub"}, "a" * 40) + + +def test_backup_catalog_archives_bind_exact_mount_and_active_image_revisions( + controller, monkeypatch, tmp_path +): + legacy_appdata_root = Path("C:/runtime-appdata") if os.name == "nt" else Path("/srv/example/appdata/itworx-mcp-hub") + monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", legacy_appdata_root) + source_dir = tmp_path / "source" + candidate_revision = "b" * 40 + candidate_catalog = f"{controller._MCP_HUB_RELEASE_STORE.logical_release_path(candidate_revision)}/catalog" + model = {"services": {}, "volumes": {}, "secrets": {}} + runtime = {} + image_facts = {} + active_revisions = { + "control-api": "8" * 40, + "worker": "8" * 40, + "dataplane-router": "a" * 40, + } + for service in controller.MCP_HUB_CATALOG_SERVICES: + container = f"itworx-mcp-hub-{service}-1" + rendered_mount = { + "type": "bind", + "source": candidate_catalog, + "target": "/opt/hub/catalog", + "read_only": True, + } + mount = { + "type": "bind", + "source": ( + controller.MCP_HUB_APPDATA_ROOT + / "deployment" + / "releases" + / active_revisions[service] + / "catalog" + ).as_posix(), + "destination": "/opt/hub/catalog", + "rw": False, + "name": "", + } + model["services"][service] = {"volumes": [rendered_mount], "secrets": [], "tmpfs": []} + runtime[container] = {"_appops_attested_mounts": [mount]} + image_facts[container] = { + "image_revision": active_revisions[service], + "image_source": "https://gitea.example.com/jens/itworx-mcp-hub", + } + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: True) + monkeypatch.setattr(controller, "_mcp_hub_git_catalog_archive", lambda *_args: b"fixed-tar") + backup = tmp_path / "backup" + backup.mkdir() + + archives = controller._mcp_hub_capture_catalog_archives( + {"source_dir": str(source_dir), "_mcp_hub_allow_legacy_live_mounts": True}, + candidate_revision, + model, + runtime, + image_facts, + backup, + ) + + assert {item["revision"] for item in archives} == { + active_revisions["control-api"], active_revisions["dataplane-router"], candidate_revision + } + assert all((backup / item["file"]).read_bytes() == b"fixed-tar" for item in archives) + + model["services"]["worker"]["volumes"][0]["read_only"] = False + runtime["itworx-mcp-hub-worker-1"]["_appops_attested_mounts"][0]["rw"] = True + with pytest.raises(controller.DeployError, match="catalog source is not bound"): + controller._mcp_hub_capture_catalog_archives( + {"source_dir": str(source_dir), "_mcp_hub_allow_legacy_live_mounts": True}, + candidate_revision, + model, + runtime, + image_facts, + tmp_path / "negative-backup", + ) + + +def test_pg_restore_streams_private_dump_over_stdin_without_mounting_backup( + controller, monkeypatch, tmp_path +): + backup_root = tmp_path / "backups" + backup_root.mkdir(mode=0o700) + backup = backup_root / "fixed-run" + backup.mkdir(mode=0o700) + dump = backup / "contextforge.dump" + dump.write_bytes(b"private dump payload") + dump.chmod(0o600) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root) + captured = {} + + def run(command, **kwargs): + captured["command"] = command + captured["stdin"] = kwargs["stdin"].read() + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr(controller.subprocess, "run", run) + controller._mcp_hub_pg_restore_dump("a" * 64, "contextforge", backup) + + assert captured["stdin"] == b"private dump payload" + assert captured["command"] == [ + "docker", "exec", "--interactive", "--user", "postgres", "a" * 64, + "pg_restore", "--host=/run/postgresql", "--username=postgres", "--no-password", + "--no-owner", "--no-privileges", "--exit-on-error", "--single-transaction", + "--dbname=contextforge", "-", + ] + + +def test_private_backup_artifacts_publish_atomically_without_overwrite( + controller, tmp_path +): + artifact = tmp_path / "fixed-backup.json" + + controller._mcp_hub_write_private_new(artifact, b"first") + + assert artifact.read_bytes() == b"first" + assert list(tmp_path.glob("*.tmp")) == [] + with pytest.raises(controller.DeployError, match="already exists"): + controller._mcp_hub_write_private_new(artifact, b"second") + assert artifact.read_bytes() == b"first" + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_live_mount_inventory_includes_volume_identity_source_and_read_only_state( + controller, monkeypatch +): + config_source = (Path("/srv/example/appdata/itworx-mcp-hub/config") if sys.platform != "win32" + else Path("C:/appdata/itworx-mcp-hub/config")) + model = { + "services": { + "postgres": { + "image": controller.MCP_HUB_POSTGRES_IMAGE, + "environment": {"PGDATA": controller.MCP_HUB_POSTGRES_PGDATA}, + "volumes": [ + {"type": "volume", "source": "pgdata", "target": "/var/lib/postgresql/data", "read_only": False}, + {"type": "bind", "source": str(config_source), "target": "/etc/itworx", "read_only": True}, + ], + "secrets": [], + "tmpfs": ["/run:rw,nosuid"], + } + }, + "volumes": {"pgdata": {"name": "itworx-mcp-hub-postgres"}}, + "secrets": {}, + } + anon_name = "f" * 64 + mountpoint = "/var/lib/docker/volumes/itworx-mcp-hub-postgres/_data" + anon_mountpoint = f"/var/lib/docker/volumes/{anon_name}/_data" + image_id = "sha256:" + "a" * 64 + live = { + "Image": image_id, + "Config": {"Image": controller.MCP_HUB_POSTGRES_IMAGE}, + "Mounts": [ + {"Type": "volume", "Name": "itworx-mcp-hub-postgres", "Source": mountpoint, "Driver": "local", "Destination": "/var/lib/postgresql/data", "RW": True}, + {"Type": "volume", "Name": anon_name, "Source": anon_mountpoint, "Driver": "local", "Destination": "/var/lib/postgresql", "RW": True}, + {"Type": "bind", "Source": str(config_source), "Destination": "/etc/itworx", "RW": False}, + {"Type": "tmpfs", "Source": "", "Destination": "/run", "RW": True}, + ] + } + volume_data = { + "itworx-mcp-hub-postgres": mountpoint, + anon_name: anon_mountpoint, + } + monkeypatch.setattr( + controller, + "_mcp_hub_docker", + lambda _kind, _operation, name, **_kwargs: subprocess.CompletedProcess( + [], 0, + stdout=( + f'[{json.dumps({"Name": name, "Driver": "local", "Mountpoint": volume_data[name], "Options": {}})}]' + ).encode("utf-8"), + ), + ) + monkeypatch.setattr( + controller, + "inspect_image", + lambda image: { + "Id": image_id, + "RepoDigests": [controller.MCP_HUB_POSTGRES_IMAGE.split("@", 1)[0].split(":", 1)[0] + "@" + controller.MCP_HUB_POSTGRES_IMAGE.split("@", 1)[1]], + "Config": {"Volumes": {"/var/lib/postgresql": {}}}, + }, + ) + + attested = controller.mcp_hub_attested_live_mounts( + model, "postgres", controller.MCP_HUB_PROJECT, live + ) + volume = next( + item for item in attested + if item["type"] == "volume" and item["destination"] == "/var/lib/postgresql/data" + ) + assert volume == { + "type": "volume", + "source": mountpoint, + "destination": "/var/lib/postgresql/data", + "rw": True, + "name": "itworx-mcp-hub-postgres", + "driver": "local", + "options": {}, + } + anonymous_volume = next(item for item in attested if item["destination"] == "/var/lib/postgresql") + assert anonymous_volume == { + "type": "volume", + "source": anon_mountpoint, + "destination": "/var/lib/postgresql", + "rw": True, + "name": anon_name, + "driver": "local", + "options": {}, + } + assert next(item for item in attested if item["destination"] == "/etc/itworx")["rw"] is False + live["Mounts"][0]["Name"] = "unexpected-postgres-volume" + with pytest.raises(controller.DeployError, match="volume name"): + controller.mcp_hub_attested_live_mounts( + model, "postgres", controller.MCP_HUB_PROJECT, live + ) + + +@pytest.mark.parametrize( + "mutate,match", + [ + (lambda mount, _image: mount.update(Destination="/var/lib/postgresql/other"), "anonymous volume is missing"), + (lambda mount, _image: mount.update(Name="bad-anonymous-volume"), "anonymous"), + (lambda mount, _image: mount.update(RW=False), "anonymous volume identity"), + (lambda _mount, image: image.update(Config={"Volumes": {"/unexpected": {}}}), "image-declared"), + ], +) +def test_postgres_anonymous_volume_is_fixed_to_the_pinned_image_and_path( + controller, monkeypatch, mutate, match +): + volume_name = "f" * 64 + image_id = "sha256:" + "a" * 64 + mountpoint = f"/var/lib/docker/volumes/{volume_name}/_data" + live_mount = { + "Type": "volume", "Name": volume_name, "Source": mountpoint, + "Driver": "local", "Destination": "/var/lib/postgresql", "RW": True, + } + image = { + "Id": image_id, + "RepoDigests": [controller.MCP_HUB_POSTGRES_IMAGE.split("@", 1)[0].split(":", 1)[0] + "@" + controller.MCP_HUB_POSTGRES_IMAGE.split("@", 1)[1]], + "Config": {"Volumes": {"/var/lib/postgresql": {}}}, + } + mutate(live_mount, image) + volume_data = { + "itworx-mcp-hub-postgres": "/var/lib/docker/volumes/itworx-mcp-hub-postgres/_data", + volume_name: mountpoint, + } + + def inspect_volume(_kind, _operation, name, **_kwargs): + payload = { + "Name": name, + "Driver": "local", + "Mountpoint": volume_data[name], + "Options": {}, + } + return subprocess.CompletedProcess([], 0, stdout=json.dumps([payload]).encode("utf-8")) + + monkeypatch.setattr(controller, "_mcp_hub_docker", inspect_volume) + monkeypatch.setattr(controller, "inspect_image", lambda _image_id: image) + model = { + "services": { + "postgres": { + "image": controller.MCP_HUB_POSTGRES_IMAGE, + "environment": {"PGDATA": controller.MCP_HUB_POSTGRES_PGDATA}, + "volumes": [{ + "type": "volume", "source": "pgdata", + "target": "/var/lib/postgresql/data", "read_only": False, + }], + "secrets": [], "tmpfs": [], + } + }, + "volumes": {"pgdata": {"name": "itworx-mcp-hub-postgres"}}, + "secrets": {}, + } + container = { + "Image": image_id, + "Config": {"Image": controller.MCP_HUB_POSTGRES_IMAGE}, + "Mounts": [ + { + "Type": "volume", "Name": "itworx-mcp-hub-postgres", + "Source": "/var/lib/docker/volumes/itworx-mcp-hub-postgres/_data", + "Driver": "local", "Destination": "/var/lib/postgresql/data", "RW": True, + }, + live_mount, + ], + } + + with pytest.raises(controller.DeployError, match=match): + controller.mcp_hub_attested_live_mounts( + model, "postgres", controller.MCP_HUB_PROJECT, container + ) + + +def test_postgres_staged_deploy_must_preserve_both_volume_identities(controller): + data_volume = { + "type": "volume", + "source": "/var/lib/docker/volumes/itworx-mcp-hub-postgres/_data", + "destination": controller.MCP_HUB_POSTGRES_DATA_VOLUME, + "rw": True, + "name": "itworx-mcp-hub-postgres", + "driver": "local", + "options": {}, + } + anonymous_name = "f" * 64 + anonymous_volume = { + "type": "volume", + "source": f"/var/lib/docker/volumes/{anonymous_name}/_data", + "destination": controller.MCP_HUB_POSTGRES_ANONYMOUS_VOLUME, + "rw": True, + "name": anonymous_name, + "driver": "local", + "options": {}, + } + snapshot = { + "containers": [{ + "name": controller.MCP_HUB_POSTGRES_CONTAINER, + "mounts": [data_volume, anonymous_volume], + }] + } + runtime = { + controller.MCP_HUB_POSTGRES_CONTAINER: { + "_appops_attested_mounts": [data_volume, anonymous_volume] + } + } + + controller._mcp_hub_require_postgres_volumes_preserved(snapshot, runtime) + + changed = json.loads(json.dumps(runtime)) + changed[controller.MCP_HUB_POSTGRES_CONTAINER]["_appops_attested_mounts"][1][ + "name" + ] = "e" * 64 + with pytest.raises(controller.DeployError, match="changed a PostgreSQL data volume identity"): + controller._mcp_hub_require_postgres_volumes_preserved(snapshot, changed) + + +def test_restore_probe_is_offline_and_uses_only_temporary_database_storage( + controller, monkeypatch, tmp_path +): + backup_root = tmp_path / "backups" + backup_root.mkdir(mode=0o700) + backup = backup_root / "approved-backup" + backup.mkdir(mode=0o700) + monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root) + monkeypatch.setattr(controller, "_mcp_hub_private_directory", lambda path, **_kwargs: None) + monkeypatch.setattr(controller.time, "sleep", lambda _seconds: None) + calls = [] + + def docker(*arguments, timeout): + calls.append((arguments, timeout)) + stdout = ( + b"a" * 64 + if arguments[0] == "run" + else b"7" + if arguments[0] == "exec" and "psql" in arguments + else b"" + ) + return subprocess.CompletedProcess(arguments, 0, stdout=stdout) + + monkeypatch.setattr(controller, "_mcp_hub_docker", docker) + restored_dumps = [] + monkeypatch.setattr( + controller, + "_mcp_hub_pg_restore_dump", + lambda container_id, database, source: restored_dumps.append( + (container_id, database, source) + ), + ) + + result = controller._mcp_hub_pg_restore_probe(backup) + + run_args = calls[0][0] + assert result["status"] == "verified" + assert result["auditEvents"] == { + "database": "itworx_mcp_hub", + "table": "audit_events", + "restoredRows": 7, + } + assert run_args[0] == "run" + assert "--network" in run_args and run_args[run_args.index("--network") + 1] == "none" + assert "--read-only" in run_args and "--pull=never" in run_args + mounts = [item for item in run_args if item.startswith("type=bind,")] + assert len(mounts) == 1 + assert "target=/var/lib/postgresql/data" in mounts[0] + assert "target=/backup" not in mounts[0] and "production" not in mounts[0] + assert [database for _, database, _ in restored_dumps] == list(controller.MCP_HUB_DATABASES) + assert all(identifier == "a" * 64 and source == backup for identifier, _, source in restored_dumps) + assert [call[0][0] for call in calls].count("rm") == 1 + + +def _legacy_migration_fixture(controller, monkeypatch, tmp_path): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + candidate = "b" * 40 + previous = "a" * 40 + model = _valid_model(controller) + state_path = tmp_path / f"{controller.MCP_HUB_PROJECT}.json" + image_metadata = { + "container_revision": "", + "image_revision": "", + "image_source": "", + "image_tree": "", + "image_version": "", + "image_created": "", + } + old_artifacts = {} + receipt_containers = {} + runtime = {} + for index, name in enumerate(controller.MCP_HUB_CONTAINERS): + service_name = name.removeprefix("itworx-mcp-hub-").removesuffix("-1") + image_id = "sha256:" + f"{index + 1:064x}" + container_id = f"{index + 1:064x}" + image_ref = model["services"][service_name]["image"] + artifact = { + "container_id": container_id, + "image_id": image_id, + "image_ref": image_ref, + **image_metadata, + } + runtime[name] = { + "Id": container_id, + "Image": image_id, + "Config": {"Image": image_ref}, + "_appops_attested_mounts": [], + } + if name in controller.MCP_HUB_LEGACY_CONTAINERS: + old_artifacts[name] = dict(artifact) + receipt_containers[name] = dict(artifact) + tree = "c" * 40 + receipt = { + "schema": "gitea-deploy-receipt.v1", + "repository": controller.MCP_HUB_REPOSITORY, + "revision": previous, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "build": { + "id": previous[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": previous, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": "2026-09-30T10:00:00.000Z", + }, + "containers": receipt_containers, + "live": None, + } + prior = { + "schema_version": 3, + "repository": controller.MCP_HUB_REPOSITORY, + "slug": controller.MCP_HUB_PROJECT, + "compose_project": controller.MCP_HUB_PROJECT, + "revision": previous, + "live_revision": previous, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": {"containers": old_artifacts}, + "live_receipt": receipt, + "status": "success", + } + state_path.write_text(json.dumps(prior), encoding="utf-8") + state_path.chmod(0o600) + monkeypatch.setattr(controller, "STATE_ROOT", tmp_path) + monkeypatch.setattr(controller, "_root_owned", lambda _path: True) + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None) + monkeypatch.setattr(controller, "validate_recorded_receipt", lambda *_args: {}) + monkeypatch.setattr(controller, "_mcp_hub_git_output", lambda *_args: candidate) + monkeypatch.setattr(controller, "_mcp_hub_is_ancestor", lambda *_args: True) + monkeypatch.setattr( + controller, + "_mcp_hub_live_image_proven", + lambda _project, _name, container, image_ref, *_args, **_kwargs: { + "container_id": str(container["Id"]), + "image_id": str(container["Image"]), + "image_ref": image_ref, + **image_metadata, + }, + ) + backup_receipt = { + "restoreVerified": True, + "repository": controller.MCP_HUB_REPOSITORY, + "revision": candidate, + "manifestSha256": "d" * 64, + "composeSha256": "e" * 64, + } + return project, candidate, model, backup_receipt, runtime, state_path, prior + + +def test_legacy_state_migration_is_backup_bound_and_writes_a_complete_17_service_baseline( + controller, monkeypatch, tmp_path +): + project, candidate, model, backup, runtime, state_path, prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + if os.name == "nt": + real_open = os.open + sync_marker = tmp_path / "directory-fsync-marker" + + def open_directory_marker(path, flags, *args, **kwargs): + if Path(path) == tmp_path: + return real_open(sync_marker, os.O_CREAT | os.O_RDWR, 0o600) + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(controller.os, "open", open_directory_marker) + + result = controller.migrate_mcp_hub_legacy_state( + project, + controller.MCP_HUB_REPOSITORY, + candidate, + model, + backup, + runtime, + ) + + assert set(result["live_artifacts"]["containers"]) == set(controller.MCP_HUB_CONTAINERS) + assert result["live_artifacts"]["topology_contract"] == controller.MCP_HUB_TOPOLOGY_CONTRACT + assert result["live_legacy_provenance"] is True + assert result["live_receipt"] == prior["live_receipt"] + assert result["rollback_snapshot"] == {} + migration_backups = list((tmp_path / "mcp-hub-topology-migrations").glob("*.json")) + assert len(migration_backups) == 1 + assert json.loads(migration_backups[0].read_text(encoding="utf-8")) == prior + assert json.loads(state_path.read_text(encoding="utf-8"))["live_artifacts"]["topology_contract"] == controller.MCP_HUB_TOPOLOGY_CONTRACT + + +@pytest.mark.skipif(os.name == "nt", reason="migration intent durability requires POSIX directory fsync") +def test_migration_intent_is_durable_attempt_bound_and_blocks_restart_retry( + controller, monkeypatch, tmp_path +): + project, candidate, model, backup, runtime, state_path, _prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None) + controller.migrate_mcp_hub_legacy_state( + project, + controller.MCP_HUB_REPOSITORY, + candidate, + model, + backup, + runtime, + ) + + attempt_id = controller._mcp_hub_begin_migration_intent(project, candidate, backup) + intent_path = tmp_path / "mcp-hub-topology-migrations" / "active-intent.json" + intent_bytes = intent_path.read_bytes() + intent = json.loads(intent_bytes) + assert attempt_id == intent["attemptId"] + assert intent["status"] == "started" + assert intent["candidateRevision"] == candidate + assert intent["backupManifestSha256"] == backup["manifestSha256"] + assert intent["baselineStateSha256"] == hashlib.sha256(state_path.read_bytes()).hexdigest() + assert stat.S_IMODE(intent_path.stat().st_mode) == 0o600 + + # A fresh controller process sees the durable marker and refuses to start + # another migration while the preceding outcome is unknown. + with pytest.raises(controller.DeployError, match="retry and image-only rollback are blocked"): + controller._mcp_hub_assert_no_unresolved_migration_intent( + {"_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT} + ) + with pytest.raises(controller.DeployError, match="retry and image-only rollback are blocked"): + controller._mcp_hub_begin_migration_intent(project, candidate, backup) + assert intent_path.read_bytes() == intent_bytes + + # The only restart reconciliation that clears a started intent is a + # durable success state containing the same attempt and backup identity. + state = json.loads(state_path.read_text(encoding="utf-8")) + state["revision"] = candidate + state["live_revision"] = candidate + state["live_status"] = "healthy" + state["last_attempt"] = { + "revision": candidate, + "status": "success", + "metadata": { + "mcpHubMigrationAttemptId": attempt_id, + "mcpHubBackupReceipt": backup, + }, + } + state_path.write_text(json.dumps(state), encoding="utf-8") + state_path.chmod(0o600) + controller._mcp_hub_assert_no_unresolved_migration_intent(project) + completed = json.loads(intent_path.read_text(encoding="utf-8")) + assert completed["status"] == "complete" + assert completed["completedStateSha256"] == hashlib.sha256(state_path.read_bytes()).hexdigest() + + +@pytest.mark.skipif(os.name == "nt", reason="migration intent durability requires POSIX directory fsync") +def test_migration_intent_update_uses_compare_and_swap(controller, monkeypatch, tmp_path): + project, candidate, model, backup, runtime, _state_path, _prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None) + controller.migrate_mcp_hub_legacy_state( + project, + controller.MCP_HUB_REPOSITORY, + candidate, + model, + backup, + runtime, + ) + controller._mcp_hub_begin_migration_intent(project, candidate, backup) + path = tmp_path / "mcp-hub-topology-migrations" / "active-intent.json" + intent, original = controller._mcp_hub_read_migration_intent() + changed = {**intent, "startedAtEpoch": intent["startedAtEpoch"] + 1} + path.write_text(json.dumps(changed), encoding="utf-8") + path.chmod(0o600) + + with pytest.raises(controller.DeployError, match="changed before its compare-and-swap"): + controller._mcp_hub_replace_migration_intent( + path, + json.dumps(intent).encode("utf-8"), + expected_bytes=original, + ) + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="state path-swap recovery requires root-owned POSIX fixtures", +) +def test_hub_state_cas_restores_original_file_before_path_readback(controller, monkeypatch, tmp_path): + shared = tmp_path / "shared" + shared.mkdir(mode=0o777) + shared.chmod(0o777) + controller_parent = shared / "controller" + controller_parent.mkdir(mode=0o700) + state_root = controller_parent / "state" + state_root.mkdir(mode=0o700) + state_path = state_root / f"{controller.MCP_HUB_PROJECT}.json" + state_path.write_bytes(b"baseline-state") + state_path.chmod(0o600) + attacker = tmp_path / "attacker-state" + attacker.mkdir(mode=0o700) + attacker_state = attacker / state_path.name + attacker_state.write_bytes(b"attacker-state") + attacker_state.chmod(0o600) + pinned = controller._MCP_HUB_RELEASE_STORE.PinnedDirectory.open( + state_root, check_ancestors=False + ) + monkeypatch.setattr(controller, "STATE_ROOT", state_root) + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None) + helper = controller._MCP_HUB_RELEASE_STORE + original_atomic_replace = helper.atomic_replace_at + + def swap_after_replace(*args, **kwargs): + def swap(): + state_root.rename(controller_parent / "state.saved") + state_root.symlink_to(attacker, target_is_directory=True) + + kwargs["after_replace"] = swap + return original_atomic_replace(*args, **kwargs) + + monkeypatch.setattr(helper, "atomic_replace_at", swap_after_replace) + try: + with pytest.raises(controller.DeployError, match="durable compare-and-swap"): + controller._mcp_hub_write_controller_state_bytes( + b"candidate-state", expected_bytes=b"baseline-state" + ) + assert helper.read_at(pinned.fd, state_path.name, max_bytes=128) == b"baseline-state" + assert attacker_state.read_bytes() == b"attacker-state" + finally: + pinned.close() + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="migration-intent path-swap recovery requires root-owned POSIX fixtures", +) +def test_hub_migration_intent_cas_removes_new_intent_from_swapped_directory( + controller, monkeypatch, tmp_path +): + shared = tmp_path / "shared" + shared.mkdir(mode=0o777) + shared.chmod(0o777) + controller_parent = shared / "controller" + controller_parent.mkdir(mode=0o700) + state_root = controller_parent / "state" + state_root.mkdir(mode=0o700) + attacker = tmp_path / "attacker-migrations" + attacker.mkdir(mode=0o700) + attacker_intent = attacker / "active-intent.json" + attacker_intent.write_bytes(b"attacker-intent") + attacker_intent.chmod(0o600) + monkeypatch.setattr(controller, "STATE_ROOT", state_root) + monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None) + path = controller._mcp_hub_migration_intent_path(create=True) + migrations_path = path.parent + pinned = controller._MCP_HUB_RELEASE_STORE.PinnedDirectory.open( + migrations_path, check_ancestors=False + ) + helper = controller._MCP_HUB_RELEASE_STORE + original_atomic_replace = helper.atomic_replace_at + + def swap_after_replace(*args, **kwargs): + def swap(): + migrations_path.rename(state_root / "migrations.saved") + migrations_path.symlink_to(attacker, target_is_directory=True) + + kwargs["after_replace"] = swap + return original_atomic_replace(*args, **kwargs) + + monkeypatch.setattr(helper, "atomic_replace_at", swap_after_replace) + try: + with pytest.raises(controller.DeployError, match="durable compare-and-swap"): + controller._mcp_hub_replace_migration_intent( + path, b"candidate-intent", expected_bytes=None + ) + with pytest.raises(FileNotFoundError): + helper.read_at(pinned.fd, path.name, max_bytes=128) + assert attacker_intent.read_bytes() == b"attacker-intent" + finally: + pinned.close() + + +def test_migration_snapshot_attests_all_17_services_without_rewriting_legacy_state( + controller, monkeypatch, tmp_path +): + project, _candidate, model, _backup, runtime, _state_path, prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + project["source_dir"] = str(tmp_path) + project["compose_dir"] = "compose" + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + for filename in controller.MCP_HUB_COMPOSE_FILES: + (compose_dir / filename).write_text(filename, encoding="utf-8") + + snapshot_state = controller.mcp_hub_migration_snapshot_state( + project, prior, runtime, model + ) + + assert set(snapshot_state["live_artifacts"]["containers"]) == set(controller.MCP_HUB_CONTAINERS) + assert snapshot_state["live_legacy_provenance"] is True + assert snapshot_state["live_receipt"] == prior["live_receipt"] + assert prior["live_artifacts"]["containers"].keys() == set(controller.MCP_HUB_LEGACY_CONTAINERS) + + +def test_legacy_state_migration_rejects_receipt_container_identity_drift( + controller, monkeypatch, tmp_path +): + project, candidate, model, backup, runtime, _state_path, prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + changed = dict(prior) + changed_artifacts = {**prior["live_artifacts"], "containers": dict(prior["live_artifacts"]["containers"])} + changed_item = dict(changed_artifacts["containers"][controller.MCP_HUB_LEGACY_CONTAINERS[0]]) + changed_item["container_id"] = "f" * 64 + changed_artifacts["containers"][controller.MCP_HUB_LEGACY_CONTAINERS[0]] = changed_item + changed["live_artifacts"] = changed_artifacts + _state_path.write_text(json.dumps(changed), encoding="utf-8") + _state_path.chmod(0o600) + + with pytest.raises(controller.DeployError, match="legacy artifact does not match"): + controller.migrate_mcp_hub_legacy_state( + project, + controller.MCP_HUB_REPOSITORY, + candidate, + model, + backup, + runtime, + ) + + +def test_rollback_source_must_render_same_fixed_topology_and_live_image_refs( + controller, monkeypatch, tmp_path +): + project, candidate, model, _backup, runtime, _state_path, prior = _legacy_migration_fixture( + controller, monkeypatch, tmp_path + ) + project["_repository_path"] = str(tmp_path / "hub.git") + project["_deployment_metadata"] = {"revision": candidate} + staged_prior = tmp_path / "trusted-releases" / str(prior["live_revision"]) + staged_calls = [] + monkeypatch.setattr( + controller, + "_mcp_hub_stage_protected_release_source", + lambda _project, _repo, revision, *, protected_head: ( + staged_calls.append((revision, protected_head)) or staged_prior + ), + ) + for name, container in runtime.items(): + service_name = name.removeprefix("itworx-mcp-hub-").removesuffix("-1") + container["Config"] = {"Image": model["services"][service_name]["image"]} + monkeypatch.setattr( + controller, + "extract_revision", + lambda *_args: pytest.fail("rollback must use the trusted staged release tree"), + ) + + def render_rollback(rollback_project, _revision, _repository): + assert Path(rollback_project["source_dir"]) == staged_prior + return model + + monkeypatch.setattr(controller, "mcp_hub_compose_model", render_rollback) + monkeypatch.setattr( + controller, "verify_mcp_hub_live_topology", lambda *_args: runtime + ) + monkeypatch.setattr(controller, "verify_mcp_hub_live_images", lambda *_args: None) + candidate_model = _valid_model(controller) + candidate_model["services"]["control-api"]["image"] = "registry.example/control-api:new-reviewed-tag" + + rollback_model, verified_runtime = controller.mcp_hub_verify_rollback_source( + project, prior, candidate_model + ) + + assert rollback_model is model + assert verified_runtime is runtime + assert staged_calls == [(prior["live_revision"], candidate)] + assert project["_mcp_hub_allow_legacy_live_mounts"] is True + + changed_source = _valid_model(controller) + changed_source["services"]["postgres"]["image"] = "postgres:unexpected" + monkeypatch.setattr(controller, "mcp_hub_compose_model", lambda *_args: changed_source) + with pytest.raises(controller.DeployError, match="exact running image references"): + controller.mcp_hub_verify_rollback_source(project, prior, model) + + monkeypatch.setattr( + controller, + "mcp_hub_compose_model", + lambda *_args: (_ for _ in ()).throw( + controller.DeployError("MCP Hub rendered Compose validation failed") + ), + ) + with pytest.raises(controller.DeployError, match="rendered Compose validation failed"): + controller.mcp_hub_verify_rollback_source(project, prior, model) + + +def test_legacy_snapshot_rollback_uses_trusted_prior_tree_and_rejects_legacy_after_up( + controller, monkeypatch, tmp_path +): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + candidate = "c" * 40 + prior = "a" * 40 + build = { + "id": prior[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": prior, + "tree": "d" * 40, + "treeDigest": "e" * 64, + "builtAt": "2026-09-30T10:00:00.000Z", + } + project["_repository_path"] = str(tmp_path / "hub.git") + project["_deployment_metadata"] = {"revision": candidate} + trusted_prior = tmp_path / "trusted-releases" / prior + model = _valid_model(controller) + containers = [] + by_name = {} + for index, name in enumerate(controller.MCP_HUB_CONTAINERS): + service_name = name.removeprefix("itworx-mcp-hub-").removesuffix("-1") + item = { + "name": name, + "container_id": f"{index + 1:064x}", + "image_id": "sha256:" + f"{index + 1:064x}", + "image_ref": model["services"][service_name]["image"], + "mounts": [], + } + containers.append(item) + by_name[name] = item + snapshot = { + "topology_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, + "revision": prior, + "legacy_provenance": True, + "receipt": {"build": build}, + "containers": containers, + } + staged = [] + rendered = [] + started = [] + observed = [] + monkeypatch.setattr(controller, "repository_path_from_project", lambda _project: tmp_path / "hub.git") + monkeypatch.setattr( + controller, + "_mcp_hub_stage_protected_release_source", + lambda rollback_project, _repo, revision, *, protected_head: ( + staged.append((revision, protected_head)) or trusted_prior + ), + ) + monkeypatch.setattr(controller, "compose_project_container_names", lambda _name: set(controller.MCP_HUB_CONTAINERS)) + + def render(rollback_project, revision, repository): + rendered.append((Path(rollback_project["source_dir"]), revision, repository)) + return model + + monkeypatch.setattr(controller, "mcp_hub_compose_model", render) + monkeypatch.setattr(controller, "_mcp_hub_prepare_rollback_image", lambda *_args: None) + monkeypatch.setattr( + controller, + "inspect_container", + lambda name: {"Id": by_name[name]["container_id"], "Image": by_name[name]["image_id"]}, + ) + + def attest(_model, _service, _project_name, _container, *, project, **_kwargs): + assert project.get("_mcp_hub_allow_legacy_live_mounts") is True + return [] + + monkeypatch.setattr(controller, "mcp_hub_attested_live_mounts", attest) + monkeypatch.setattr( + controller, + "mcp_hub_up_services", + lambda rollback_project, revision, repository, services: started.append( + (Path(rollback_project["source_dir"]), revision, repository, services) + ), + ) + + def verify_topology(rollback_project, _model): + assert "_mcp_hub_allow_legacy_live_mounts" not in rollback_project + assert Path(rollback_project["source_dir"]) == trusted_prior + observed.append("trusted-runtime-verified") + return {} + + monkeypatch.setattr(controller, "verify_mcp_hub_live_topology", verify_topology) + verified_receipt = {"repository": controller.MCP_HUB_REPOSITORY, "revision": prior} + monkeypatch.setattr( + controller, + "verify_deployment", + lambda rollback_project, metadata, *, expected_image_ids: ( + observed.append((Path(rollback_project["source_dir"]), metadata, len(expected_image_ids))) + or verified_receipt + ), + ) + + controller.restore_mcp_hub_snapshot( + project, snapshot, controller.MCP_HUB_REPOSITORY, candidate + ) + + assert staged == [(prior, candidate)] + assert rendered == [(trusted_prior, prior, controller.MCP_HUB_REPOSITORY)] + assert len(started) == 4 + assert all(item[0] == trusted_prior and item[1] == prior for item in started) + assert observed[0] == "trusted-runtime-verified" + assert project["_rollback_receipt"] is verified_receipt + assert project["_rollback_restored_revision"] == prior + + +def test_hub_deploy_runs_the_fixed_migration_and_startup_stages(controller, monkeypatch, tmp_path): + project = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, _legacy_target(controller) + ) + project["_repository_path"] = str(tmp_path / "hub.git") + project["source_dir"] = str(tmp_path) + project["compose_dir"] = "infrastructure/compose" + compose_dir = tmp_path / "infrastructure" / "compose" + compose_dir.mkdir(parents=True) + for filename in controller.MCP_HUB_COMPOSE_FILES: + (compose_dir / filename).write_text("services: {}\n", encoding="utf-8") + catalog_override = tmp_path / "catalog.yaml" + catalog_override.write_text("services: {}\n", encoding="utf-8") + revision = "a" * 40 + model = _valid_model(controller) + build = { + "id": revision[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": revision, + "tree": "b" * 40, + "treeDigest": "c" * 64, + "builtAt": "2026-10-02T10:00:00.000Z", + } + project["_deployment_metadata"] = build + image_ids = {} + for index, service_name in enumerate(controller.MCP_HUB_APPLICATION_SERVICES, 1): + model["services"][service_name]["build"] = {"context": "."} + model["services"][service_name]["image"] = f"itworx-mcp-hub/{service_name}:{revision[:12]}" + image_ids[model["services"][service_name]["image"]] = "sha256:" + f"{index:064x}" + image_labels = { + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.source-tree": build["tree"], + "org.opencontainers.image.version": build["id"], + "org.opencontainers.image.created": build["builtAt"], + } + commands = [] + stages = [] + migration_attempts = [] + events = [] + verified_models = [] + migration_pins = [] + monkeypatch.setattr(controller, "mcp_hub_require_docker_capacity", lambda: None) + monkeypatch.setattr( + controller, + "mcp_hub_stage_release_source", + lambda *_args: tmp_path / "release", + ) + monkeypatch.setattr(controller, "mcp_hub_compose_model", lambda *_args: model) + monkeypatch.setattr( + controller, + "inspect_image", + lambda reference: { + "Id": image_ids[reference], + "Config": {"Labels": image_labels}, + }, + ) + monkeypatch.setattr( + controller, + "_mcp_hub_compose_override", + lambda *_args, **_kwargs: catalog_override, + ) + monkeypatch.setattr(controller, "deployment_environment", lambda *_args: {}) + + def record_command(command, **_kwargs): + commands.append(command) + if "SERVICE_NAME=database-migrate" in command: + pin_index = next( + index + for index, argument in enumerate(command[:-1]) + if argument == "--file" and Path(command[index + 1]).name == "image-pins.json" + ) + migration_pins.append( + json.loads(Path(command[pin_index + 1]).read_text(encoding="ascii")) + ) + + monkeypatch.setattr(controller, "run", record_command) + monkeypatch.setattr( + controller, + "_mcp_hub_begin_migration_intent", + lambda _project, _revision, _backup: ( + migration_attempts.append("intent") or events.append("intent") or "1" * 32 + ), + ) + monkeypatch.setattr( + controller, + "prepare_compose_migration", + lambda _project: events.append("prepare") or [], + ) + monkeypatch.setattr( + controller, + "mcp_hub_up_services", + lambda _p, _r, _repo, names: stages.append(names) or events.append("up"), + ) + monkeypatch.setattr(controller, "verify_mcp_hub_database_revision", lambda *_args: "abc123") + monkeypatch.setattr( + controller, + "verify_mcp_hub_live_topology", + lambda _project, verified_model, **_kwargs: verified_models.append(verified_model) or {}, + ) + monkeypatch.setattr( + controller.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0), + ) + + controller.deploy_mcp_hub_staged(project, revision, controller.MCP_HUB_REPOSITORY) + + assert commands[0][-1] == "build" + migration = next(command for command in commands if "SERVICE_NAME=database-migrate" in command) + assert migration[-8:] == [ + "run", "--rm", "--no-deps", "--pull", "never", "-e", "SERVICE_NAME=database-migrate", "control-api" + ] + assert all( + project["_mcp_hub_rendered_model"]["services"][service_name]["image"] == image_id + for service_name, image_id in zip(controller.MCP_HUB_APPLICATION_SERVICES, image_ids.values()) + ) + assert "--build" not in migration + assert migration_pins == [ + { + "services": { + name: {"image": project["_mcp_hub_rendered_model"]["services"][name]["image"]} + for name in sorted(controller.MCP_HUB_APPLICATION_SERVICES) + } + } + ] + assert verified_models == [project["_mcp_hub_rendered_model"]] + assert project["_mcp_hub_migration_started"] is True + assert project["_mcp_hub_migration_attempt_id"] == "1" * 32 + assert migration_attempts == ["intent"] + assert events[:2] == ["intent", "prepare"] + assert events[2:] == ["up"] * 4 + assert stages == [ + controller.MCP_HUB_DATABASE_SERVICES, + controller.MCP_HUB_CONTEXTFORGE_SERVICES, + controller.MCP_HUB_APPLICATION_SERVICES, + controller.MCP_HUB_INTAKE_SERVICES, + ] + assert "down" not in [part for command in commands for part in command] diff --git a/backend/tests/test_mcp_hub_release_store.py b/backend/tests/test_mcp_hub_release_store.py new file mode 100644 index 0000000..818d058 --- /dev/null +++ b/backend/tests/test_mcp_hub_release_store.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +STORE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "mcp_hub_release_store.py" + + +def _load_store(): + spec = importlib.util.spec_from_file_location("mcp_hub_release_store_test", STORE_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def store(): + return _load_store() + + +def _volume(store, **overrides): + value = { + "Name": store.VOLUME_NAME, + "Driver": "local", + "Scope": "local", + "Mountpoint": store.VOLUME_HOST_ROOT.as_posix(), + "Options": None, + "Labels": dict(store.EXPECTED_LABELS), + } + return {**value, **overrides} + + +def test_fixed_volume_contract_rejects_unexpected_options_labels_and_root(store): + assert store.validate_volume_metadata(_volume(store), "/var/lib/docker") is None + + bad_documents = [ + _volume(store, Name="other-volume"), + _volume(store, Driver="local-persist"), + _volume(store, Scope="global"), + _volume(store, Mountpoint="/srv/example/appdata/releases"), + _volume(store, Options={"o": "bind", "device": "/tmp"}), + _volume(store, Labels={**store.EXPECTED_LABELS, "attacker": "extra"}), + _volume(store, Labels={"itworx.managed-by": "deployment-controller"}), + ] + for document in bad_documents: + with pytest.raises(store.ReleaseStoreError): + store.validate_volume_metadata(document, "/var/lib/docker") + with pytest.raises(store.ReleaseStoreError): + store.validate_volume_metadata(_volume(store), "/var/lib/docker-alt") + + +def test_release_store_fails_closed_when_acl_metadata_cannot_be_inspected(store, monkeypatch): + def unavailable(_fd): + raise OSError("filesystem ACL metadata is unavailable") + + monkeypatch.setattr(store.os, "listxattr", unavailable, raising=False) + with pytest.raises(store.ReleaseStoreError, match="cannot inspect filesystem ACLs"): + store._require_no_acl(1) + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="Hub state reader verifies root-owned fixed storage", +) +@pytest.mark.parametrize("state_mode", [0o700, 0o755]) +def test_fixed_hub_state_reader_is_bounded_and_rejects_symlink_or_missing_state( + store, tmp_path, monkeypatch, state_mode +): + controller_root = tmp_path / "controller" + state_root = controller_root / "state" + controller_root.mkdir(mode=0o700) + controller_root.chmod(0o700) + monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root) + monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root) + + assert store.read_hub_state_bytes() is None + with pytest.raises(store.ReleaseStoreError): + store.read_hub_state_bytes(missing_ok=False) + + state_root.mkdir(mode=state_mode) + state_root.chmod(state_mode) + + state_file = state_root / store.HUB_STATE_FILENAME + state_file.write_bytes(b'{"status":"success"}') + state_file.chmod(0o600) + assert store.read_hub_state_bytes(missing_ok=False) == b'{"status":"success"}' + + attacker_root = tmp_path / "attacker" + attacker_root.mkdir(mode=0o700) + attacker_state = attacker_root / store.HUB_STATE_FILENAME + attacker_state.write_bytes(b'{"status":"attacker"}') + attacker_state.chmod(0o600) + state_file.unlink() + state_file.symlink_to(attacker_state) + with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"): + store.read_hub_state_bytes() + assert attacker_state.read_bytes() == b'{"status":"attacker"}' + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="Hub state reader verifies root-owned fixed storage", +) +@pytest.mark.parametrize("state_mode", [0o775, 0o777, 0o750]) +def test_fixed_hub_state_reader_rejects_unapproved_directory_modes( + store, tmp_path, monkeypatch, state_mode +): + controller = tmp_path / "controller" + state = controller / "state" + state.mkdir(parents=True) + controller.chmod(0o755) + state.chmod(state_mode) + state_file = state / store.HUB_STATE_FILENAME + state_file.write_bytes(b'{"status":"success"}') + state_file.chmod(0o600) + monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller) + monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state) + + with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"): + store.read_hub_state_bytes() + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="Hub state reader path-swap test requires root-owned POSIX fixtures", +) +def test_fixed_hub_state_reader_rejects_parent_swap_during_read(store, tmp_path, monkeypatch): + controller_root = tmp_path / "controller" + state_root = controller_root / "state" + state_root.mkdir(parents=True, mode=0o700) + controller_root.chmod(0o700) + state_file = state_root / store.HUB_STATE_FILENAME + state_file.write_bytes(b"trusted") + state_file.chmod(0o600) + attacker_root = tmp_path / "attacker" + attacker_state_root = attacker_root / "state" + attacker_state_root.mkdir(parents=True, mode=0o700) + attacker_file = attacker_state_root / store.HUB_STATE_FILENAME + attacker_file.write_bytes(b"attacker") + attacker_file.chmod(0o600) + monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root) + monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root) + original_read_at = store.read_at + + def read_then_swap(*args, **kwargs): + result = original_read_at(*args, **kwargs) + controller_root.rename(tmp_path / "controller.saved") + controller_root.symlink_to(attacker_root, target_is_directory=True) + return result + + monkeypatch.setattr(store, "read_at", read_then_swap) + with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"): + store.read_hub_state_bytes() + assert attacker_file.read_bytes() == b"attacker" + + +_RENDERED_2A948_SERVICES = ( + "control-api", + "connector-appops", + "connector-gitea", + "connector-ingestion", + "connector-knowledge", + "connector-mobilityops", + "connector-projectbrain", + "connector-stitch", + "connector-unraid", + "contextforge", + "dataplane-router", + "docker-read-broker", + "ingestion-clamd", + "postgres", + "redis", + "web", + "worker", +) +_RENDERED_2A948_RELEASE_BINDS = ( + ("connector-gitea", "services/runtime/connector_gitea.py", "/opt/hub/services/runtime/connector_gitea.py", "file"), + ("contextforge", "infrastructure/images/contextforge/entrypoint.sh", "/opt/itworx/contextforge-entrypoint.sh", "file"), + ("contextforge", "infrastructure/images/contextforge/runtime-contract.json", "/opt/itworx/contextforge-runtime-contract.json", "file"), + ("contextforge", "scripts/contextforge_runtime_contract.py", "/opt/itworx/contextforge-runtime-contract.py", "file"), + ("control-api", "catalog", "/opt/hub/catalog", "directory"), + ("dataplane-router", "catalog", "/opt/hub/catalog", "directory"), + ("dataplane-router", "services/runtime/dataplane.py", "/opt/hub/services/runtime/dataplane.py", "file"), + ("postgres", "infrastructure/postgres/init", "/docker-entrypoint-initdb.d", "directory"), + ("postgres", "infrastructure/images/postgres/entrypoint.sh", "/opt/itworx/postgres-entrypoint.sh", "file"), + ("redis", "infrastructure/images/redis/entrypoint.sh", "/opt/itworx/redis-entrypoint.sh", "file"), + ("redis", "infrastructure/images/redis/healthcheck.sh", "/opt/itworx/redis-healthcheck.sh", "file"), + ("web", "services/runtime/web.py", "/opt/hub/services/runtime/web.py", "file"), + ("worker", "catalog", "/opt/hub/catalog", "directory"), + ("worker", "services/runtime/state_sync.py", "/opt/hub/services/runtime/state_sync.py", "file"), +) + + +def _rendered_2a948_model(store, revision): + """Mount projection captured from protected Hub main commit 2a948b17.""" + services = {name: {"volumes": []} for name in _RENDERED_2A948_SERVICES} + for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS: + services[service]["volumes"].append( + { + "type": "bind", + "source": store.logical_release_path(revision) + "/" + suffix, + "target": target, + "read_only": True, + "mode": "ro", + } + ) + # These are rendered non-release binds and must remain unchanged. + services["docker-read-broker"]["volumes"].append( + { + "type": "bind", + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "read_only": True, + } + ) + services["postgres"]["volumes"].append( + {"type": "volume", "source": "itworx-mcp-hub-postgres", "target": "/var/lib/postgresql/data"} + ) + return {"services": services} + + +def test_exact_rendered_2a948_four_file_model_translates_all_release_binds_only(store): + revision = "a" * 40 + model = _rendered_2a948_model(store, revision) + + store.validate_release_mount_inventory(model, revision, source_kind="logical") + translated = store.translate_release_mounts(model, revision) + store.validate_release_mount_inventory(translated, revision, source_kind="host") + + assert store.RELEASE_BIND_CONTRACT == _RENDERED_2A948_RELEASE_BINDS + assert len(_RENDERED_2A948_RELEASE_BINDS) == 14 + for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS: + original = next( + mount for mount in model["services"][service]["volumes"] + if mount.get("target") == target + ) + observed = next( + mount for mount in translated["services"][service]["volumes"] + if mount.get("target") == target + ) + assert original["source"] == f"{store.logical_release_path(revision)}/{suffix}" + assert observed["source"] == f"{store.host_release_path(revision)}/{suffix}" + assert observed["read_only"] is True + assert translated["services"]["docker-read-broker"]["volumes"] == model["services"]["docker-read-broker"]["volumes"] + assert translated["services"]["postgres"]["volumes"][-1] == model["services"]["postgres"]["volumes"][-1] + assert model["services"]["control-api"]["volumes"][0]["source"].startswith( + store.logical_release_path(revision) + ) + + mutations = [] + missing = copy.deepcopy(model) + missing["services"]["redis"]["volumes"].pop(0) + mutations.append(missing) + writable = copy.deepcopy(model) + writable["services"]["worker"]["volumes"][0]["read_only"] = False + mutations.append(writable) + wrong_service = copy.deepcopy(model) + wrong_service["services"]["contextforge"]["volumes"][0]["target"] = "/tmp/entrypoint.sh" + mutations.append(wrong_service) + undeclared_leaf = copy.deepcopy(model) + undeclared_leaf["services"]["control-api"]["volumes"].append( + { + "type": "bind", + "source": f"{store.logical_release_path(revision)}/scripts", + "target": "/opt/hub/scripts", + "read_only": True, + } + ) + mutations.append(undeclared_leaf) + release_root = copy.deepcopy(model) + release_root["services"]["control-api"]["volumes"][0]["source"] = store.logical_release_path(revision) + mutations.append(release_root) + volume_ancestor = copy.deepcopy(model) + volume_ancestor["services"]["control-api"]["volumes"][0]["source"] = store.DOCKER_ROOT.as_posix() + mutations.append(volume_ancestor) + for unsafe in mutations: + with pytest.raises(store.ReleaseStoreError): + store.translate_release_mounts(unsafe, revision) + + +def test_private_overlay_contains_only_fixed_volume_leaf_sources(store): + revision = "b" * 40 + content = json.loads(store.compose_override_bytes(revision)) + text = json.dumps(content) + assert "${" not in text + assert "secret" not in text.lower() + mounts = [ + (service, mount) + for service, config in content["services"].items() + for mount in config["volumes"] + ] + assert len(mounts) == len(_RENDERED_2A948_RELEASE_BINDS) + for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS: + mount = next(item for name, item in mounts if name == service and item["target"] == target) + assert mount["source"] == f"{store.host_release_path(revision)}/{suffix}" + assert mount["type"] == "bind" + assert mount["read_only"] is True + assert mount["bind"] == {"create_host_path": False} + assert "privileged" not in text and "/controller-hub-releases:" not in text + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="release-leaf identity checks require root-owned POSIX fixtures", +) +def test_release_leaf_verifier_allows_only_regular_files_and_declared_directories(store, tmp_path, monkeypatch): + revision = "c" * 40 + volume_root = tmp_path / "volume" + releases = volume_root / store.RELEASES_DIRECTORY + release = releases / revision + release.mkdir(parents=True, mode=0o700) + releases.chmod(0o700) + unique: dict[str, str] = {} + for _service, suffix, _target, kind in store.RELEASE_BIND_CONTRACT: + unique[suffix] = kind + for suffix, kind in unique.items(): + target = release.joinpath(*suffix.split("/")) + if kind == "directory": + target.mkdir(parents=True, mode=0o700, exist_ok=True) + else: + target.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + target.write_bytes(b"protected") + target.chmod(0o444) + for path in sorted(release.rglob("*"), reverse=True): + if path.is_dir(): + path.chmod(0o555) + release.chmod(0o555) + pinned = store.PinnedDirectory.open(volume_root, check_ancestors=False) + fixed = store.FixedReleaseStore(host=pinned, logical=pinned) + monkeypatch.setattr(fixed, "assert_current", lambda: None) + try: + store.verify_release_bind_leaves(fixed, revision) + + bad_file = release / "infrastructure" / "images" / "redis" / "entrypoint.sh" + bad_file.parent.chmod(0o755) + bad_file.unlink() + outside = tmp_path / "outside.sh" + outside.write_text("attacker", encoding="utf-8") + bad_file.symlink_to(outside) + bad_file.parent.chmod(0o555) + with pytest.raises(store.ReleaseStoreError, match="symlink"): + store.verify_release_bind_leaves(fixed, revision) + finally: + pinned.close() + + +@pytest.mark.skipif(os.name == "nt", reason="descriptor identity checks require POSIX openat") +def test_pinned_store_refuses_ancestor_symlink_swap_before_path_readback(store, tmp_path): + parent = tmp_path / "shared" + parent.mkdir(mode=0o700) + original = parent / "controller" + original.mkdir(mode=0o700) + marker = original / "state.json" + marker.write_bytes(b"old-state") + marker.chmod(0o600) + pinned = store.PinnedDirectory.open(original, check_ancestors=False) + attacker = tmp_path / "attacker" + attacker.mkdir(mode=0o700) + (attacker / "state.json").write_bytes(b"attacker-state") + moved = parent / "controller.saved" + + try: + original.rename(moved) + original.symlink_to(attacker, target_is_directory=True) + with pytest.raises(store.ReleaseStoreError, match="path identity changed"): + pinned.assert_current(original) + assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state" + assert (attacker / "state.json").read_bytes() == b"attacker-state" + finally: + pinned.close() + + +@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat") +def test_pinned_atomic_replace_uses_original_inode_and_detects_swapped_path(store, tmp_path): + parent = tmp_path / "shared" + parent.mkdir(mode=0o700) + original = parent / "controller" + original.mkdir(mode=0o700) + pinned = store.PinnedDirectory.open(original, check_ancestors=False) + attacker = tmp_path / "attacker" + attacker.mkdir(mode=0o700) + + def swap_path(): + original.rename(parent / "controller.saved") + original.symlink_to(attacker, target_is_directory=True) + + try: + with pytest.raises(store.ReleaseStoreError, match="path identity changed"): + store.atomic_replace_at( + pinned.fd, + "state.json", + b"new-state", + expected=None, + after_replace=swap_path, + path_for_identity_check=original, + ) + with pytest.raises(FileNotFoundError): + store.read_at(pinned.fd, "state.json", max_bytes=64) + assert not (attacker / "state.json").exists() + finally: + pinned.close() + + +@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat") +def test_pinned_atomic_replace_restores_previous_bytes_after_ancestor_swap(store, tmp_path): + parent = tmp_path / "shared" + parent.mkdir(mode=0o700) + original = parent / "controller" + original.mkdir(mode=0o700) + state = original / "state.json" + state.write_bytes(b"old-state") + state.chmod(0o600) + pinned = store.PinnedDirectory.open(original, check_ancestors=False) + attacker = tmp_path / "attacker" + attacker.mkdir(mode=0o700) + + def swap_path(): + original.rename(parent / "controller.saved") + original.symlink_to(attacker, target_is_directory=True) + + try: + with pytest.raises(store.ReleaseStoreError, match="path identity changed"): + store.atomic_replace_at( + pinned.fd, + "state.json", + b"candidate-state", + expected=b"old-state", + path_for_identity_check=original, + after_replace=swap_path, + ) + assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state" + assert not (attacker / "state.json").exists() + finally: + pinned.close() diff --git a/backend/tests/test_repopulse_controller_adoption_store.py b/backend/tests/test_repopulse_controller_adoption_store.py index 06b2e04..3777935 100644 --- a/backend/tests/test_repopulse_controller_adoption_store.py +++ b/backend/tests/test_repopulse_controller_adoption_store.py @@ -42,6 +42,16 @@ def _sha(value: str) -> str: return hashlib.sha256(value.encode("ascii")).hexdigest() +def _second_process_lock(path_text, started, locked): + import fcntl + + started.set() + with open(path_text, "a+") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + locked.set() + fcntl.flock(handle, fcntl.LOCK_UN) + + def test_intent_binding_matches_appops_canonical_digest() -> None: module = _load_store() idempotency = _sha("repopulse-adoption-key") @@ -799,19 +809,10 @@ def test_project_flock_blocks_a_second_process(store_env): ready = multiprocessing.Event() acquired = multiprocessing.Event() - def second_process_lock(path_text, started, locked): - import fcntl - - started.set() - with open(path_text, "a+") as handle: - fcntl.flock(handle, fcntl.LOCK_EX) - locked.set() - fcntl.flock(handle, fcntl.LOCK_UN) - with store.transaction(): assert store.load_intent() is None child = multiprocessing.Process( - target=second_process_lock, + target=_second_process_lock, args=(str(module.PROJECT_LOCK), ready, acquired), ) child.start() diff --git a/backend/tests/test_user_auth.py b/backend/tests/test_user_auth.py index 34dd41e..c2133a4 100644 --- a/backend/tests/test_user_auth.py +++ b/backend/tests/test_user_auth.py @@ -202,3 +202,603 @@ def test_operational_users_cannot_change_enrollment_trust(identity_client): sign_in(identity_client, "owner") # Administrator reaches schema validation; an operator is rejected first. assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 422 + + +def _enrollment_trust_payload(*, appdata_root: str, backup_acknowledged: bool) -> dict: + return { + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "trusted_appdata_root": appdata_root, + "managed_appdata_backup_acknowledged": backup_acknowledged, + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": None, + "oidc_role_claim": "roles", + "administrator_group": None, + "support_agent_group": None, + "read_only_group": None, + } + + +@pytest.mark.parametrize( + ("initial_root", "initial_ack", "requested_root", "requested_ack"), + [ + ( + "/srv/example/appdata", + False, + "/var/lib/docker/volumes/itworx-managed-appdata/_data", + True, + ), + ( + "/var/lib/docker/volumes/itworx-managed-appdata/_data", + True, + "/srv/example/appdata", + False, + ), + ], + ids=["initial-managed-selection", "managed-root-reset"], +) +def test_settings_manage_put_cannot_change_storage_root( + identity_client, initial_root, initial_ack, requested_root, requested_ack +): + from sqlmodel import Session, select + + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + + before = _enrollment_trust_payload( + appdata_root=initial_root, backup_acknowledged=initial_ack + ) + with Session(database.engine) as db: + db.add(AppSetting(key="deployment_enrollment:trust", value_json=before, is_secret=False)) + db.commit() + audit_ids_before = set(db.exec(select(AuditEvent.id)).all()) + + sign_in(identity_client, "reader") + response = identity_client.put( + "/api/enrollments/trust", + json=_enrollment_trust_payload( + appdata_root=requested_root, backup_acknowledged=requested_ack + ), + headers=headers, + ) + assert response.status_code == 403 + + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None and saved.value_json == before + assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before + + +def test_settings_manage_put_cannot_create_initial_managed_trust(identity_client): + from sqlmodel import Session, select + + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + with Session(database.engine) as db: + audit_ids_before = set(db.exec(select(AuditEvent.id)).all()) + + sign_in(identity_client, "reader") + response = identity_client.put( + "/api/enrollments/trust", + json=_enrollment_trust_payload( + appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data", + backup_acknowledged=True, + ), + headers=headers, + ) + assert response.status_code == 403 + with Session(database.engine) as db: + assert db.get(AppSetting, "deployment_enrollment:trust") is None + assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before + + +def test_settings_manage_put_can_create_initial_legacy_trust(identity_client): + from sqlmodel import Session + + from backend.app.db import session as database + from backend.app.db.models import AppSetting + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + sign_in(identity_client, "reader") + + legacy = _enrollment_trust_payload( + appdata_root="/srv/example/appdata", backup_acknowledged=False + ) + response = identity_client.put("/api/enrollments/trust", json=legacy, headers=headers) + assert response.status_code == 200 + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata" + assert saved.value_json["managed_appdata_backup_acknowledged"] is False + + +def test_settings_manage_put_keeps_other_trust_edits_and_admin_can_select_managed_root(identity_client): + from sqlmodel import Session + + from backend.app.db import session as database + from backend.app.db.models import AppSetting + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + legacy = _enrollment_trust_payload( + appdata_root="/srv/example/appdata", backup_acknowledged=False + ) + with Session(database.engine) as db: + db.add(AppSetting(key="deployment_enrollment:trust", value_json=legacy, is_secret=False)) + db.commit() + + sign_in(identity_client, "reader") + unrelated_update = { + **legacy, + "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"], + } + response = identity_client.put( + "/api/enrollments/trust", json=unrelated_update, headers=headers + ) + assert response.status_code == 200 + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"] + assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata" + assert saved.value_json["managed_appdata_backup_acknowledged"] is False + + sign_in(identity_client, "owner") + managed = _enrollment_trust_payload( + appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data", + backup_acknowledged=True, + ) + response = identity_client.put("/api/enrollments/trust", json=managed, headers=headers) + assert response.status_code == 200 + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json["trusted_appdata_root"] == managed["trusted_appdata_root"] + assert saved.value_json["managed_appdata_backup_acknowledged"] is True + + +def test_settings_manage_put_cas_preserves_concurrent_admin_storage_selection(identity_client): + from sqlalchemy import event + from sqlmodel import Session, select + + from backend.app.core.time import utc_now + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + from backend.app.services.enrollment_plane import TRUST_SETTING_KEY + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + + original = _enrollment_trust_payload( + appdata_root="/srv/example/appdata", backup_acknowledged=False + ) + with database.engine.connect() as connection: + connection.exec_driver_sql("PRAGMA journal_mode=WAL") + with Session(database.engine) as db: + db.add(AppSetting(key=TRUST_SETTING_KEY, value_json=original, is_secret=False)) + db.commit() + audit_ids_before = set(db.exec(select(AuditEvent.id)).all()) + + sign_in(identity_client, "reader") + injected = False + + def select_managed_root_before_put_write( + _connection, _cursor, statement, parameters, _context, _executemany + ): + nonlocal injected + normalized = statement.lstrip().upper() + if ( + not injected + and normalized.startswith("UPDATE APP_SETTINGS") + and TRUST_SETTING_KEY in str(parameters) + ): + injected = True + with Session(database.engine) as concurrent_session: + row = concurrent_session.get(AppSetting, TRUST_SETTING_KEY) + assert row is not None + row.value_json = { + **row.value_json, + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + } + row.updated_at = utc_now() + concurrent_session.add(row) + concurrent_session.commit() + + event.listen(database.engine, "before_cursor_execute", select_managed_root_before_put_write) + try: + response = identity_client.put( + "/api/enrollments/trust", + json={ + **original, + "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"], + }, + headers=headers, + ) + finally: + event.remove(database.engine, "before_cursor_execute", select_managed_root_before_put_write) + + assert injected is True + assert response.status_code == 409 + with Session(database.engine) as db: + saved = db.get(AppSetting, TRUST_SETTING_KEY) + assert saved is not None + assert saved.value_json["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data" + assert saved.value_json["managed_appdata_backup_acknowledged"] is True + assert saved.value_json["approved_networks"] == original["approved_networks"] + assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before + + +def test_settings_manage_initial_legacy_put_loses_if_admin_creates_trust_concurrently(identity_client): + from sqlalchemy import event + from sqlmodel import Session, select + + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + from backend.app.services.enrollment_plane import TRUST_SETTING_KEY + + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + with database.engine.connect() as connection: + connection.exec_driver_sql("PRAGMA journal_mode=WAL") + with Session(database.engine) as db: + audit_ids_before = set(db.exec(select(AuditEvent.id)).all()) + + sign_in(identity_client, "reader") + admin_value = _enrollment_trust_payload( + appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data", + backup_acknowledged=True, + ) + injected = False + + def create_admin_trust_before_put_insert( + _connection, _cursor, statement, parameters, _context, _executemany + ): + nonlocal injected + normalized = statement.lstrip().upper() + if ( + not injected + and normalized.startswith("INSERT INTO APP_SETTINGS") + and TRUST_SETTING_KEY in str(parameters) + ): + injected = True + with Session(database.engine) as concurrent_session: + concurrent_session.add( + AppSetting(key=TRUST_SETTING_KEY, value_json=admin_value, is_secret=False) + ) + concurrent_session.commit() + + event.listen(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert) + try: + response = identity_client.put( + "/api/enrollments/trust", + json=_enrollment_trust_payload( + appdata_root="/srv/example/appdata", backup_acknowledged=False + ), + headers=headers, + ) + finally: + event.remove(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert) + + assert injected is True + assert response.status_code == 409 + with Session(database.engine) as db: + saved = db.get(AppSetting, TRUST_SETTING_KEY) + assert saved is not None and saved.value_json == admin_value + assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before + + +def test_storage_root_patch_is_admin_only_and_preserves_other_trust_fields(identity_client): + from sqlmodel import Session, select + + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + + sign_in(identity_client, "owner") + original = { + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "trusted_appdata_root": "/srv/example/appdata", + "managed_appdata_backup_acknowledged": False, + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": None, + "oidc_role_claim": "roles", + "administrator_group": None, + "support_agent_group": None, + "read_only_group": None, + } + with Session(database.engine) as db: + db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False)) + db.commit() + + headers = {"X-AppOps-Session-CSRF": "1"} + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + updated = identity_client.patch( + "/api/enrollments/trust/storage-root", + json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": True}, + headers=headers, + ) + assert updated.status_code == 200 + assert updated.json()["trustedAppdataRoot"] == managed_root + assert updated.json()["managedAppdataBackupAcknowledged"] is True + + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json == { + **original, + "trusted_appdata_root": managed_root, + "managed_appdata_backup_acknowledged": True, + } + event = db.exec( + select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update") + ).one() + assert event.before_state == { + "trustedAppdataRoot": "/srv/example/appdata", + "managedAppdataBackupAcknowledged": False, + } + assert event.after_state == { + "trustedAppdataRoot": managed_root, + "managedAppdataBackupAcknowledged": True, + } + assert event.redacted_metadata == { + "backupLocation": "dockerRootDir", + "requestSource": "manual", + } + assert "bootstrap_secret_ref" not in str(event.before_state) + + assert identity_client.patch( + "/api/enrollments/trust/storage-root", + json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": False}, + headers=headers, + ).status_code == 422 + assert identity_client.patch( + "/api/enrollments/trust/storage-root", + json={ + "trusted_appdata_root": "/srv/example/other", + "managed_appdata_backup_acknowledged": False, + }, + headers=headers, + ).status_code == 422 + assert identity_client.patch( + "/api/enrollments/trust/storage-root", + json={ + "trusted_appdata_root": managed_root, + "managed_appdata_backup_acknowledged": True, + "bootstrap_secret_ref": "LEAKED_SECRET_REFERENCE", + }, + headers=headers, + ).status_code == 422 + + legacy = identity_client.patch( + "/api/enrollments/trust/storage-root", + json={"trusted_appdata_root": "/srv/example/appdata", "managed_appdata_backup_acknowledged": False}, + headers=headers, + ) + assert legacy.status_code == 200 + assert legacy.json()["trustedAppdataRoot"] == "/srv/example/appdata" + assert legacy.json()["managedAppdataBackupAcknowledged"] is False + + +def test_storage_root_patch_returns_conflict_after_concurrent_trust_change(identity_client, monkeypatch): + from sqlmodel import Session, select + + from backend.app.core.time import utc_now + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 + from backend.app.services.enrollment_plane import EnrollmentPlane + + sign_in(identity_client, "owner") + original = { + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "trusted_appdata_root": "/srv/example/appdata", + "managed_appdata_backup_acknowledged": False, + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": None, + "oidc_role_claim": "roles", + "administrator_group": None, + "support_agent_group": None, + "read_only_group": None, + } + with Session(database.engine) as db: + db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False)) + db.commit() + + stale_trust = EnrollmentTrustConfigV1.model_validate(original) + + def read_stale_after_concurrent_update(_session): + with Session(database.engine) as concurrent_session: + row = concurrent_session.get(AppSetting, "deployment_enrollment:trust") + assert row is not None + row.value_json = {**row.value_json, "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"]} + row.updated_at = utc_now() + concurrent_session.add(row) + concurrent_session.commit() + return stale_trust + + monkeypatch.setattr( + EnrollmentPlane, + "get_trust_config", + staticmethod(read_stale_after_concurrent_update), + ) + response = identity_client.patch( + "/api/enrollments/trust/storage-root", + json={ + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + }, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert response.status_code == 409 + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"] + assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata" + assert db.exec( + select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update") + ).all() == [] + + +def test_storage_root_patch_cas_rejects_change_after_snapshot(identity_client, monkeypatch): + from sqlmodel import Session, select + + from backend.app.api import routes_enrollments + from backend.app.core.time import utc_now + from backend.app.db import session as database + from backend.app.db.models import AppSetting, AuditEvent + + sign_in(identity_client, "owner") + original = { + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "trusted_appdata_root": "/srv/example/appdata", + "managed_appdata_backup_acknowledged": False, + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": None, + "oidc_role_claim": "roles", + "administrator_group": None, + "support_agent_group": None, + "read_only_group": None, + } + with database.engine.connect() as connection: + connection.exec_driver_sql("PRAGMA journal_mode=WAL") + with Session(database.engine) as db: + db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False)) + db.commit() + + actual_sql_update = routes_enrollments.sql_update + concurrent_write_done = False + + def write_concurrently_after_snapshot(model): + nonlocal concurrent_write_done + if model is AppSetting and not concurrent_write_done: + concurrent_write_done = True + with Session(database.engine) as concurrent_session: + row = concurrent_session.get(AppSetting, "deployment_enrollment:trust") + assert row is not None + row.value_json = { + **row.value_json, + "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"], + } + row.updated_at = utc_now() + concurrent_session.add(row) + concurrent_session.commit() + return actual_sql_update(model) + + # The endpoint has already read and validated its selector snapshot when it + # calls sql_update. Commit an independent trust edit at that boundary so the + # route's compare-and-swap must reject the now-stale write. + monkeypatch.setattr(routes_enrollments, "sql_update", write_concurrently_after_snapshot) + response = identity_client.patch( + "/api/enrollments/trust/storage-root", + json={ + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + }, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert concurrent_write_done is True + assert response.status_code == 409 + with Session(database.engine) as db: + saved = db.get(AppSetting, "deployment_enrollment:trust") + assert saved is not None + assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"] + assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata" + assert db.exec( + select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update") + ).all() == [] + + +def test_read_only_and_operator_cannot_change_storage_root(identity_client): + reader_id = sign_in(identity_client, "reader") + sign_in(identity_client, "owner") + headers = {"X-AppOps-Session-CSRF": "1"} + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "operator", "enabled": True, "permissions": []}, + headers=headers, + ).status_code == 200 + + selection = { + "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "managed_appdata_backup_acknowledged": True, + } + sign_in(identity_client, "reader") + assert identity_client.patch( + "/api/enrollments/trust/storage-root", json=selection, headers=headers + ).status_code == 403 + sign_in(identity_client, "owner") + assert identity_client.patch( + f"/api/auth/users/{reader_id}", + json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]}, + headers=headers, + ).status_code == 200 + sign_in(identity_client, "reader") + assert identity_client.patch( + "/api/enrollments/trust/storage-root", json=selection, headers=headers + ).status_code == 403 + sign_in(identity_client, "read-only") + assert identity_client.patch( + "/api/enrollments/trust/storage-root", json=selection, headers=headers + ).status_code == 403 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6007cb..5a8e688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,20 @@ settings: excludeLinksFromLockfile: false overrides: + '@humanfs/node@>=0.16.6 <0.16.8': 0.16.8 + brace-expansion@>=1.0.0 <1.1.21: 1.1.21 brace-expansion@>=2.0.0 <2.0.3: 2.0.3 - brace-expansion@>=5.0.0 <5.0.9: 5.0.9 + brace-expansion@>=5.0.0 <5.0.12: 5.0.12 + browserslist@>=4.0.0 <4.28.7: 4.28.7 flatted@<3.4.2: 3.4.2 glob@>=10.2.0 <10.5.0: 10.5.0 + js-yaml@>=4.0.0 <4.3.2: 4.3.2 lodash@<=4.17.23: 4.18.1 minimatch@>=9.0.0 <9.0.7: 9.0.7 nanoid@>=3.0.0 <3.3.18: 3.3.18 picomatch@<2.3.2: 2.3.2 postcss@<8.5.23: 8.5.26 + postcss-selector-parser@>=6.1.0 <6.1.3: 6.1.3 yaml@>=2.0.0 <2.8.3: 2.9.0 importers: @@ -631,22 +636,22 @@ packages: peerDependencies: react-hook-form: ^7.0.0 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/retry@0.3.1': - resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} - engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.2': resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} @@ -1912,23 +1917,28 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} + engines: {node: '>=6.0.0'} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.24.4: - resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1943,6 +1953,9 @@ packages: caniuse-lite@1.0.30001805: resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2090,8 +2103,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.138: - resolution: {integrity: sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==} + electron-to-chromium@1.5.443: + resolution: {integrity: sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==} embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} @@ -2350,8 +2363,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true json-buffer@3.0.1: @@ -2522,8 +2535,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2628,8 +2642,8 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + postcss-selector-parser@6.1.3: + resolution: {integrity: sha512-cDoO18VWCIWRsSaws3C23b0HXRxlUknttfdNcbXnf3IGHAPRNLlXPHc6dYiatOkxW0W4uZh524Plaaw5a7WEtQ==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -2930,11 +2944,11 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: 4.28.7 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3239,7 +3253,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3281,17 +3295,20 @@ snapshots: dependencies: react-hook-form: 7.55.0(react@19.2.3) - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.6': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.2 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/retry@0.3.1': {} - '@humanwhocodes/retry@0.4.2': {} '@isaacs/cliui@8.0.2': @@ -4488,7 +4505,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.26): dependencies: - browserslist: 4.24.4 + browserslist: 4.28.7 caniuse-lite: 1.0.30001805 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -4500,14 +4517,16 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.11.26: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.11: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -4515,12 +4534,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.24.4: + browserslist@4.28.7: dependencies: - caniuse-lite: 1.0.30001805 - electron-to-chromium: 1.5.138 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.4) + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001814 + electron-to-chromium: 1.5.443 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.28.7) callsites@3.1.0: {} @@ -4528,6 +4548,8 @@ snapshots: caniuse-lite@1.0.30001805: {} + caniuse-lite@1.0.30001814: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4661,7 +4683,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.138: {} + electron-to-chromium@1.5.443: {} embla-carousel-react@8.6.0(react@19.2.3): dependencies: @@ -4770,7 +4792,7 @@ snapshots: '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.6 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.2 '@types/estree': 1.0.9 @@ -4954,7 +4976,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -5058,15 +5080,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.21 minimatch@9.0.7: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minipass@7.1.2: {} @@ -5087,7 +5109,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - node-releases@2.0.19: {} + node-releases@2.0.57: {} normalize-path@3.0.0: {} @@ -5163,14 +5185,14 @@ snapshots: postcss-nested@6.2.0(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 6.1.2 + postcss-selector-parser: 6.1.3 postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@6.1.2: + postcss-selector-parser@6.1.3: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -5449,7 +5471,7 @@ snapshots: postcss-js: 4.0.1(postcss@8.5.26) postcss-load-config: 4.0.2(postcss@8.5.26) postcss-nested: 6.2.0(postcss@8.5.26) - postcss-selector-parser: 6.1.2 + postcss-selector-parser: 6.1.3 resolve: 1.22.10 sucrase: 3.35.0 transitivePeerDependencies: @@ -5507,9 +5529,9 @@ snapshots: undici-types@6.21.0: {} - update-browserslist-db@1.1.3(browserslist@4.24.4): + update-browserslist-db@1.3.3(browserslist@4.28.7): dependencies: - browserslist: 4.24.4 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f6e6205..5783155 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -371,13 +371,18 @@ packages: - . minimumReleaseAge: 1440 overrides: + "@humanfs/node@>=0.16.6 <0.16.8": 0.16.8 + "brace-expansion@>=1.0.0 <1.1.21": 1.1.21 "brace-expansion@>=2.0.0 <2.0.3": 2.0.3 - "brace-expansion@>=5.0.0 <5.0.9": 5.0.9 + "brace-expansion@>=5.0.0 <5.0.12": 5.0.12 + "browserslist@>=4.0.0 <4.28.7": 4.28.7 "flatted@<3.4.2": 3.4.2 "glob@>=10.2.0 <10.5.0": 10.5.0 + "js-yaml@>=4.0.0 <4.3.2": 4.3.2 "lodash@<=4.17.23": 4.18.1 "minimatch@>=9.0.0 <9.0.7": 9.0.7 "nanoid@>=3.0.0 <3.3.18": 3.3.18 "picomatch@<2.3.2": 2.3.2 "postcss@<8.5.23": 8.5.26 + "postcss-selector-parser@>=6.1.0 <6.1.3": 6.1.3 "yaml@>=2.0.0 <2.8.3": 2.9.0 diff --git a/src/api/liveClient.ts b/src/api/liveClient.ts index 0abc9a7..493de04 100644 --- a/src/api/liveClient.ts +++ b/src/api/liveClient.ts @@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f import { toIsoString } from '@/lib/backendDateTime'; import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops'; import type { GiteaActionsStatus, IntegrationStatus, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops'; -import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, WorkMeshPromotion } from '../types/appops'; +import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, WorkMeshPromotion } from '../types/appops'; import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops'; type HealthStatus = { @@ -372,6 +372,10 @@ export const createLiveClient = () => ({ }, async getDeliveryStatus(): Promise { return get('/api/deployments/delivery-status', defaultConfig); }, async getEnrollmentCapabilities(): Promise { return get('/api/enrollments/capabilities', defaultConfig); }, + async getEnrollmentTrustStatus(): Promise { return get('/api/enrollments/trust', defaultConfig); }, + async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise { + return patch('/api/enrollments/trust/storage-root', payload, defaultConfig); + }, async getEnrollments(): Promise<{ items: Enrollment[] }> { return get<{ items: Enrollment[] }>('/api/enrollments', defaultConfig); }, async getSystemSummary(): Promise { return get('/api/system/summary', defaultConfig); }, async getHealth(): Promise { return get('/api/health', defaultConfig); }, diff --git a/src/components/ManagedStorageSettings.tsx b/src/components/ManagedStorageSettings.tsx new file mode 100644 index 0000000..f5f00e0 --- /dev/null +++ b/src/components/ManagedStorageSettings.tsx @@ -0,0 +1,392 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AlertCircle, CheckCircle2, Database, LoaderCircle, RefreshCw, ShieldAlert } from 'lucide-react'; +import { appopsClient } from '@/api/appopsClient'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { formatOperatorError } from '@/lib/operatorError'; +import { + buildEnrollmentStorageView, + buildStorageRootSelection, + canSaveStorageRootSelection, + canRequestManagedStorageInitialization, + normalizeManagedStoragePreview, + normalizeManagedStorageActionOutcome, + LEGACY_APPDATA_ROOT, + MANAGED_APPDATA_ROOT, + MANAGED_STORAGE_INITIALIZE_ACTION, + MANAGED_STORAGE_TARGET, + managedStorageBlockerLabel, + managedStorageStatusLabel, + rootReadinessLabel, +} from '@/lib/enrollmentStorageView'; +import { useUserSession } from '@/hooks/useUserSession'; +import type { ActionPlan, EnrollmentCapabilities, EnrollmentTrustStatus, PreflightPreview, TrustedAppdataRoot } from '@/types/appops'; + +interface StorageSnapshot { + trust: EnrollmentTrustStatus; + capabilities: EnrollmentCapabilities; + view: ReturnType; + preview: PreflightPreview | null; + previewError: string | null; +} + +interface StorageDraft { + root: TrustedAppdataRoot; + backupAcknowledged: boolean; +} + +const labelForRoot = (root: TrustedAppdataRoot) => ( + root === LEGACY_APPDATA_ROOT ? 'Unraid appdata share' : 'Dedicated managed Docker-root volume' +); + +const isConflict = (error: unknown): boolean => ( + error instanceof Error && /Backend 409:/.test(error.message) +); + +export const ManagedStorageSettings = () => { + const session = useUserSession(); + const isAdmin = session.ready && session.authenticated && session.user?.role === 'admin'; + const canManage = isAdmin && session.permissions.includes('settings.manage'); + const [snapshot, setSnapshot] = useState(null); + const [draft, setDraft] = useState({ root: LEGACY_APPDATA_ROOT, backupAcknowledged: false }); + const [refreshing, setRefreshing] = useState(false); + const [loadError, setLoadError] = useState(''); + const [saveError, setSaveError] = useState(''); + const [conflict, setConflict] = useState(''); + const [saveNotice, setSaveNotice] = useState(''); + const [saving, setSaving] = useState(false); + const [initializing, setInitializing] = useState(false); + const [actionNotice, setActionNotice] = useState<{ tone: 'status' | 'alert'; message: string } | null>(null); + const requestSequence = useRef(0); + const snapshotRef = useRef(null); + const draftRef = useRef(draft); + + const updateDraft = (next: StorageDraft) => { + draftRef.current = next; + setDraft(next); + }; + + const load = useCallback(async (options: { preserveDraft?: boolean } = {}) => { + if (!canManage) return; + const sequence = ++requestSequence.current; + setRefreshing(true); + setLoadError(''); + try { + const [trust, capabilities] = await Promise.all([ + appopsClient.getEnrollmentTrustStatus(), + appopsClient.getEnrollmentCapabilities(), + ]); + const view = buildEnrollmentStorageView(trust, capabilities); + let preview: PreflightPreview | null = null; + let previewError: string | null = null; + if (view.managedStatus === 'initialization_required') { + try { + const rawPreview: unknown = await appopsClient.previewAction(MANAGED_STORAGE_INITIALIZE_ACTION, MANAGED_STORAGE_TARGET); + preview = normalizeManagedStoragePreview(rawPreview); + if (!preview) previewError = 'De policy-preview had geen geldig formaat; de actie blijft geblokkeerd.'; + } catch (error) { + previewError = formatOperatorError(error, 'De policy-preview is niet beschikbaar.'); + } + } + if (sequence !== requestSequence.current) return; + + const previous = snapshotRef.current; + const currentDraft = draftRef.current; + const draftChanged = previous !== null && ( + currentDraft.root !== previous.view.currentRoot || + currentDraft.backupAcknowledged !== previous.view.backupAcknowledged + ); + const nextSnapshot = { trust, capabilities, view, preview, previewError }; + snapshotRef.current = nextSnapshot; + setSnapshot(nextSnapshot); + if (!options.preserveDraft && !draftChanged && view.currentRoot) { + updateDraft({ root: view.currentRoot, backupAcknowledged: view.backupAcknowledged }); + } + if (!options.preserveDraft) setConflict(''); + } catch (error) { + if (sequence !== requestSequence.current) return; + setLoadError(formatOperatorError(error, 'Opslaginstellingen konden niet worden geladen.')); + } finally { + if (sequence === requestSequence.current) setRefreshing(false); + } + }, [canManage]); + + useEffect(() => { + if (canManage) void load(); + }, [canManage, load]); + + if (!isAdmin) return null; + + const view = snapshot?.view; + const selectedDraft = draft.root; + const draftIsManaged = selectedDraft === MANAGED_APPDATA_ROOT; + const dirty = Boolean(view?.currentRoot && ( + draft.root !== view.currentRoot || + (draftIsManaged && draft.backupAcknowledged !== view.backupAcknowledged) + )); + const requestPayload = buildStorageRootSelection(draft.root, draft.backupAcknowledged); + const saveDisabled = !canSaveStorageRootSelection({ + canManage, + configured: Boolean(snapshot?.view.configured), + payloadAvailable: Boolean(requestPayload), + dirty, + loadError, + refreshing, + saving, + initializing, + }); + const initializationGate = { + canManage, + view, + preview: snapshot?.preview, + dirty, + loadError, + conflict, + refreshing, + saving, + initializing, + }; + const initializeAllowed = canRequestManagedStorageInitialization(initializationGate); + + const save = async () => { + if (!canManage || !requestPayload || saveDisabled) return; + const sequence = ++requestSequence.current; + setSaving(true); + setSaveError(''); + setConflict(''); + setSaveNotice(''); + setActionNotice(null); + try { + const updatedTrust = await appopsClient.updateEnrollmentStorageRoot(requestPayload); + if (sequence !== requestSequence.current) return; + const current = snapshotRef.current; + if (current) { + const updatedView = buildEnrollmentStorageView(updatedTrust, current.capabilities); + const updatedSnapshot = { ...current, trust: updatedTrust, view: updatedView }; + snapshotRef.current = updatedSnapshot; + setSnapshot(updatedSnapshot); + if (updatedView.currentRoot) updateDraft({ root: updatedView.currentRoot, backupAcknowledged: updatedView.backupAcknowledged }); + } + setSaveNotice('Storagevoorkeur opgeslagen. De controller-readiness wordt opnieuw gecontroleerd; bestaande targets blijven ongewijzigd.'); + await load(); + } catch (error) { + if (sequence !== requestSequence.current) return; + if (isConflict(error)) { + setConflict('De trustconfiguratie is intussen gewijzigd. De actuele status wordt geladen; jouw keuze blijft als concept bewaard. Controleer de selectie en sla opnieuw op als die nog klopt.'); + await load({ preserveDraft: true }); + } else { + setSaveError(formatOperatorError(error, 'De storagevoorkeur is niet opgeslagen. Jouw concept blijft behouden.')); + } + } finally { + setSaving(false); + } + }; + + const initialize = async () => { + if (!snapshot?.preview || !view || !canRequestManagedStorageInitialization(initializationGate)) return; + const preview = snapshot.preview; + const plan: ActionPlan = { + intent: MANAGED_STORAGE_INITIALIZE_ACTION, + target: MANAGED_STORAGE_TARGET, + source: 'manual', + risk: preview.risk, + policy: preview.policyDecision, + reason: preview.auditPreview, + plan: preview.preChecks, + postChecks: preview.postChecks, + }; + setInitializing(true); + setActionNotice(null); + try { + const result: unknown = await appopsClient.executeAction(plan); + setActionNotice(normalizeManagedStorageActionOutcome(result)); + await load({ preserveDraft: true }); + } catch (error) { + setActionNotice({ tone: 'alert', message: formatOperatorError(error, 'Het initialisatieverzoek is niet bevestigd.') }); + } finally { + setInitializing(false); + } + }; + + return ( + + +
+ + Opslag voor nieuwe targets + +

+ Kies de vertrouwde opslagroot voor toekomstige registraties. Dit wijzigt of verplaatst geen bestaande targetregistraties. +

+
+ +
+ + {!canManage && ( +

Alleen een beheerder met de instellingentoestemming kan deze opslagkeuze bekijken of wijzigen.

+ )} + {canManage && refreshing && !snapshot &&

Opslagstatus laden…

} + {canManage && loadError && ( +
+
+ )} + {snapshot && view && canManage && ( + <> +
+
+

Opgeslagen root

+

{view.currentRoot || 'Niet bevestigd door de API'}

+

{view.currentRoot ? labelForRoot(view.currentRoot) : 'De huidige trustconfiguratie kan niet veilig worden gewijzigd.'}

+
+
+

Truststatus

+

+ {view.trustStatus === 'ready' ? 'Configuratie gelezen' : view.trustStatus === 'blocked' ? 'Trustconfiguratie heeft andere blockers' : view.trustStatus === 'needs_setup' ? 'Trustconfiguratie ontbreekt' : 'Status onbekend'} +

+ {view.trustStatus === 'needs_setup' &&

Rond de trustconfiguratie eerst af via Setup.

} +
+
+ + {view.trustStatus === 'needs_setup' && ( +
Deze beperkte kaart kan geen volledige trustconfiguratie maken.
+ )} + + {snapshot.view.configured && ( +
+ Vertrouwde opslagroot + { + if (value !== LEGACY_APPDATA_ROOT && value !== MANAGED_APPDATA_ROOT) return; + setSaveNotice(''); + setConflict(''); + updateDraft({ root: value, backupAcknowledged: false }); + }} + className="grid min-w-0 gap-3 md:grid-cols-2" + > + + + +
+ )} + + {draftIsManaged && snapshot.view.configured && ( +
+
+
+
+ { + setSaveNotice(''); + setConflict(''); + updateDraft({ ...draftRef.current, backupAcknowledged: checked === true }); + }} + disabled={saving || refreshing || initializing} + aria-describedby="managed-root-backup-copy" + className="mt-0.5 shrink-0" + /> + +
+
+ )} + +
+

Controller-readiness van de beheerde volume

+

{managedStorageStatusLabel(view.managedStatus)}

+ {managedStorageBlockerLabel(view.managedBlocker) &&

{managedStorageBlockerLabel(view.managedBlocker)}

} + {!view.managedCapabilityValid &&

De controller leverde geen geldige readinessbevestiging; acties blijven geblokkeerd.

} +

De rootkeuze geldt voor toekomstige registraties. Bestaande targets blijven aan hun eerder vastgelegde root gekoppeld en worden niet verplaatst.

+
+ + {view.managedStatus === 'initialization_required' && ( +
+
+

Eenmalige controlleractie

+

Alleen beschikbaar als de exacte handmatige high-risk policy-preview dit toestaat. Een aanvraag is geen bewijs dat de volume klaar is.

+
+ {snapshot.previewError &&

Policy-preview niet beschikbaar: {snapshot.previewError}

} + {snapshot.preview && ( +
+

Policy: {snapshot.preview.policyDecision === 'allowed' ? 'Toegestaan' : snapshot.preview.policyDecision === 'approval_required' ? 'Approval vereist' : 'Geblokkeerd'}

+

{snapshot.preview.auditPreview}

+ {snapshot.preview.preChecks.length > 0 && ( +
    + {snapshot.preview.preChecks.map((check, index) =>
  • {check}
  • )} +
+ )} + {(snapshot.preview.action !== MANAGED_STORAGE_INITIALIZE_ACTION || snapshot.preview.target !== MANAGED_STORAGE_TARGET) && ( +

De preview bevestigt niet de vaste actie en controllerdoel; uitvoering blijft geblokkeerd.

+ )} +
+ )} + {initializeAllowed && ( + + )} +
+ )} + + {actionNotice && ( +
+ {actionNotice.message} +
+ )} + + {conflict &&
{conflict}
} + {saveError &&
{saveError}
} + {saveNotice &&
} + + {view.configured && canManage && ( +
+

Opslaan wijzigt alleen de trustinstelling voor volgende registraties.

+ +
+ )} + + )} +
+
+ ); +}; diff --git a/src/lib/enrollmentStorageView.test.ts b/src/lib/enrollmentStorageView.test.ts new file mode 100644 index 0000000..396fc6e --- /dev/null +++ b/src/lib/enrollmentStorageView.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildEnrollmentStorageView, + buildStorageRootSelection, + canInitializeManagedStorage, + canRequestManagedStorageInitialization, + canSaveStorageRootSelection, + LEGACY_APPDATA_ROOT, + MANAGED_APPDATA_ROOT, + MANAGED_STORAGE_INITIALIZE_ACTION, + MANAGED_STORAGE_TARGET, + normalizeManagedStoragePreview, + normalizeManagedStorageActionOutcome, +} from './enrollmentStorageView.ts'; + +const baseTrust = { + configured: true, + status: 'ready', + trustedAppdataRoot: MANAGED_APPDATA_ROOT, + managedAppdataBackupAcknowledged: true, +}; +const baseCapabilities = { + availableTrustedAppdataRoots: [ + { root: LEGACY_APPDATA_ROOT, status: 'ready' }, + { root: MANAGED_APPDATA_ROOT, status: 'needs_initialization' }, + ], + managedStorage: { + status: 'initialization_required', + blockerCode: 'managed_appdata_initialization_required', + root: MANAGED_APPDATA_ROOT, + backupScope: 'dockerRootDir', + backupAcknowledged: true, + }, +}; + +test('malformed or unknown roots fail closed without echoing untrusted paths', () => { + const view = buildEnrollmentStorageView({ ...baseTrust, trustedAppdataRoot: '/tmp/unknown-secret-path' }, { + ...baseCapabilities, + availableTrustedAppdataRoots: [{ root: '/tmp/private', status: 'ready' }, ...baseCapabilities.availableTrustedAppdataRoots], + }); + + assert.equal(view.currentRoot, null); + assert.equal(view.configured, false); + assert.equal(view.rootReadiness[LEGACY_APPDATA_ROOT], 'unavailable'); + assert.equal(view.rootReadiness[MANAGED_APPDATA_ROOT], 'unavailable'); + assert.equal(view.managedStatus, 'unavailable'); + assert.equal(view.managedCapabilityValid, false); +}); + +test('readiness and saved selection fail closed when root evidence or acknowledgement contradicts', () => { + const unavailableManagedRoot = buildEnrollmentStorageView(baseTrust, { + ...baseCapabilities, + availableTrustedAppdataRoots: [ + { root: LEGACY_APPDATA_ROOT, status: 'ready' }, + { root: MANAGED_APPDATA_ROOT, status: 'unavailable' }, + ], + managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' }, + }); + assert.equal(unavailableManagedRoot.managedStatus, 'unavailable'); + assert.equal(unavailableManagedRoot.managedCapabilityValid, false); + + const contradictoryManagedAcknowledgement = buildEnrollmentStorageView(baseTrust, { + ...baseCapabilities, + managedStorage: { ...baseCapabilities.managedStorage, backupAcknowledged: false }, + }); + assert.equal(contradictoryManagedAcknowledgement.configured, false); + assert.equal(contradictoryManagedAcknowledgement.currentRoot, null); + assert.equal(contradictoryManagedAcknowledgement.trustStatus, 'unknown'); + assert.equal(contradictoryManagedAcknowledgement.managedStatus, 'unavailable'); + + const malformedRootEvidence = buildEnrollmentStorageView(baseTrust, { + ...baseCapabilities, + availableTrustedAppdataRoots: [{ root: LEGACY_APPDATA_ROOT, status: 'ready' }], + managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' }, + }); + assert.equal(malformedRootEvidence.managedStatus, 'unavailable'); +}); + +test('managed-root choice requires explicit backup acknowledgement and sends only the two contract fields', () => { + assert.equal(buildStorageRootSelection(MANAGED_APPDATA_ROOT, false), null); + assert.deepEqual(buildStorageRootSelection(MANAGED_APPDATA_ROOT, true), { + trusted_appdata_root: MANAGED_APPDATA_ROOT, + managed_appdata_backup_acknowledged: true, + }); + assert.deepEqual(buildStorageRootSelection(LEGACY_APPDATA_ROOT, true), { + trusted_appdata_root: LEGACY_APPDATA_ROOT, + managed_appdata_backup_acknowledged: false, + }); +}); + +test('a failed refresh disables a dirty storage-root draft from being saved', () => { + const gate = { + canManage: true, + configured: true, + payloadAvailable: true, + dirty: true, + loadError: '', + refreshing: false, + saving: false, + initializing: false, + }; + + assert.equal(canSaveStorageRootSelection(gate), true); + assert.equal(canSaveStorageRootSelection({ ...gate, loadError: 'Refresh failed; showing last-known configuration.' }), false); + assert.equal(canSaveStorageRootSelection({ ...gate, refreshing: true }), false); + assert.equal(canSaveStorageRootSelection({ ...gate, initializing: true }), false); +}); + +test('initialization requires selected managed root, capability evidence, exact high-risk action and allowed manual policy', () => { + const view = buildEnrollmentStorageView(baseTrust, baseCapabilities); + const preview = { + action: MANAGED_STORAGE_INITIALIZE_ACTION, + target: MANAGED_STORAGE_TARGET, + risk: 'high', + policyDecision: 'allowed', + }; + + assert.equal(canInitializeManagedStorage(view, preview), true); + assert.equal(canInitializeManagedStorage(view, { ...preview, policyDecision: 'blocked' }), false); + assert.equal(canInitializeManagedStorage(view, { ...preview, target: 'tower/other' }), false); + assert.equal(canInitializeManagedStorage(view, { ...preview, risk: 'low' }), false); + assert.equal(canInitializeManagedStorage(view, { ...view, policyDecision: 'allowed' }), false); + assert.equal(canInitializeManagedStorage( + buildEnrollmentStorageView({ ...baseTrust, managedAppdataBackupAcknowledged: false }, baseCapabilities), + preview, + ), false); + assert.equal(canInitializeManagedStorage( + buildEnrollmentStorageView(baseTrust, { ...baseCapabilities, managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' } }), + preview, + ), false); +}); + +test('initialization stays disabled during stale refresh, conflict recovery, dirty drafts and mutations', () => { + const view = buildEnrollmentStorageView(baseTrust, baseCapabilities); + const gate = { + canManage: true, + view, + preview: { + action: MANAGED_STORAGE_INITIALIZE_ACTION, + target: MANAGED_STORAGE_TARGET, + risk: 'high', + policyDecision: 'allowed', + }, + dirty: false, + loadError: '', + conflict: '', + refreshing: false, + saving: false, + initializing: false, + }; + + assert.equal(canRequestManagedStorageInitialization(gate), true); + assert.equal(canRequestManagedStorageInitialization({ ...gate, loadError: 'Refresh failed; showing last known data.' }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, conflict: 'The saved selection changed.' }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, dirty: true }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, refreshing: true }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, saving: true }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, initializing: true }), false); + assert.equal(canRequestManagedStorageInitialization({ ...gate, canManage: false }), false); +}); + +test('malformed runtime policy preview is rejected before UI uses its arrays or text', () => { + assert.equal(normalizeManagedStoragePreview({ + action: MANAGED_STORAGE_INITIALIZE_ACTION, + target: MANAGED_STORAGE_TARGET, + risk: 'high', + policyDecision: 'allowed', + auditPreview: 'Initialize managed storage', + affectedApps: [], + dependencies: [], + preChecks: 'not-an-array', + postChecks: [], + }), null); + assert.equal(normalizeManagedStoragePreview({ + action: MANAGED_STORAGE_INITIALIZE_ACTION, + target: MANAGED_STORAGE_TARGET, + risk: 'high', + policyDecision: 'allowed', + auditPreview: 42, + affectedApps: [], + dependencies: [], + preChecks: [], + postChecks: [], + }), null); +}); + +test('malformed initializer results never produce a success notice or stringify arbitrary messages', () => { + const malformedResults: unknown[] = [ + { success: true, jobId: {}, auditId: 'audit-1', message: 'Started' }, + { success: true, jobId: 'job-1', auditId: ' ', message: 'Started' }, + { success: true, jobId: `${' '.repeat(201)}job-1`, auditId: 'audit-1', message: 'Started' }, + { success: true, jobId: 'job-1', auditId: `${' '.repeat(201)}audit-1`, message: 'Started' }, + { success: true, jobId: 'job-1', auditId: 'audit-1', message: { detail: 'Started' } }, + { success: true, jobId: 'job-1', auditId: 'audit-1' }, + { success: 'true', jobId: 'job-1', auditId: 'audit-1', message: 'Started' }, + { success: false, message: { detail: 'Denied' } }, + ]; + + for (const result of malformedResults) { + assert.deepEqual(normalizeManagedStorageActionOutcome(result), { + tone: 'alert', + message: 'Het initialisatieverzoek is niet bevestigd met geldige referenties.', + }); + } + + assert.deepEqual(normalizeManagedStorageActionOutcome({ + success: true, + jobId: 'job-123', + auditId: 'audit-456', + message: 'Accepted', + }), { + tone: 'status', + message: 'Initialisatieverzoek geregistreerd. Job job-123; audit audit-456. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.', + }); +}); diff --git a/src/lib/enrollmentStorageView.ts b/src/lib/enrollmentStorageView.ts new file mode 100644 index 0000000..a1695ad --- /dev/null +++ b/src/lib/enrollmentStorageView.ts @@ -0,0 +1,277 @@ +import type { + PreflightPreview, + EnrollmentStorageRootSelection, + ManagedStorageStatus, + TrustedAppdataRoot, + TrustedAppdataRootReadinessStatus, +} from '@/types/appops'; + +export const LEGACY_APPDATA_ROOT: TrustedAppdataRoot = '/srv/example/appdata'; +export const MANAGED_APPDATA_ROOT: TrustedAppdataRoot = '/var/lib/docker/volumes/itworx-managed-appdata/_data'; +export const MANAGED_STORAGE_INITIALIZE_ACTION = 'controller.managed-storage.initialize'; +export const MANAGED_STORAGE_TARGET = 'tower/deployment-controller'; + +export interface EnrollmentStorageView { + configured: boolean; + trustStatus: 'ready' | 'blocked' | 'needs_setup' | 'unknown'; + currentRoot: TrustedAppdataRoot | null; + backupAcknowledged: boolean; + rootReadiness: Record; + managedStatus: ManagedStorageStatus; + managedBlocker: string | null; + managedCapabilityValid: boolean; +} + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); + +const isRoot = (value: unknown): value is TrustedAppdataRoot => ( + value === LEGACY_APPDATA_ROOT || value === MANAGED_APPDATA_ROOT +); + +const isRootReadiness = (value: unknown): value is TrustedAppdataRootReadinessStatus => ( + value === 'ready' || value === 'needs_initialization' || value === 'unavailable' +); + +const isManagedStatus = (value: unknown): value is ManagedStorageStatus => ( + value === 'not_selected' || value === 'blocked' || value === 'ready' || value === 'initialization_required' || value === 'unavailable' +); + +export const buildEnrollmentStorageView = (trust: unknown, capabilities: unknown): EnrollmentStorageView => { + const safeTrust = isRecord(trust) ? trust : {}; + const safeCapabilities = isRecord(capabilities) ? capabilities : {}; + const trustedRoot = isRoot(safeTrust.trustedAppdataRoot) ? safeTrust.trustedAppdataRoot : null; + const managed = isRecord(safeCapabilities.managedStorage) ? safeCapabilities.managedStorage : null; + const reportedRoots = safeCapabilities.availableTrustedAppdataRoots; + let rootReadiness: Record = { + [LEGACY_APPDATA_ROOT]: 'unavailable', + [MANAGED_APPDATA_ROOT]: 'unavailable', + }; + let rootsValid = false; + + if (Array.isArray(reportedRoots) && reportedRoots.length === 2) { + const entries = new Map(); + const valid = reportedRoots.every((entry) => { + if (!isRecord(entry) || !isRoot(entry.root) || !isRootReadiness(entry.status) || entries.has(entry.root)) return false; + entries.set(entry.root, entry.status); + return true; + }); + if (valid && entries.has(LEGACY_APPDATA_ROOT) && entries.has(MANAGED_APPDATA_ROOT)) { + rootsValid = true; + rootReadiness = { + [LEGACY_APPDATA_ROOT]: entries.get(LEGACY_APPDATA_ROOT)!, + [MANAGED_APPDATA_ROOT]: entries.get(MANAGED_APPDATA_ROOT)!, + }; + } + } + + const backupAcknowledged = safeTrust.managedAppdataBackupAcknowledged; + const trustSelectionConsistent = typeof backupAcknowledged === 'boolean' && ( + trustedRoot === MANAGED_APPDATA_ROOT + ? backupAcknowledged && managed?.root === MANAGED_APPDATA_ROOT && managed.backupAcknowledged === backupAcknowledged + : trustedRoot === LEGACY_APPDATA_ROOT && !backupAcknowledged + ); + const managedStatusConsistent = Boolean( + managed && ( + (managed.status !== 'ready' || rootReadiness[MANAGED_APPDATA_ROOT] === 'ready') && + (managed.status !== 'initialization_required' || rootReadiness[MANAGED_APPDATA_ROOT] === 'needs_initialization') && + (trustedRoot !== MANAGED_APPDATA_ROOT || managed.status !== 'not_selected') && + (trustedRoot !== MANAGED_APPDATA_ROOT || managed.backupAcknowledged === backupAcknowledged) + ) + ); + const managedCapabilityValid = Boolean( + rootsValid && + trustSelectionConsistent && + managed && + managed.root === MANAGED_APPDATA_ROOT && + managed.backupScope === 'dockerRootDir' && + typeof managed.backupAcknowledged === 'boolean' && + isManagedStatus(managed.status) && + managedStatusConsistent, + ); + const rawTrustStatus = safeTrust.status === 'ready' || safeTrust.status === 'blocked' || safeTrust.status === 'needs_setup' + ? safeTrust.status + : 'unknown'; + const configured = safeTrust.configured === true && trustedRoot !== null && trustSelectionConsistent; + const trustStatus = rawTrustStatus === 'needs_setup' || rawTrustStatus === 'blocked' + ? rawTrustStatus + : configured && rawTrustStatus === 'ready' ? 'ready' : 'unknown'; + + return { + configured, + trustStatus, + currentRoot: configured ? trustedRoot : null, + backupAcknowledged: backupAcknowledged === true, + rootReadiness, + managedStatus: managedCapabilityValid ? managed!.status as ManagedStorageStatus : 'unavailable', + managedBlocker: managedCapabilityValid && typeof managed!.blockerCode === 'string' && managed!.blockerCode.length <= 100 + ? managed!.blockerCode || null + : null, + managedCapabilityValid, + }; +}; + +const isBoundedStringArray = (value: unknown): value is string[] => ( + Array.isArray(value) && value.length <= 100 && value.every((item) => typeof item === 'string' && item.length <= 500) +); + +export const normalizeManagedStoragePreview = (value: unknown): PreflightPreview | null => { + if (!isRecord(value)) return null; + const rollbackAvailable = value.rollbackAvailable; + if ( + typeof value.action !== 'string' || value.action.length > 120 || + typeof value.target !== 'string' || value.target.length > 200 || + (value.risk !== 'low' && value.risk !== 'medium' && value.risk !== 'high' && value.risk !== 'critical') || + (value.policyDecision !== 'allowed' && value.policyDecision !== 'approval_required' && value.policyDecision !== 'blocked') || + typeof value.auditPreview !== 'string' || value.auditPreview.length > 2000 || + !isBoundedStringArray(value.affectedApps) || + !isBoundedStringArray(value.dependencies) || + !isBoundedStringArray(value.preChecks) || + !isBoundedStringArray(value.postChecks) || + (rollbackAvailable !== undefined && typeof rollbackAvailable !== 'boolean') + ) return null; + + const normalizedRollbackAvailable: boolean | undefined = typeof rollbackAvailable === 'boolean' + ? rollbackAvailable + : undefined; + + return { + action: value.action, + target: value.target, + risk: value.risk, + policyDecision: value.policyDecision, + auditPreview: value.auditPreview, + affectedApps: value.affectedApps, + dependencies: value.dependencies, + preChecks: value.preChecks, + postChecks: value.postChecks, + ...(normalizedRollbackAvailable === undefined ? {} : { rollbackAvailable: normalizedRollbackAvailable }), + }; +}; + +export const buildStorageRootSelection = ( + root: TrustedAppdataRoot, + backupAcknowledged: boolean, +): EnrollmentStorageRootSelection | null => { + if (root === MANAGED_APPDATA_ROOT && !backupAcknowledged) return null; + return { + trusted_appdata_root: root, + managed_appdata_backup_acknowledged: root === MANAGED_APPDATA_ROOT, + }; +}; + +export const canInitializeManagedStorage = ( + view: EnrollmentStorageView, + preview: unknown, +): boolean => ( + view.configured && + view.currentRoot === MANAGED_APPDATA_ROOT && + view.backupAcknowledged && + view.managedCapabilityValid && + view.managedStatus === 'initialization_required' && + isRecord(preview) && + preview.action === MANAGED_STORAGE_INITIALIZE_ACTION && + preview.target === MANAGED_STORAGE_TARGET && + preview.risk === 'high' && + preview.policyDecision === 'allowed' +); + +export interface ManagedStorageInitializationGate { + canManage: boolean; + view: EnrollmentStorageView | null | undefined; + preview: unknown; + dirty: boolean; + loadError: string; + conflict: string; + refreshing: boolean; + saving: boolean; + initializing: boolean; +} + +export const canRequestManagedStorageInitialization = (gate: ManagedStorageInitializationGate): boolean => ( + gate.canManage && + Boolean(gate.view && canInitializeManagedStorage(gate.view, gate.preview)) && + !gate.dirty && + !gate.loadError && + !gate.conflict && + !gate.refreshing && + !gate.saving && + !gate.initializing +); + +export interface StorageRootSaveGate { + canManage: boolean; + configured: boolean; + payloadAvailable: boolean; + dirty: boolean; + loadError: string; + refreshing: boolean; + saving: boolean; + initializing: boolean; +} + +export const canSaveStorageRootSelection = (gate: StorageRootSaveGate): boolean => ( + gate.canManage && + gate.configured && + gate.payloadAvailable && + gate.dirty && + !gate.loadError && + !gate.refreshing && + !gate.saving && + !gate.initializing +); + +export interface ManagedStorageActionOutcome { + tone: 'status' | 'alert'; + message: string; +} + +const UNCONFIRMED_INITIALIZATION_MESSAGE = 'Het initialisatieverzoek is niet bevestigd met geldige referenties.'; +const isBoundedIdentifier = (value: unknown): value is string => ( + typeof value === 'string' && value.trim().length > 0 && value.length <= 200 +); + +export const normalizeManagedStorageActionOutcome = (value: unknown): ManagedStorageActionOutcome => { + if (!isRecord(value) || typeof value.message !== 'string' || value.message.trim().length === 0 || value.message.length > 500) { + return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE }; + } + + if (value.success === true && isBoundedIdentifier(value.jobId) && isBoundedIdentifier(value.auditId)) { + return { + tone: 'status', + message: `Initialisatieverzoek geregistreerd. Job ${value.jobId.trim()}; audit ${value.auditId.trim()}. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.`, + }; + } + + if (value.success === false) { + return { tone: 'alert', message: value.message.trim() }; + } + + return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE }; +}; + +export const rootReadinessLabel = (status: TrustedAppdataRootReadinessStatus): string => { + if (status === 'ready') return 'Controller confirms ready'; + if (status === 'needs_initialization') return 'Controller reports initialization needed'; + return 'Not currently confirmed'; +}; + +export const managedStorageStatusLabel = (status: ManagedStorageStatus): string => { + switch (status) { + case 'not_selected': return 'Not selected for future targets'; + case 'blocked': return 'Blocked by a required safety condition'; + case 'ready': return 'Controller reports ready'; + case 'initialization_required': return 'Controller requires one-time initialization'; + default: return 'Unavailable or not confirmed'; + } +}; + +export const managedStorageBlockerLabel = (blocker: string | null): string | null => { + switch (blocker) { + case 'managed_appdata_backup_acknowledgement_required': return 'Confirm the Docker-root backup responsibility before continuing.'; + case 'managed_appdata_initialization_required': return 'The controller reports that the managed storage volume needs initialization.'; + case 'managed_appdata_volume_unavailable': return 'The controller cannot currently verify the managed storage volume.'; + default: return blocker ? 'The controller reports an additional storage blocker.' : null; + } +}; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index dc44688..d5bd165 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,6 +15,7 @@ import { usePermission } from "@/hooks/useUserSession"; import { UserAccessSettings } from "@/components/UserAccessSettings"; import { ExposureSettings } from "@/components/ExposureSettings"; import { ManagedAiSettings } from "@/components/ManagedAiSettings"; +import { ManagedStorageSettings } from "@/components/ManagedStorageSettings"; const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const; type SettingsTab = typeof settingsTabs[number]; @@ -194,6 +195,7 @@ const Settings = () => { + Runtime Profiel diff --git a/src/types/appops.ts b/src/types/appops.ts index 99e6a04..478c2ec 100644 --- a/src/types/appops.ts +++ b/src/types/appops.ts @@ -121,9 +121,37 @@ export interface EnrollmentCapabilities { policyProvisioning: EnrollmentCapability; rollback: EnrollmentCapability; verification: EnrollmentCapability; + availableTrustedAppdataRoots?: TrustedAppdataRootReadiness[]; + managedStorage?: ManagedStorageCapability; profiles: Array<{ name: string; version: string; public: boolean; authentication: string }>; } +export type TrustedAppdataRoot = '/srv/example/appdata' | '/var/lib/docker/volumes/itworx-managed-appdata/_data'; +export type TrustedAppdataRootReadinessStatus = 'ready' | 'needs_initialization' | 'unavailable'; +export interface TrustedAppdataRootReadiness { + root: TrustedAppdataRoot; + status: TrustedAppdataRootReadinessStatus; +} +export type ManagedStorageStatus = 'not_selected' | 'blocked' | 'ready' | 'initialization_required' | 'unavailable'; +export interface ManagedStorageCapability { + status: ManagedStorageStatus; + blockerCode?: string; + root: '/var/lib/docker/volumes/itworx-managed-appdata/_data'; + backupScope: 'dockerRootDir'; + backupAcknowledged: boolean; +} +export interface EnrollmentTrustStatus { + status: 'ready' | 'blocked' | 'needs_setup' | string; + blockers?: string[]; + configured?: boolean; + trustedAppdataRoot: TrustedAppdataRoot | string; + managedAppdataBackupAcknowledged: boolean; +} +export interface EnrollmentStorageRootSelection { + trusted_appdata_root: TrustedAppdataRoot; + managed_appdata_backup_acknowledged: boolean; +} + export interface Enrollment { id: string; applicationId: string;