Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,630 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Barrier, Event, Thread
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import backend.app.services.privatemuse_deployment_recovery as recovery
|
||||
from backend.app.db.models import JobRun
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakePortainer:
|
||||
def __init__(self, exit_code=0):
|
||||
self.exit_code = exit_code
|
||||
self.calls = []
|
||||
|
||||
def get_container_status(self, name):
|
||||
assert name == "gitea-deploy-control"
|
||||
return {
|
||||
"status": "ok",
|
||||
"container": {
|
||||
"id": "controller-id",
|
||||
"name": "gitea-deploy-control",
|
||||
"state": "running",
|
||||
},
|
||||
}
|
||||
|
||||
def _config(self):
|
||||
return SimpleNamespace(endpoint_id="3")
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
self.calls.append((method, path, kwargs))
|
||||
if path.endswith("/containers/controller-id/exec"):
|
||||
return FakeResponse({"Id": "exec-1"})
|
||||
if path.endswith("/exec/exec-1/start"):
|
||||
return FakeResponse({})
|
||||
if path.endswith("/exec/exec-1/json"):
|
||||
return FakeResponse(
|
||||
{
|
||||
"ID": "exec-1",
|
||||
"ContainerID": "controller-id",
|
||||
"Running": False,
|
||||
"ExitCode": self.exit_code,
|
||||
}
|
||||
)
|
||||
raise AssertionError(path)
|
||||
|
||||
|
||||
def recovery_job(job_id):
|
||||
return JobRun(
|
||||
id=job_id,
|
||||
type="deployment.target.register_privatemuse",
|
||||
status="queued",
|
||||
source="chatgpt-mcp",
|
||||
action_id="deployment.target.register_privatemuse",
|
||||
target="jens/privatemuse",
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_runs_only_the_exact_observed_privatemuse_incident():
|
||||
fake = FakePortainer()
|
||||
|
||||
result = recovery.PrivateMuseDeploymentRecovery(fake).run()
|
||||
|
||||
assert result == {
|
||||
"status": "reconciled",
|
||||
"repository": "jens/privatemuse",
|
||||
"live_revision": "2103d5795d151885edeeb964466b643b8fd4a0eb",
|
||||
"failed_revision": "6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38",
|
||||
"containers": [
|
||||
"privatemuse-backend-1",
|
||||
"privatemuse-frontend-1",
|
||||
"privatemuse-audio-enhancement-1",
|
||||
],
|
||||
"compose_project": "privatemuse",
|
||||
}
|
||||
command = fake.calls[0][2]["json"]["Cmd"]
|
||||
assert command[:3] == ["python3", "-c", recovery._REATTEST_SCRIPT]
|
||||
assert command[3:] == [
|
||||
"jens/privatemuse",
|
||||
"2103d5795d151885edeeb964466b643b8fd4a0eb",
|
||||
"6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38",
|
||||
"Existing production containers require a valid healthy live deployment state",
|
||||
]
|
||||
|
||||
|
||||
def test_recovery_script_allows_only_the_two_observed_container_id_changes():
|
||||
script = recovery._REATTEST_SCRIPT
|
||||
|
||||
assert (
|
||||
'allowed_changed_ids = {"privatemuse-backend-1", "privatemuse-frontend-1"}'
|
||||
in script
|
||||
)
|
||||
assert "changed_receipt_ids != allowed_changed_ids" in script
|
||||
assert "changed_artifact_ids != allowed_changed_ids" in script
|
||||
assert 'state.get("revision") == live_revision' in script
|
||||
assert 'state.get("live_revision") == live_revision' in script
|
||||
assert 'state.get("live_status") == "unknown"' in script
|
||||
assert 'attempt.get("revision") == failed_revision' in script
|
||||
assert 'attempt.get("message") == failure_message' in script
|
||||
|
||||
|
||||
def test_recovery_reuses_controller_verifiers_and_writes_state_durably():
|
||||
script = recovery._REATTEST_SCRIPT
|
||||
|
||||
assert "controller.validate_recorded_receipt" in script
|
||||
assert "controller.verify_deployment" in script
|
||||
assert "expected_image_ids=expected_image_ids" in script
|
||||
assert "controller.deployment_artifacts" in script
|
||||
assert "controller.verified_current_receipt" in script
|
||||
assert "write_exclusive(backup, original_state, state_metadata)" in script
|
||||
assert "os.replace(temporary, state_path)" in script
|
||||
assert "os.fsync" in script
|
||||
|
||||
|
||||
def test_recovery_durably_preserves_the_original_state_before_replacement():
|
||||
script = recovery._REATTEST_SCRIPT
|
||||
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/deploy.lock" in script
|
||||
assert "original_state = state_path.read_bytes()" in script
|
||||
assert "write_exclusive(backup, original_state, state_metadata)" in script
|
||||
assert "os.fchmod(handle.fileno(), stat.S_IMODE(metadata.st_mode))" in script
|
||||
assert "os.fchown(handle.fileno(), metadata.st_uid, metadata.st_gid)" in script
|
||||
assert "fsync_directory(state_root)" in script
|
||||
|
||||
|
||||
def test_recovery_restores_original_state_when_post_write_verification_fails():
|
||||
script = recovery._REATTEST_SCRIPT
|
||||
|
||||
assert "def restore_original_state():" in script
|
||||
assert (
|
||||
"write_exclusive(restore_temporary, original_state, state_metadata)" in script
|
||||
)
|
||||
assert "os.replace(restore_temporary, state_path)" in script
|
||||
assert "return state_path.read_bytes() == original_state" in script
|
||||
assert "if not restore_original_state():" in script
|
||||
assert "raise SystemExit(45)" in script
|
||||
|
||||
|
||||
def test_recovery_restores_original_state_when_post_replace_durability_fails():
|
||||
script = recovery._REATTEST_SCRIPT
|
||||
|
||||
assert "state_replaced = False" in script
|
||||
assert "state_replaced = True" in script
|
||||
assert "if state_replaced and not restore_original_state():" in script
|
||||
|
||||
|
||||
def test_recovery_script_has_no_application_runtime_mutation_commands():
|
||||
forbidden = (
|
||||
"docker restart",
|
||||
"docker stop",
|
||||
"docker rm",
|
||||
"docker compose up",
|
||||
"docker compose down",
|
||||
'subprocess.run(["docker", "restart"',
|
||||
'subprocess.run(["docker", "rm"',
|
||||
)
|
||||
|
||||
assert all(value not in recovery._REATTEST_SCRIPT for value in forbidden)
|
||||
|
||||
|
||||
def test_recovery_reports_verified_healthy_state_without_enrollment_fallback():
|
||||
fake = FakePortainer(exit_code=35)
|
||||
|
||||
result = recovery.PrivateMuseDeploymentRecovery(fake).run()
|
||||
|
||||
assert result == {
|
||||
"status": "already-enrolled",
|
||||
"repository": "jens/privatemuse",
|
||||
}
|
||||
assert "PrivateMuseDeploymentEnrollment" not in Path(
|
||||
"backend/app/services/privatemuse_deployment_recovery.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_recovery_fails_closed_when_controller_is_unavailable():
|
||||
class UnavailablePortainer(FakePortainer):
|
||||
def get_container_status(self, name):
|
||||
assert name == "gitea-deploy-control"
|
||||
return {"status": "unavailable", "reason": "offline"}
|
||||
|
||||
def _config(self):
|
||||
raise AssertionError("configuration must not be read")
|
||||
|
||||
result = recovery.PrivateMuseDeploymentRecovery(UnavailablePortainer()).run()
|
||||
|
||||
assert result == {"status": "controller_unavailable", "reason": "offline"}
|
||||
|
||||
|
||||
def test_recovery_waits_beyond_controller_health_timeout_and_marks_timeout_uncertain(
|
||||
monkeypatch,
|
||||
):
|
||||
fake = FakePortainer()
|
||||
fake._request = lambda method, path, **kwargs: (
|
||||
FakeResponse({"Id": "exec-1"})
|
||||
if path.endswith("/containers/controller-id/exec")
|
||||
else FakeResponse({})
|
||||
if path.endswith("/exec/exec-1/start")
|
||||
else FakeResponse({"Running": True, "ExitCode": None})
|
||||
)
|
||||
monotonic_values = iter((0.0, 301.0))
|
||||
monkeypatch.setattr(recovery.time, "monotonic", lambda: next(monotonic_values))
|
||||
|
||||
result = recovery.PrivateMuseDeploymentRecovery(fake).run()
|
||||
|
||||
assert result == {
|
||||
"status": "outcome_unknown",
|
||||
"reason": (
|
||||
"PrivateMuse runtime re-attestation is still running; inspect the "
|
||||
"existing execution and do not retry"
|
||||
),
|
||||
"execution_id": "exec-1",
|
||||
}
|
||||
source = Path("backend/app/services/privatemuse_deployment_recovery.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "deadline = time.monotonic() + 300" in source
|
||||
|
||||
|
||||
def test_recovery_marks_connector_failure_after_exec_start_as_uncertain():
|
||||
class InspectFailurePortainer(FakePortainer):
|
||||
def _request(self, method, path, **kwargs):
|
||||
if path.endswith("/containers/controller-id/exec"):
|
||||
return FakeResponse({"Id": "exec-uncertain"})
|
||||
if path.endswith("/exec/exec-uncertain/start"):
|
||||
return FakeResponse({})
|
||||
if path.endswith("/exec/exec-uncertain/json"):
|
||||
raise RuntimeError("connection lost")
|
||||
raise AssertionError(path)
|
||||
|
||||
result = recovery.PrivateMuseDeploymentRecovery(InspectFailurePortainer()).run()
|
||||
|
||||
assert result == {
|
||||
"status": "outcome_unknown",
|
||||
"reason": (
|
||||
"PrivateMuse runtime re-attestation outcome is unknown after execution "
|
||||
"start; inspect the existing execution and do not retry"
|
||||
),
|
||||
"execution_id": "exec-uncertain",
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_reconciles_durable_unknown_exec_without_starting_another(
|
||||
tmp_path,
|
||||
):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'recovery.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
class FirstAttemptPortainer(FakePortainer):
|
||||
def _request(self, method, path, **kwargs):
|
||||
self.calls.append((method, path, kwargs))
|
||||
if path.endswith("/containers/controller-id/exec"):
|
||||
return FakeResponse({"Id": "exec-durable"})
|
||||
if path.endswith("/exec/exec-durable/start"):
|
||||
return FakeResponse({})
|
||||
if path.endswith("/exec/exec-durable/json"):
|
||||
raise RuntimeError("connection lost")
|
||||
raise AssertionError(path)
|
||||
|
||||
first = FirstAttemptPortainer()
|
||||
with Session(engine) as session:
|
||||
_tracked, admitted = recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
session, recovery_job("job-original")
|
||||
)
|
||||
assert admitted is True
|
||||
uncertain = recovery.PrivateMuseDeploymentRecovery(first).run(
|
||||
session=session,
|
||||
job_id="job-original",
|
||||
)
|
||||
|
||||
assert uncertain["status"] == "outcome_unknown"
|
||||
assert uncertain["execution_id"] == "exec-durable"
|
||||
assert uncertain["original_job_id"] == "job-original"
|
||||
|
||||
class ReconcilePortainer(FakePortainer):
|
||||
def _request(self, method, path, **kwargs):
|
||||
self.calls.append((method, path, kwargs))
|
||||
assert method == "GET"
|
||||
assert path.endswith("/exec/exec-durable/json")
|
||||
return FakeResponse(
|
||||
{
|
||||
"ID": "exec-durable",
|
||||
"ContainerID": "controller-id",
|
||||
"Running": False,
|
||||
"ExitCode": 0,
|
||||
}
|
||||
)
|
||||
|
||||
reconciler = ReconcilePortainer()
|
||||
with Session(engine) as session:
|
||||
reconciled = recovery.PrivateMuseDeploymentRecovery(reconciler).run(
|
||||
session=session,
|
||||
job_id="job-fresh-key",
|
||||
)
|
||||
|
||||
assert reconciled["status"] == "reconciled"
|
||||
assert reconciled["execution_id"] == "exec-durable"
|
||||
assert reconciled["original_job_id"] == "job-original"
|
||||
assert [method for method, _path, _kwargs in reconciler.calls] == ["GET"]
|
||||
|
||||
|
||||
def test_recovery_keeps_unknown_exec_guard_when_controller_identity_changed(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'identity.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
recovery.AppSetting(
|
||||
key=recovery._RECOVERY_STATE_SETTING,
|
||||
value_json={
|
||||
"state": "outcome_unknown",
|
||||
"executionId": "exec-existing",
|
||||
"controllerId": "old-controller",
|
||||
"jobId": "job-original",
|
||||
},
|
||||
is_secret=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
portainer = FakePortainer()
|
||||
with Session(engine) as session:
|
||||
result = recovery.PrivateMuseDeploymentRecovery(portainer).run(
|
||||
session=session,
|
||||
job_id="job-fresh-key",
|
||||
)
|
||||
|
||||
assert result["status"] == "outcome_unknown"
|
||||
assert result["execution_id"] == "exec-existing"
|
||||
assert result["original_job_id"] == "job-original"
|
||||
assert "controller identity changed" in result["reason"]
|
||||
assert portainer.calls == []
|
||||
|
||||
|
||||
def test_recovery_admission_creates_only_the_winning_job(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'admission.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
with Session(engine) as first_session:
|
||||
first_state, first_admitted = recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
first_session, recovery_job("job-winner")
|
||||
)
|
||||
with Session(engine) as second_session:
|
||||
second_state, second_admitted = (
|
||||
recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
second_session, recovery_job("job-loser")
|
||||
)
|
||||
)
|
||||
jobs = second_session.exec(select(JobRun)).all()
|
||||
|
||||
assert first_admitted is True
|
||||
assert second_admitted is False
|
||||
assert first_state["jobId"] == "job-winner"
|
||||
assert second_state["jobId"] == "job-winner"
|
||||
assert [job.id for job in jobs] == ["job-winner"]
|
||||
|
||||
|
||||
def test_recovery_admission_cas_uses_generation_when_timestamps_collide(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
fixed = datetime(2026, 9, 20, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None)
|
||||
monkeypatch.setattr(recovery, "utc_now", lambda: fixed)
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'admission-generation.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
recovery.AppSetting(
|
||||
key=recovery._RECOVERY_STATE_SETTING,
|
||||
value_json={"state": "idle", "version": 0},
|
||||
is_secret=False,
|
||||
updated_at=fixed,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
update_barrier = Barrier(2)
|
||||
|
||||
def align_cas_updates(_conn, _cursor, statement, _parameters, _context, _many):
|
||||
if statement.startswith("UPDATE app_settings SET"):
|
||||
update_barrier.wait(timeout=10)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", align_cas_updates)
|
||||
admissions = []
|
||||
|
||||
def admit(job_id):
|
||||
with Session(engine) as session:
|
||||
_state, admitted = recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
session, recovery_job(job_id)
|
||||
)
|
||||
admissions.append((job_id, admitted))
|
||||
|
||||
workers = [Thread(target=admit, args=(f"job-{index}",)) for index in range(2)]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
for worker in workers:
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive()
|
||||
event.remove(engine, "before_cursor_execute", align_cas_updates)
|
||||
|
||||
with Session(engine) as session:
|
||||
jobs = session.exec(select(JobRun)).all()
|
||||
state = session.get(recovery.AppSetting, recovery._RECOVERY_STATE_SETTING)
|
||||
|
||||
assert sorted(admitted for _job_id, admitted in admissions) == [False, True]
|
||||
assert len(jobs) == 1
|
||||
assert state is not None
|
||||
assert state.value_json["jobId"] == jobs[0].id
|
||||
assert state.value_json["version"] == 1
|
||||
|
||||
|
||||
def test_reserved_recovery_can_only_be_started_by_its_admitted_job(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'owner.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
class NoPreflightPortainer(FakePortainer):
|
||||
def get_container_status(self, name):
|
||||
raise AssertionError("a losing caller must not reach Portainer preflight")
|
||||
|
||||
with Session(engine) as session:
|
||||
recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
session, recovery_job("job-owner")
|
||||
)
|
||||
result = recovery.PrivateMuseDeploymentRecovery(NoPreflightPortainer()).run(
|
||||
session=session,
|
||||
job_id="job-loser",
|
||||
)
|
||||
|
||||
assert result["status"] == "in_progress"
|
||||
assert result["original_job_id"] == "job-owner"
|
||||
assert "no second execution was started" in result["reason"]
|
||||
|
||||
|
||||
def test_terminal_reconciliation_cannot_regress_to_late_unknown(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'monotonic.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
recovery.PrivateMuseDeploymentRecovery.admit_job(
|
||||
session, recovery_job("job-race")
|
||||
)
|
||||
|
||||
inspect_started = Event()
|
||||
reconciliation_finished = Event()
|
||||
worker_result = {}
|
||||
|
||||
class DelayedWorkerPortainer(FakePortainer):
|
||||
def _request(self, method, path, **kwargs):
|
||||
if path.endswith("/containers/controller-id/exec"):
|
||||
return FakeResponse({"Id": "exec-race"})
|
||||
if path.endswith("/exec/exec-race/start"):
|
||||
return FakeResponse({})
|
||||
if path.endswith("/exec/exec-race/json"):
|
||||
inspect_started.set()
|
||||
assert reconciliation_finished.wait(timeout=10)
|
||||
raise RuntimeError("late connection loss")
|
||||
raise AssertionError(path)
|
||||
|
||||
def run_delayed_worker():
|
||||
with Session(engine) as worker_session:
|
||||
worker_result.update(
|
||||
recovery.PrivateMuseDeploymentRecovery(
|
||||
DelayedWorkerPortainer()
|
||||
).run(session=worker_session, job_id="job-race")
|
||||
)
|
||||
|
||||
worker = Thread(target=run_delayed_worker)
|
||||
worker.start()
|
||||
assert inspect_started.wait(timeout=10)
|
||||
|
||||
class TerminalInspectPortainer(FakePortainer):
|
||||
def _request(self, method, path, **kwargs):
|
||||
assert method == "GET"
|
||||
assert path.endswith("/exec/exec-race/json")
|
||||
return FakeResponse(
|
||||
{
|
||||
"ID": "exec-race",
|
||||
"ContainerID": "controller-id",
|
||||
"Running": False,
|
||||
"ExitCode": 0,
|
||||
}
|
||||
)
|
||||
|
||||
with Session(engine) as reconcile_session:
|
||||
reconciled = recovery.PrivateMuseDeploymentRecovery(
|
||||
TerminalInspectPortainer()
|
||||
).run(session=reconcile_session, job_id="job-fresh-key")
|
||||
reconciliation_finished.set()
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive()
|
||||
|
||||
with Session(engine) as verification_session:
|
||||
row = verification_session.get(
|
||||
recovery.AppSetting, recovery._RECOVERY_STATE_SETTING
|
||||
)
|
||||
|
||||
assert reconciled["status"] == "reconciled"
|
||||
assert worker_result["status"] == "reconciled"
|
||||
assert row is not None
|
||||
assert row.value_json["state"] == "completed"
|
||||
assert row.value_json["result"]["status"] == "reconciled"
|
||||
|
||||
|
||||
def test_recovery_state_cas_prevents_same_timestamp_terminal_regression(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
fixed = datetime(2026, 9, 20, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None)
|
||||
monkeypatch.setattr(recovery, "utc_now", lambda: fixed)
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'terminal-generation.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
tracked = {
|
||||
"state": "started",
|
||||
"claimId": "claim-race",
|
||||
"jobId": "job-race",
|
||||
"controllerId": "controller-id",
|
||||
"executionId": "exec-race",
|
||||
"version": 7,
|
||||
}
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
recovery.AppSetting(
|
||||
key=recovery._RECOVERY_STATE_SETTING,
|
||||
value_json=tracked,
|
||||
is_secret=False,
|
||||
updated_at=fixed,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
completed_at_update = Event()
|
||||
unknown_at_update = Event()
|
||||
completed_finished = Event()
|
||||
|
||||
def order_cas_updates(_conn, _cursor, statement, parameters, _context, _many):
|
||||
if not statement.startswith("UPDATE app_settings SET"):
|
||||
return
|
||||
payload = str(parameters[0])
|
||||
if '"state": "completed"' in payload:
|
||||
completed_at_update.set()
|
||||
assert unknown_at_update.wait(timeout=10)
|
||||
elif '"state": "outcome_unknown"' in payload:
|
||||
unknown_at_update.set()
|
||||
assert completed_at_update.wait(timeout=10)
|
||||
assert completed_finished.wait(timeout=10)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", order_cas_updates)
|
||||
results = {}
|
||||
|
||||
def persist_completed():
|
||||
with Session(engine) as session:
|
||||
results["completed"] = recovery.PrivateMuseDeploymentRecovery._persist_state(
|
||||
session,
|
||||
dict(tracked),
|
||||
state="completed",
|
||||
execution_id="exec-race",
|
||||
result={"status": "reconciled"},
|
||||
)
|
||||
completed_finished.set()
|
||||
|
||||
def persist_unknown():
|
||||
with Session(engine) as session:
|
||||
results["unknown"] = recovery.PrivateMuseDeploymentRecovery._persist_state(
|
||||
session,
|
||||
dict(tracked),
|
||||
state="outcome_unknown",
|
||||
execution_id="exec-race",
|
||||
result={"status": "outcome_unknown"},
|
||||
)
|
||||
|
||||
completed_worker = Thread(target=persist_completed)
|
||||
unknown_worker = Thread(target=persist_unknown)
|
||||
completed_worker.start()
|
||||
unknown_worker.start()
|
||||
completed_worker.join(timeout=10)
|
||||
unknown_worker.join(timeout=10)
|
||||
assert not completed_worker.is_alive()
|
||||
assert not unknown_worker.is_alive()
|
||||
event.remove(engine, "before_cursor_execute", order_cas_updates)
|
||||
|
||||
with Session(engine) as session:
|
||||
state = session.get(recovery.AppSetting, recovery._RECOVERY_STATE_SETTING)
|
||||
|
||||
assert results["completed"]["state"] == "completed"
|
||||
assert results["unknown"]["state"] == "completed"
|
||||
assert state is not None
|
||||
assert state.value_json["state"] == "completed"
|
||||
assert state.value_json["result"]["status"] == "reconciled"
|
||||
assert state.value_json["version"] == 8
|
||||
|
||||
|
||||
def test_recovery_fails_closed_when_runtime_provenance_differs():
|
||||
result = recovery.PrivateMuseDeploymentRecovery(FakePortainer(exit_code=38)).run()
|
||||
|
||||
assert result == {
|
||||
"status": "validation_failed",
|
||||
"reason": "PrivateMuse immutable container/image provenance differs from the recorded receipt",
|
||||
}
|
||||
|
||||
|
||||
def test_privatemuse_action_execution_accepts_reconciled_status():
|
||||
source = Path("backend/app/services/action_execution.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert (
|
||||
"from .privatemuse_deployment_recovery import PrivateMuseDeploymentRecovery"
|
||||
in source
|
||||
)
|
||||
assert "tracked, admitted = recovery.admit_job(session, candidate_job)" in source
|
||||
assert "job_id=job.id if admitted else candidate_job.id" in source
|
||||
assert 'status in {"enrolled", "already-enrolled", "reconciled"}' in source
|
||||
Reference in new issue
Block a user