Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea
This commit is contained in:
1 parent
29c1e3d5a1
commit
98d31477f9
35 files changed
+7264
-185
No files matched your search
@@ -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.
|
||||
+94
-49
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") == {}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Generated
+76
-54
@@ -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
|
||||
|
||||
|
||||
+6
-1
@@ -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
|
||||
@@ -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<DeliveryStatus> { return get<DeliveryStatus>('/api/deployments/delivery-status', defaultConfig); },
|
||||
async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); },
|
||||
async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); },
|
||||
async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> {
|
||||
return patch<EnrollmentTrustStatus>('/api/enrollments/trust/storage-root', payload, defaultConfig);
|
||||
},
|
||||
async getEnrollments(): Promise<{ items: Enrollment[] }> { return get<{ items: Enrollment[] }>('/api/enrollments', defaultConfig); },
|
||||
async getSystemSummary(): Promise<SystemSummary> { return get<SystemSummary>('/api/system/summary', defaultConfig); },
|
||||
async getHealth(): Promise<HealthStatus> { return get<HealthStatus>('/api/health', defaultConfig); },
|
||||
|
||||
@@ -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<typeof buildEnrollmentStorageView>;
|
||||
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<StorageSnapshot | null>(null);
|
||||
const [draft, setDraft] = useState<StorageDraft>({ 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<StorageSnapshot | null>(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 (
|
||||
<Card className="border-slate-700 bg-slate-900/50" aria-busy={refreshing || saving || initializing}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-300">
|
||||
<Database className="h-4 w-4 shrink-0 text-blue-400" /> Opslag voor nieuwe targets
|
||||
</CardTitle>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">
|
||||
Kies de vertrouwde opslagroot voor toekomstige registraties. Dit wijzigt of verplaatst geen bestaande targetregistraties.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="w-full shrink-0 border-slate-700 bg-slate-950 text-slate-200 sm:w-auto" onClick={() => void load()} disabled={!canManage || refreshing || saving || initializing}>
|
||||
{refreshing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{refreshing ? 'Status vernieuwen…' : 'Vernieuw status'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!canManage && (
|
||||
<p className="text-sm text-slate-400">Alleen een beheerder met de instellingentoestemming kan deze opslagkeuze bekijken of wijzigen.</p>
|
||||
)}
|
||||
{canManage && refreshing && !snapshot && <p role="status" aria-live="polite" className="text-sm text-slate-400">Opslagstatus laden…</p>}
|
||||
{canManage && loadError && (
|
||||
<div role="alert" className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p>{snapshot ? `De laatste opslagstatus kon niet worden vernieuwd. De waarden hieronder zijn de laatst succesvol gelezen gegevens. ${loadError}` : loadError}</p>
|
||||
{!snapshot && <Button type="button" variant="outline" className="mt-3 border-amber-500/30" onClick={() => void load()}>Opnieuw proberen</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{snapshot && view && canManage && (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-3 rounded-lg border border-slate-800 bg-slate-950/70 p-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Opgeslagen root</p>
|
||||
<p className="mt-1 break-all text-sm text-slate-100">{view.currentRoot || 'Niet bevestigd door de API'}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{view.currentRoot ? labelForRoot(view.currentRoot) : 'De huidige trustconfiguratie kan niet veilig worden gewijzigd.'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Truststatus</p>
|
||||
<p className="mt-1 text-sm text-slate-100">
|
||||
{view.trustStatus === 'ready' ? 'Configuratie gelezen' : view.trustStatus === 'blocked' ? 'Trustconfiguratie heeft andere blockers' : view.trustStatus === 'needs_setup' ? 'Trustconfiguratie ontbreekt' : 'Status onbekend'}
|
||||
</p>
|
||||
{view.trustStatus === 'needs_setup' && <p className="mt-1 text-xs text-amber-200">Rond de trustconfiguratie eerst af via Setup.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.trustStatus === 'needs_setup' && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-sm text-amber-100">Deze beperkte kaart kan geen volledige trustconfiguratie maken.</div>
|
||||
)}
|
||||
|
||||
{snapshot.view.configured && (
|
||||
<fieldset disabled={saving || refreshing || initializing} className="min-w-0 space-y-3 disabled:opacity-80">
|
||||
<legend className="text-sm font-semibold text-slate-200">Vertrouwde opslagroot</legend>
|
||||
<RadioGroup
|
||||
aria-label="Vertrouwde opslagroot voor toekomstige targetregistraties"
|
||||
value={draft.root}
|
||||
onValueChange={(value) => {
|
||||
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"
|
||||
>
|
||||
<label htmlFor="appdata-root-legacy" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-legacy" value={LEGACY_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(LEGACY_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{LEGACY_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[LEGACY_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label htmlFor="appdata-root-managed" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-managed" value={MANAGED_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(MANAGED_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{MANAGED_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[MANAGED_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{draftIsManaged && snapshot.view.configured && (
|
||||
<section aria-labelledby="managed-root-backup-title" className="space-y-3 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<h3 id="managed-root-backup-title" className="text-sm font-semibold text-amber-100">Back-upverantwoordelijkheid voor DockerRootDir</h3>
|
||||
<p className="mt-1 break-words text-sm leading-6 text-amber-100/80">
|
||||
Deze vaste root ligt onder <code className="break-all">/var/lib/docker</code> en kan deel uitmaken van <code>docker.img</code>. Gebruikelijke back-ups van <code>/srv/example/appdata</code> nemen dit niet mee. Beheer en herstel van DockerRootDir vallen onder jouw back-upplan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3 rounded-md border border-amber-500/20 bg-slate-950/60 p-3">
|
||||
<Checkbox
|
||||
id="managed-root-backup-ack"
|
||||
checked={draft.backupAcknowledged}
|
||||
onCheckedChange={(checked) => {
|
||||
setSaveNotice('');
|
||||
setConflict('');
|
||||
updateDraft({ ...draftRef.current, backupAcknowledged: checked === true });
|
||||
}}
|
||||
disabled={saving || refreshing || initializing}
|
||||
aria-describedby="managed-root-backup-copy"
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<Label htmlFor="managed-root-backup-ack" id="managed-root-backup-copy" className="cursor-pointer text-sm leading-6 text-slate-200">
|
||||
Ik bevestig dat reguliere appdata-back-ups DockerRootDir en <code>docker.img</code> niet dekken en dat ik daarvoor afzonderlijk back-up- en herstelbeheer regel.
|
||||
</Label>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controller-readiness van de beheerde volume</p>
|
||||
<p className="mt-1 text-sm text-slate-100">{managedStorageStatusLabel(view.managedStatus)}</p>
|
||||
{managedStorageBlockerLabel(view.managedBlocker) && <p className="mt-1 text-xs leading-5 text-amber-200">{managedStorageBlockerLabel(view.managedBlocker)}</p>}
|
||||
{!view.managedCapabilityValid && <p className="mt-1 text-xs text-amber-200">De controller leverde geen geldige readinessbevestiging; acties blijven geblokkeerd.</p>}
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">De rootkeuze geldt voor toekomstige registraties. Bestaande targets blijven aan hun eerder vastgelegde root gekoppeld en worden niet verplaatst.</p>
|
||||
</div>
|
||||
|
||||
{view.managedStatus === 'initialization_required' && (
|
||||
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-100">Eenmalige controlleractie</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">Alleen beschikbaar als de exacte handmatige high-risk policy-preview dit toestaat. Een aanvraag is geen bewijs dat de volume klaar is.</p>
|
||||
</div>
|
||||
{snapshot.previewError && <p role="alert" className="text-sm text-amber-200">Policy-preview niet beschikbaar: {snapshot.previewError}</p>}
|
||||
{snapshot.preview && (
|
||||
<div className="space-y-2 rounded-md border border-slate-800 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Policy: {snapshot.preview.policyDecision === 'allowed' ? 'Toegestaan' : snapshot.preview.policyDecision === 'approval_required' ? 'Approval vereist' : 'Geblokkeerd'}</p>
|
||||
<p className="break-words text-sm text-slate-200">{snapshot.preview.auditPreview}</p>
|
||||
{snapshot.preview.preChecks.length > 0 && (
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-400">
|
||||
{snapshot.preview.preChecks.map((check, index) => <li key={`${index}-${check}`} className="break-words">{check}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{(snapshot.preview.action !== MANAGED_STORAGE_INITIALIZE_ACTION || snapshot.preview.target !== MANAGED_STORAGE_TARGET) && (
|
||||
<p role="alert" className="text-xs text-rose-200">De preview bevestigt niet de vaste actie en controllerdoel; uitvoering blijft geblokkeerd.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{initializeAllowed && (
|
||||
<Button type="button" onClick={() => void initialize()} disabled={initializing || saving || refreshing} className="w-full sm:w-auto">
|
||||
{initializing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <ShieldAlert className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{initializing ? 'Actieverzoek indienen…' : 'Vraag volume-initialisatie aan'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionNotice && (
|
||||
<div role={actionNotice.tone === 'alert' ? 'alert' : 'status'} aria-live={actionNotice.tone === 'status' ? 'polite' : undefined} className={`rounded-lg border p-3 text-sm ${actionNotice.tone === 'alert' ? 'border-amber-500/30 bg-amber-500/5 text-amber-100' : 'border-blue-500/25 bg-blue-500/5 text-blue-100'}`}>
|
||||
{actionNotice.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && <div role="alert" className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">{conflict}</div>}
|
||||
{saveError && <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/5 p-3 text-sm text-rose-100">{saveError}</div>}
|
||||
{saveNotice && <div role="status" aria-live="polite" className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-3 text-sm text-emerald-100"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />{saveNotice}</div>}
|
||||
|
||||
{view.configured && canManage && (
|
||||
<div className="flex flex-col gap-3 border-t border-slate-800 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-slate-400">Opslaan wijzigt alleen de trustinstelling voor volgende registraties.</p>
|
||||
<Button type="button" onClick={() => void save()} disabled={saveDisabled} className="w-full sm:w-auto">
|
||||
{saving ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||
{saving ? 'Opslaan…' : dirty ? 'Opslagkeuze opslaan' : 'Geen wijzigingen'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
@@ -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<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>;
|
||||
managedStatus: ManagedStorageStatus;
|
||||
managedBlocker: string | null;
|
||||
managedCapabilityValid: boolean;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
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<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus> = {
|
||||
[LEGACY_APPDATA_ROOT]: 'unavailable',
|
||||
[MANAGED_APPDATA_ROOT]: 'unavailable',
|
||||
};
|
||||
let rootsValid = false;
|
||||
|
||||
if (Array.isArray(reportedRoots) && reportedRoots.length === 2) {
|
||||
const entries = new Map<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>();
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
|
||||
<TabsContent value="general">
|
||||
<ManagedStorageSettings />
|
||||
<Card className="signal-rail border-slate-700 bg-slate-900/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Runtime Profiel</CardTitle>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user