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
@@ -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
|
||||
Reference in new issue
Block a user