Publish curated Unraid-AppOps-Gateway source from 6d175c3333218e2b580a310496fed6c407f28114
This commit is contained in:
1 parent
58f5e3c8c2
commit
41fd48f0bc
7 files changed
+339
-16
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
# Public source export
|
# Public source export
|
||||||
|
|
||||||
This source snapshot was generated from the private canonical repository at revision `402a0ef2e7d14e98325504f79ab7d7651ffc88b1`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
This source snapshot was generated from the private canonical repository at revision `6d175c3333218e2b580a310496fed6c407f28114`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "402a0ef2e7d14e98325504f79ab7d7651ffc88b1",
|
"sourceRevision": "6d175c3333218e2b580a310496fed6c407f28114",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".dockerignore",
|
"path": ".dockerignore",
|
||||||
@@ -404,8 +404,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/app/mcp_server.py",
|
"path": "backend/app/mcp_server.py",
|
||||||
"sha256": "feac22ee7a1c755f0fafd6890500b83442b5f13b51e6a6cde3b0227c5b5ab25b",
|
"sha256": "598e7f7a5020d92becfb143d31dbd096f16bf79ae73b26e6ecae710661f73ca1",
|
||||||
"bytes": 49536
|
"bytes": 50519
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/app/runner_statistics_mcp.py",
|
"path": "backend/app/runner_statistics_mcp.py",
|
||||||
@@ -899,8 +899,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_appops_recovery_deploy_binding.py",
|
"path": "backend/tests/test_appops_recovery_deploy_binding.py",
|
||||||
"sha256": "4902f2e16ed35b8bd52d8610ab61b9752aa10a4147e5aafc27404bbbfef61e0a",
|
"sha256": "274e401e81eda762ff4cae0b64ecdefd322972c72a004fdd84f02b93a2e8b44d",
|
||||||
"bytes": 50460
|
"bytes": 52001
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_appops_self_upgrade_controller.py",
|
"path": "backend/tests/test_appops_self_upgrade_controller.py",
|
||||||
@@ -909,8 +909,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_appops_self_upgrade_recovery_boundaries.py",
|
"path": "backend/tests/test_appops_self_upgrade_recovery_boundaries.py",
|
||||||
"sha256": "07c3a38c360f5b70b6f3efbc8ba62356d4937170667d07d31d82a6810ed58a0e",
|
"sha256": "676a465700296b081cc07eaeab3d2669f3047001b28b614e8a8a82088010f3f0",
|
||||||
"bytes": 21578
|
"bytes": 29130
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_appops_self_upgrade_runtime_model.py",
|
"path": "backend/tests/test_appops_self_upgrade_runtime_model.py",
|
||||||
@@ -1004,8 +1004,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_deploy_broker.py",
|
"path": "backend/tests/test_deploy_broker.py",
|
||||||
"sha256": "b0e96b1cd2954dc2a6452f5f7c50c5fbae541dc0b4de1e60af11349654594902",
|
"sha256": "40c290925c0569192973d858e5df36b91e3c5dd38ce638b9f19b0ca60e61903a",
|
||||||
"bytes": 438851
|
"bytes": 442136
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_deployment_auto_rollback.py",
|
"path": "backend/tests/test_deployment_auto_rollback.py",
|
||||||
@@ -1284,8 +1284,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
|
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
|
||||||
"sha256": "0c90431bb77eab27f0da21afd02dd5fb232141483062f7a19c3340d1d91f6c9d",
|
"sha256": "1d72e95529863d3263a178d71d4c7729c8b624b13e734460fb9303a3f6f7416d",
|
||||||
"bytes": 52814
|
"bytes": 54507
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py",
|
"path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from typing import Any
|
|||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pydantic import StrictBool
|
from pydantic import StrictBool, ValidationError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from .api.routes_actions import action_registry as build_action_registry
|
from .api.routes_actions import action_registry as build_action_registry
|
||||||
@@ -894,6 +894,9 @@ def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str,
|
|||||||
operation="preflight",
|
operation="preflight",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except ValidationError:
|
||||||
|
# Pydantic errors may contain arbitrary keys, input values and validator context.
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except (ValueError, DeploymentControlError) as exc:
|
except (ValueError, DeploymentControlError) as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
|
|
||||||
@@ -918,6 +921,8 @@ def forge_bootstrap_apply(profile: dict[str, Any], idempotencyKey: str) -> dict[
|
|||||||
idempotency_key=idempotencyKey,
|
idempotency_key=idempotencyKey,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except ValidationError:
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
finally:
|
finally:
|
||||||
@@ -967,6 +972,8 @@ def enrollment_preflight(plan: dict[str, Any]) -> dict[str, Any]:
|
|||||||
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
return _json(EnrollmentPlane().preflight(session, typed_plan))
|
return _json(EnrollmentPlane().preflight(session, typed_plan))
|
||||||
|
except ValidationError:
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except (ValueError, EnrollmentError) as exc:
|
except (ValueError, EnrollmentError) as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
|
|
||||||
@@ -981,6 +988,8 @@ def target_enroll(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
|
|||||||
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey))
|
return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey))
|
||||||
|
except ValidationError:
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except (ValueError, EnrollmentError) as exc:
|
except (ValueError, EnrollmentError) as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
finally:
|
finally:
|
||||||
@@ -1015,6 +1024,9 @@ def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any
|
|||||||
operation="apply",
|
operation="apply",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except ValidationError:
|
||||||
|
# Pydantic errors may contain arbitrary keys, input values and validator context.
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except (ValueError, DeploymentControlError) as exc:
|
except (ValueError, DeploymentControlError) as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
finally:
|
finally:
|
||||||
@@ -1063,6 +1075,9 @@ def deployment_rollback(plan: dict[str, Any], idempotencyKey: str) -> dict[str,
|
|||||||
operation="rollback",
|
operation="rollback",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except ValidationError:
|
||||||
|
# Pydantic errors may contain arbitrary keys, input values and validator context.
|
||||||
|
return {"status": "invalid", "executed": False, "reason": "invalid_typed_plan"}
|
||||||
except (ValueError, DeploymentControlError) as exc:
|
except (ValueError, DeploymentControlError) as exc:
|
||||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1135,3 +1135,36 @@ def test_followup_transaction_journal_failure_precedes_all_source_runtime_and_st
|
|||||||
assert [event[0] for event in events] == ["green", "snapshot", "backup", "journal"]
|
assert [event[0] for event in events] == ["green", "snapshot", "backup", "journal"]
|
||||||
assert marker.read_text() == "prior source remains\n"
|
assert marker.read_text() == "prior source remains\n"
|
||||||
assert state["live_revision"] == prior_revision
|
assert state["live_revision"] == prior_revision
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_restored_recovery_rechecks_signed_live_state_and_recaptures_under_new_id(
|
||||||
|
deploy, monkeypatch, tmp_path,
|
||||||
|
):
|
||||||
|
case = _appops_prepare_recovery_case(deploy, monkeypatch, tmp_path)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def verify(_project, container, image, **kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
assert container["Id"] != deploy.APPOPS_LEGACY_OLD_CONTAINER_ID
|
||||||
|
|
||||||
|
monkeypatch.setattr(deploy, "verify_appops_legacy_runtime", verify)
|
||||||
|
deploy._appops_prepare_recovery(
|
||||||
|
case.project, APP_OPS_REPOSITORY, case.repo, case.revision, case.metadata, case.state,
|
||||||
|
)
|
||||||
|
assert calls == [{"require_original_container": False, "recovery_state": case.state}]
|
||||||
|
assert case.calls["models"][0][0] == RECOVERY_ID
|
||||||
|
assert case.calls["models"][0][2]["services"]["appops-gateway"]["environment"] == case.values
|
||||||
|
assert case.project["_compose_rollback"]["containers"][0]["container_id"] == case.container["Id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_recovery_rejects_environment_drift_before_model_backup_or_transaction(
|
||||||
|
deploy, monkeypatch, tmp_path,
|
||||||
|
):
|
||||||
|
case = _appops_prepare_recovery_case(deploy, monkeypatch, tmp_path)
|
||||||
|
case.container["Config"]["Env"].append("UNEXPECTED_SETTING=private-sentinel")
|
||||||
|
with pytest.raises(deploy.DeployError, match="complete runtime environment changed"):
|
||||||
|
deploy._appops_prepare_recovery(
|
||||||
|
case.project, APP_OPS_REPOSITORY, case.repo, case.revision, case.metadata, case.state,
|
||||||
|
)
|
||||||
|
assert not any(case.calls[name] for name in ("models", "capture", "backup", "begin"))
|
||||||
@@ -556,3 +556,166 @@ def test_original_failed_delivery_rejects_modified_legacy_record(controller, mon
|
|||||||
assert controller._self_upgrade_original_failed_delivery(
|
assert controller._self_upgrade_original_failed_delivery(
|
||||||
semantic, revision, policy,
|
semantic, revision, policy,
|
||||||
) is None
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("status", "extra", "valid"), [
|
||||||
|
("deploying", {}, True),
|
||||||
|
("failed", {"reason": "deployment_failed"}, True),
|
||||||
|
("deployed", {"receipt_sha256": "f" * 64}, True),
|
||||||
|
("deployed", {"receipt_sha256": "invalid"}, False),
|
||||||
|
("deploying", {"reason": "deployment_failed"}, False),
|
||||||
|
("failed", {"reason": "private-secret-value"}, False),
|
||||||
|
("failed", {"receipt_sha256": "f" * 64}, False),
|
||||||
|
("failed", {"unknown": "value"}, False),
|
||||||
|
("failed", {"reason": ["deployment_failed"]}, False),
|
||||||
|
("awaiting_validation", {"reason": "gitea_validation_unavailable"}, True),
|
||||||
|
("failed", {"reason": "recovery_preimage_changed"}, True),
|
||||||
|
])
|
||||||
|
def test_signed_recovery_reader_accepts_only_typed_worker_outcome_fields(
|
||||||
|
controller, monkeypatch, tmp_path, status, extra, valid,
|
||||||
|
):
|
||||||
|
record = _recovery_delivery(controller, secret="sentinel")
|
||||||
|
record.pop("runtimeEnvironment")
|
||||||
|
record.pop("privateCredential")
|
||||||
|
record.update(status=status, **extra)
|
||||||
|
secret = b"fixture-recovery-signing-key"
|
||||||
|
journal = {
|
||||||
|
"recoveryId": record["recovery_id"], "revision": record["revision"],
|
||||||
|
"idempotencyKeySha256": record["idempotency_key_sha256"],
|
||||||
|
"preflightSha256": record["preflight_sha256"],
|
||||||
|
"semanticSha256": record["semantic_sha256"],
|
||||||
|
"originalRawSha256": record["recovery_of_sha256"],
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"operationId": record["semantic_sha256"], "revision": record["revision"],
|
||||||
|
"preflightSha256": record["preflight_sha256"],
|
||||||
|
"idempotencyKeySha256": record["idempotency_key_sha256"],
|
||||||
|
"recoveryId": record["recovery_id"],
|
||||||
|
"recoveryOfSha256": record["recovery_of_sha256"],
|
||||||
|
}
|
||||||
|
record["body_sha256"] = controller.hashlib.sha256(controller._self_upgrade_canonical_json({
|
||||||
|
"semanticSha256": record["semantic_sha256"], "recoveryId": record["recovery_id"],
|
||||||
|
"idempotencyKeySha256": record["idempotency_key_sha256"],
|
||||||
|
})).hexdigest()
|
||||||
|
record["recovery_hmac"] = controller._self_upgrade_public_recovery_signature(record, secret)
|
||||||
|
monkeypatch.setattr(controller, "_self_upgrade_secret", lambda: secret)
|
||||||
|
monkeypatch.setattr(controller, "_self_upgrade_recovery_journal", lambda _id: journal)
|
||||||
|
monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {}})
|
||||||
|
monkeypatch.setattr(controller, "_validation_policy", lambda *_a: {})
|
||||||
|
monkeypatch.setattr(controller, "_validation_snapshot_matches", lambda *_a: True)
|
||||||
|
monkeypatch.setattr(controller, "_self_upgrade_request_root", lambda **_k: tmp_path)
|
||||||
|
monkeypatch.setattr(controller, "_self_upgrade_read_private_json", lambda _path: (b"", {}))
|
||||||
|
monkeypatch.setattr(controller, "_self_upgrade_recovery_request_payload", lambda _record: payload)
|
||||||
|
|
||||||
|
def verify():
|
||||||
|
return controller._self_upgrade_recovery_record_valid(
|
||||||
|
record, semantic_digest=record["semantic_sha256"],
|
||||||
|
original_raw_sha256=record["recovery_of_sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert verify() is valid
|
||||||
|
record["updated_at"] += 1
|
||||||
|
assert verify() is False # Even permitted outcome fields require the original complete HMAC.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("uid", "gid", "mode"), [(99, 100, 0o777), (0, 0, 0o755)])
|
||||||
|
@pytest.mark.parametrize("drift", ["none", "owner", "mode", "symlink", "foreign_cache", "inode"])
|
||||||
|
def test_legacy_cache_reader_and_state_cas_bind_both_exact_appdata_profiles(
|
||||||
|
controller, monkeypatch, tmp_path, uid, gid, mode, drift,
|
||||||
|
):
|
||||||
|
if os.name == "nt" or os.geteuid() != 0:
|
||||||
|
pytest.skip("Pinned dirfd ownership contract requires root POSIX")
|
||||||
|
root = tmp_path / "virtual-host"
|
||||||
|
root.mkdir(mode=0o755)
|
||||||
|
dirs = [
|
||||||
|
("mnt", 0, 0, 0o755), ("mnt/cache", 99, 100, 0o777),
|
||||||
|
("mnt/cache/appdata", uid, gid, mode),
|
||||||
|
("mnt/cache/appdata/gitea-deploy-runners", 0, 0, 0o755),
|
||||||
|
("mnt/cache/appdata/gitea-deploy-runners/state", 0, 0, 0o755),
|
||||||
|
]
|
||||||
|
for name, owner, group, permissions in dirs:
|
||||||
|
path = root / name
|
||||||
|
path.mkdir()
|
||||||
|
os.chown(path, owner, group)
|
||||||
|
path.chmod(permissions)
|
||||||
|
appdata = root / "mnt/cache/appdata"
|
||||||
|
state_root = appdata / "gitea-deploy-runners/state"
|
||||||
|
state_path = state_root / "unraid-appops-gateway.json"
|
||||||
|
before = controller._self_upgrade_canonical_json({
|
||||||
|
"schema_version": 3, "repository": controller.SELF_UPGRADE_REPOSITORY, "marker": "old",
|
||||||
|
}) + b"\n"
|
||||||
|
state_path.write_bytes(before)
|
||||||
|
state_path.chmod(0o600)
|
||||||
|
after = before.replace(b'"old"', b'"new"')
|
||||||
|
if drift == "owner":
|
||||||
|
os.chown(appdata, 1000, 1000)
|
||||||
|
elif drift == "mode":
|
||||||
|
appdata.chmod(0o775)
|
||||||
|
elif drift == "foreign_cache":
|
||||||
|
os.chown(root / "mnt/cache", 0, 0)
|
||||||
|
(root / "mnt/cache").chmod(0o755)
|
||||||
|
elif drift == "inode":
|
||||||
|
replacement = appdata.with_name("different-inode")
|
||||||
|
replacement.mkdir()
|
||||||
|
os.chown(replacement, uid, gid)
|
||||||
|
replacement.chmod(mode)
|
||||||
|
elif drift == "symlink":
|
||||||
|
renamed = appdata.with_name("appdata-elsewhere")
|
||||||
|
appdata.rename(renamed)
|
||||||
|
appdata.symlink_to(renamed, target_is_directory=True)
|
||||||
|
monkeypatch.setattr(controller, "STATE_ROOT", Path("/mnt/cache/appdata/gitea-deploy-runners/state"))
|
||||||
|
real_open = os.open
|
||||||
|
|
||||||
|
def virtual_root_open(path, flags, *args, **kwargs):
|
||||||
|
if drift == "inode" and str(path) == "appdata":
|
||||||
|
return real_open(replacement, flags)
|
||||||
|
return real_open(root if str(path) == "/" else path, flags, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(controller.os, "open", virtual_root_open)
|
||||||
|
requested = controller.STATE_ROOT / state_path.name
|
||||||
|
if drift == "none":
|
||||||
|
assert controller._self_upgrade_read_legacy_cache_file(
|
||||||
|
requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}),
|
||||||
|
) == before
|
||||||
|
assert not controller._self_upgrade_write_legacy_state_cas("0" * 64, after)
|
||||||
|
assert state_path.read_bytes() == before
|
||||||
|
assert controller._self_upgrade_write_legacy_state_cas(
|
||||||
|
controller.hashlib.sha256(before).hexdigest(), after,
|
||||||
|
)
|
||||||
|
assert state_path.read_bytes() == after
|
||||||
|
else:
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
controller._self_upgrade_read_legacy_cache_file(
|
||||||
|
requested, maximum_bytes=65536, allowed_modes=frozenset({0o600}),
|
||||||
|
)
|
||||||
|
assert not controller._self_upgrade_write_legacy_state_cas(
|
||||||
|
controller.hashlib.sha256(before).hexdigest(), after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("profile", "valid"), [
|
||||||
|
((99, 100, 0o777), True), ((0, 0, 0o755), True),
|
||||||
|
((0, 0, 0o775), False), ((99, 100, 0o755), False),
|
||||||
|
])
|
||||||
|
def test_source_root_allows_only_legacy_or_root_owned_appdata_anchor(
|
||||||
|
controller, monkeypatch, profile, valid,
|
||||||
|
):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
root = Path("/srv/example/appdata/unraid-appops-gateway/source")
|
||||||
|
profiles = {
|
||||||
|
Path("/mnt"): (0, 0, 0o755), Path("/mnt/user"): (99, 100, 0o777),
|
||||||
|
Path("/srv/example/appdata"): profile,
|
||||||
|
root.parent: (0, 0, 0o755), root: (100, 101, 0o755),
|
||||||
|
}
|
||||||
|
|
||||||
|
def lstat(path):
|
||||||
|
uid, gid, mode = profiles[path]
|
||||||
|
return SimpleNamespace(st_uid=uid, st_gid=gid, st_mode=stat.S_IFDIR | mode)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "lstat", lstat)
|
||||||
|
monkeypatch.setattr(Path, "is_symlink", lambda _path: False)
|
||||||
|
assert controller._self_upgrade_source_root_safe(root) is valid
|
||||||
|
monkeypatch.setattr(Path, "is_symlink", lambda path: path == root.parent)
|
||||||
|
assert controller._self_upgrade_source_root_safe(root) is False
|
||||||
@@ -379,9 +379,7 @@ def test_appops_legacy_state_anchor_accepts_only_known_preimage_or_current_green
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model(
|
def _fixed_appops_legacy_runtime_case(deploy_module, tmp_path):
|
||||||
deploy_module, tmp_path
|
|
||||||
):
|
|
||||||
project = _project(tmp_path)
|
project = _project(tmp_path)
|
||||||
project.update({
|
project.update({
|
||||||
"branch": "main", "gitea_repository_id": 9,
|
"branch": "main", "gitea_repository_id": 9,
|
||||||
@@ -435,6 +433,15 @@ def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model(
|
|||||||
"Config": {"Labels": container["Config"]["Labels"]},
|
"Config": {"Labels": container["Config"]["Labels"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return project, container, image
|
||||||
|
|
||||||
|
|
||||||
|
def test_appops_legacy_runtime_requires_pinned_source_image_and_resolved_model(
|
||||||
|
deploy_module, tmp_path
|
||||||
|
):
|
||||||
|
project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path)
|
||||||
|
image_id = deploy_module.APPOPS_LEGACY_OLD_IMAGE_ID
|
||||||
|
source = "https://gitea.example.com/Jens/Unraid-AppOps-Gateway"
|
||||||
observed = deploy_module.verify_appops_legacy_runtime(project, container, image)
|
observed = deploy_module.verify_appops_legacy_runtime(project, container, image)
|
||||||
assert observed["imageId"] == image_id
|
assert observed["imageId"] == image_id
|
||||||
assert observed["imageRef"] == "unraid-appops-gateway:platform-5553c04ce497"
|
assert observed["imageRef"] == "unraid-appops-gateway:platform-5553c04ce497"
|
||||||
@@ -10604,3 +10611,67 @@ def test_managed_appdata_initializer_http_prerequisite_and_refusal_are_sanitized
|
|||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
serving.join(timeout=2)
|
serving.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("proof", ["signed", "missing", "foreign", "candidate"])
|
||||||
|
def test_recovery_verifies_restored_container_only_with_signed_live_555_model(
|
||||||
|
deploy_module, monkeypatch, tmp_path, proof,
|
||||||
|
):
|
||||||
|
project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path)
|
||||||
|
container["Id"] = "a" * 64
|
||||||
|
model_path = "/private/models/signed-555.compose.json"
|
||||||
|
container["Config"]["Labels"]["com.docker.compose.project.config_files"] = model_path
|
||||||
|
state = {"live_revision": deploy_module.APPOPS_LEGACY_OLD_REVISION}
|
||||||
|
active = {"legacy": True, "path": model_path}
|
||||||
|
if proof == "missing":
|
||||||
|
active = None
|
||||||
|
elif proof == "foreign":
|
||||||
|
active["path"] = "/private/models/foreign.compose.json"
|
||||||
|
elif proof == "candidate":
|
||||||
|
active["legacy"] = False
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def verify_active(actual_state, actual_container, *, project):
|
||||||
|
calls.append((actual_state, actual_container, project))
|
||||||
|
return active
|
||||||
|
|
||||||
|
reader = types.SimpleNamespace(_self_upgrade_active_compose_model=verify_active)
|
||||||
|
monkeypatch.setattr(deploy_module, "_appops_status_reader", lambda: reader)
|
||||||
|
with pytest.raises(deploy_module.DeployError, match="fixed AppOps legacy runtime"):
|
||||||
|
deploy_module.verify_appops_legacy_runtime(project, container, image)
|
||||||
|
if proof == "signed":
|
||||||
|
observed = deploy_module.verify_appops_legacy_runtime(
|
||||||
|
project, container, image, require_original_container=False, recovery_state=state,
|
||||||
|
)
|
||||||
|
assert observed["containerId"] == container["Id"]
|
||||||
|
else:
|
||||||
|
with pytest.raises(deploy_module.DeployError):
|
||||||
|
deploy_module.verify_appops_legacy_runtime(
|
||||||
|
project, container, image, require_original_container=False, recovery_state=state,
|
||||||
|
)
|
||||||
|
assert calls == [(state, container, project)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("drift", ["image", "ports", "mounts", "health"])
|
||||||
|
def test_signed_restored_555_proof_preserves_fixed_runtime_checks(
|
||||||
|
deploy_module, monkeypatch, tmp_path, drift,
|
||||||
|
):
|
||||||
|
project, container, image = _fixed_appops_legacy_runtime_case(deploy_module, tmp_path)
|
||||||
|
container["Id"] = "a" * 64
|
||||||
|
active = {"legacy": True, "path": "/private/models/signed-555.compose.json"}
|
||||||
|
monkeypatch.setattr(deploy_module, "_appops_status_reader", lambda: types.SimpleNamespace(
|
||||||
|
_self_upgrade_active_compose_model=lambda *_a, **_k: active,
|
||||||
|
))
|
||||||
|
if drift == "image":
|
||||||
|
image["Id"] = "sha256:" + "0" * 64
|
||||||
|
elif drift == "ports":
|
||||||
|
container["NetworkSettings"]["Ports"]["1216/tcp"][0]["HostIp"] = "127.0.0.1"
|
||||||
|
elif drift == "mounts":
|
||||||
|
container["Mounts"][0]["RW"] = False
|
||||||
|
elif drift == "health":
|
||||||
|
container["State"]["Health"]["Status"] = "unhealthy"
|
||||||
|
with pytest.raises(deploy_module.DeployError):
|
||||||
|
deploy_module.verify_appops_legacy_runtime(
|
||||||
|
project, container, image, require_original_container=False, recovery_state={},
|
||||||
|
)
|
||||||
@@ -1369,3 +1369,44 @@ def test_controller_preflight_changed_identity_expires_approval_without_unknown_
|
|||||||
promotion = session.get(WorkMeshPromotion, promotion_id)
|
promotion = session.get(WorkMeshPromotion, promotion_id)
|
||||||
assert approval is not None and approval.status == "expired"
|
assert approval is not None and approval.status == "expired"
|
||||||
assert promotion is not None and promotion.status == "preflight_changed"
|
assert promotion is not None and promotion.status == "preflight_changed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("name", "argument", "discriminator"), [
|
||||||
|
("enrollment.preflight", "plan", None),
|
||||||
|
("target.enroll", "plan", None),
|
||||||
|
("deployment.preflight", "plan", None),
|
||||||
|
("deployment.apply", "plan", None),
|
||||||
|
("deployment.rollback", "plan", None),
|
||||||
|
("deployment.preflight", "plan", "itworx.forge-bootstrap-preflight/v1"),
|
||||||
|
("forge.bootstrap.apply", "profile", None),
|
||||||
|
])
|
||||||
|
def test_mcp_typed_schema_rejection_never_echoes_opaque_input(
|
||||||
|
integration_client, monkeypatch, name, argument, discriminator,
|
||||||
|
):
|
||||||
|
from backend.app import mcp_server
|
||||||
|
canary = "syntheticOpaqueInput_9Z7XQ"
|
||||||
|
payload = {"api_key": canary, canary: {"nested": canary}, "application_id": canary}
|
||||||
|
if discriminator:
|
||||||
|
payload["api_version"] = discriminator
|
||||||
|
arguments = {argument: payload}
|
||||||
|
if name != "enrollment.preflight":
|
||||||
|
arguments["idempotencyKey"] = "schema-redaction-fixture"
|
||||||
|
|
||||||
|
def forbidden_service(*_args, **_kwargs):
|
||||||
|
pytest.fail("invalid schema must not reach deployment/enrollment services")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_server, "EnrollmentPlane", forbidden_service)
|
||||||
|
monkeypatch.setattr(mcp_server, "DeploymentControlPlane", forbidden_service)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
async with Client(mcp_server.appops_mcp) as client:
|
||||||
|
result = await client.call_tool(name, arguments)
|
||||||
|
assert result.is_error is False
|
||||||
|
assert result.structured_content == {
|
||||||
|
"status": "invalid", "executed": False, "reason": "invalid_typed_plan",
|
||||||
|
}
|
||||||
|
assert canary not in json.dumps(result.model_dump(mode="json"))
|
||||||
|
|
||||||
|
asyncio.run(exercise())
|
||||||
|
assert mcp_server._MCP_EXECUTION_LOCK.locked() is False
|
||||||
Reference in new issue
Block a user