Files
Unraid-AppOps-Gateway-Public/backend/tests/test_repopulse_adoption_runner.py

632 lines
24 KiB
Python

from __future__ import annotations
import hashlib
import importlib
import json
import pytest
from sqlmodel import Session, SQLModel, create_engine
from backend.app.core.config import settings
from backend.app.db.models import AppSetting
from backend.app.schemas.enrollment import EnrollmentPlanV1
from backend.app.services import repopulse_adoption_journal as journal
from backend.app.services import repopulse_legacy_adoption_protocol as protocol
from backend.app.services.secret_store import set_secret
def _sha(char: str) -> str:
return char * 64
def _passing_preflight() -> dict:
check_codes = (
"legacy_registry",
"controller_state",
"gitea_ancestry",
"runtime_identity_and_health",
"legacy_storage_topology",
"legacy_appdata",
"product_api",
"database_health",
"queue_quiescence",
"database_baseline",
)
return {
"apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1",
"repository": "jens/repopulse",
"status": "preflight_passed",
"legacyCompatibility": {
"status": "passed",
"checks": [
{"code": code, "status": "passed", "gate": "legacy_compatibility"}
for code in check_codes
],
},
"targetAcceptance": {"status": "blocked", "checks": []},
"readOnly": True,
"adoptionAuthorized": False,
"evidence": {
"main_revision": "a" * 40,
"live_revision": "b" * 40,
"container_id_sha256": _sha("d"),
"container_image_sha256": _sha("1"),
"container_state": "running",
"container_health": "healthy",
"queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0},
"database": {
"database_bytes": 44_576_768,
"journal_bytes": 0,
"scan_count": 572,
"compacted_scan_count": 96,
},
},
}
def _plan_sha(revision: str) -> str:
plan = EnrollmentPlanV1.model_validate(
{
"application_id": "repopulse",
"display_name": "RepoPulse",
"environment": "production",
"repository": "Jens/RepoPulse",
"revision": revision,
"profile": "repopulse-authenticated",
"target": {
"target_identity": "unraid-controller",
"container_name": "repopulse",
},
"storage": {"runtime_uid": 10001, "runtime_gid": 10001},
"network": {"docker_network": "itworx-mcp-hub-npm", "internal_port": 8000},
"artifact": {
"release_version": "1.5.0",
"image_repository": "repopulse-managed",
"image_tag": "production",
"health_path": "/health",
},
"exposure": {
"hostname": "repopulse.example.com",
"upstream_service": "repopulse",
},
"secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]},
}
).model_dump(mode="json")
encoded = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _document_sha(value: dict) -> str:
encoded = (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
return hashlib.sha256(encoded).hexdigest()
def _completion_sha(value: dict) -> str:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
return hashlib.sha256(encoded).hexdigest()
def _controller_result(intent_id: str, binding: str) -> dict:
plan_sha = _plan_sha("a" * 40)
gates = {
"profileId": "repopulse-authenticated",
"profileVersion": "enrollment-profile/v1",
"planSha256": plan_sha,
**{
key: True
for key in (
"profileReady",
"controllerTrustReady",
"controllerBootstrapSecretImported",
"typedEnrollmentTrustReady",
"targetSecretReady",
"exactPolicyReady",
"authenticatedRouteReady",
"accessListReady",
"mutationBoundaryReady",
"managedRuntimeReady",
"scanSourceMatches",
"scanMountReadOnly",
"scanPermissionPolicyPersistent",
"scanAccessForRuntimeIdentityReady",
"ownedDataTargetReady",
"managedEnvironmentReady",
"legacyEnvironmentRetained",
"maintenanceAuthReady",
"controllerAdoptionCapabilityReady",
"controllerRegistryWriterReady",
"controllerSecretWriterReady",
)
},
}
quiescence = {
"intentId": intent_id,
"globalDeployLockHeld": True,
"projectLockHeld": True,
"maintenanceModeActive": True,
"queueFrozen": True,
"queuedJobs": 0,
"activeJobs": 0,
"maintenanceModeAuthenticated": True,
"containerName": "repopulse",
"containerIdSha256": _sha("d"),
"containerImageSha256": _sha("1"),
"containerState": "exited",
"legacyVolume": "repopulse_data",
"controllerIntentDurable": True,
"controllerIntentBindingSha256": binding,
}
snapshot = {
"intentId": intent_id,
"globalDeployLockHeld": True,
"projectLockHeld": True,
"legacyContainerStopped": True,
"legacyVolume": "repopulse_data",
"legacyVolumeRetained": True,
"sqliteBackupMethod": "sqlite_online_backup",
"quickCheck": "ok",
"integrityCheck": "ok",
"sourceLogicalSha256": _sha("e"),
"snapshotLogicalSha256": _sha("e"),
"snapshotFileSha256": _sha("f"),
"sourceScanCount": 572,
"snapshotScanCount": 572,
"sourceCompactedScanCount": 96,
"snapshotCompactedScanCount": 96,
"sourceQueueSha256": _sha("1"),
"snapshotQueueSha256": _sha("1"),
"sourceAuxiliaryTreeSha256": _sha("3"),
"sourceAuxiliaryFileCount": 42,
"targetDataPathWasAbsent": True,
"snapshotReceiptDurable": True,
"legacyEnvironmentRetained": True,
"managedEnvironmentFilePrivate": True,
"controllerIntentBindingSha256": binding,
}
copied = {
"intentId": intent_id,
"snapshotFileSha256": _sha("f"),
"targetLogicalSha256": _sha("e"),
"targetScanCount": 572,
"targetCompactedScanCount": 96,
"targetQueueSha256": _sha("1"),
"sourceAuxiliaryTreeSha256": _sha("3"),
"targetAuxiliaryTreeSha256": _sha("3"),
"sourceAuxiliaryFileCount": 42,
"targetAuxiliaryFileCount": 42,
"targetQuickCheck": "ok",
"targetIntegrityCheck": "ok",
"targetDataUid": 10001,
"targetDataGid": 10001,
"targetRecoveryStatus": "healthy",
"managedDataMountMatches": True,
"copySourceIsVerifiedSnapshot": True,
"legacyVolumeRetained": True,
"legacyEnvironmentRetained": True,
"managedEnvironmentFilePrivate": True,
}
enrollment_id = f"enr-{intent_id[4:]}"
registry_receipt = {
"apiVersion": "itworx.repopulse-controller-adoption-store-receipt/v1",
"intentId": intent_id,
"repository": "jens/repopulse",
"applicationId": "repopulse",
"phase": "registry_committed",
"intentBindingSha256": binding,
"legacyVolumeRetained": True,
"intentDurable": True,
"managedEnrollmentId": enrollment_id,
"projectsSha256": _sha("2"),
"snapshotFileSha256": _sha("f"),
}
registry_receipt["receiptSha256"] = _document_sha(registry_receipt)
deployment_receipt = {
"repository": "jens/repopulse",
"revision": "a" * 40,
"containerRevision": "a" * 40,
"containerIdSha256": _sha("4"),
"deploymentReceiptSchema": "gitea-deploy-receipt.v1",
"deploymentReceiptSha256": _sha("5"),
"deploymentReceiptVerified": True,
"runtimeHealthy": True,
"publishedHostPorts": [],
"managedDataMountMatches": True,
"targetDataUid": 10001,
"targetDataGid": 10001,
"targetLogicalSha256": _sha("e"),
"targetScanCount": 572,
"targetCompactedScanCount": 96,
"targetQueueSha256": _sha("1"),
"targetAuxiliaryTreeSha256": _sha("3"),
"targetAuxiliaryFileCount": 42,
"targetQuickCheck": "ok",
"targetIntegrityCheck": "ok",
"targetRecoveryStatus": "healthy",
"targetQueuedJobs": 0,
"targetActiveJobs": 0,
"legacyVolume": "repopulse_data",
"legacyVolumeRetained": True,
"legacyEnvironmentRetained": True,
"managedEnvironmentFilePrivate": True,
"scanSourceMatches": True,
"scanMountReadOnly": True,
"scanPermissionPolicyPersistent": True,
"scanAccessForRuntimeIdentityReady": True,
"containerName": "repopulse",
"containerImageSha256": _sha("6"),
"snapshotFileSha256": _sha("f"),
}
completion = {
"apiVersion": "itworx.repopulse-controller-adoption-completion/v1",
"intentId": intent_id,
"intentBindingSha256": binding,
"controllerRegistrationReceiptSha256": registry_receipt["receiptSha256"],
"mainRevision": "a" * 40,
"deploymentReceiptSha256": deployment_receipt["deploymentReceiptSha256"],
"snapshotFileSha256": snapshot["snapshotFileSha256"],
"snapshotLogicalSha256": snapshot["snapshotLogicalSha256"],
"snapshotQueueSha256": snapshot["snapshotQueueSha256"],
"auxiliaryTreeSha256": copied["targetAuxiliaryTreeSha256"],
"containerIdSha256": deployment_receipt["containerIdSha256"],
"containerImageSha256": deployment_receipt["containerImageSha256"],
"legacyVolume": "repopulse_data",
"legacyVolumeRetained": True,
}
completion["completionSha256"] = _completion_sha(completion)
return {
"apiVersion": "itworx.repopulse-controller-adoption-execution-result/v1",
"repository": "jens/repopulse",
"applicationId": "repopulse",
"intentId": intent_id,
"status": "deployment_verified",
"phase": "completion_verified",
"legacyVolume": "repopulse_data",
"legacyVolumeRetained": True,
"targetGates": gates,
"exactShaValidation": {
"repository": "jens/repopulse",
"revision": "a" * 40,
"contexts": {"Managed validation / full (push)": "success"},
},
"quiescence": quiescence,
"snapshot": snapshot,
"copy": copied,
"registryReceipt": registry_receipt,
"deploymentReceipt": deployment_receipt,
"completion": completion,
}
class FakePreflight:
def __init__(self) -> None:
self.calls = 0
def run(self) -> dict:
self.calls += 1
return _passing_preflight()
class FakeBroker:
base_url = "https://controller.test"
verify_tls = True
ca_file = None
def __init__(
self,
*,
reserve_result: dict | None = None,
status_result: dict | None = None,
) -> None:
self.reserve_result = reserve_result
self.status_result = status_result
self.execute_calls: list[dict] = []
self.status_calls = 0
self.reserve_calls: list[dict] = []
def configuration_error(self):
return None
def repopulse_adoption_capabilities(self, *, secret: str) -> dict:
return {
"apiVersion": "itworx.repopulse-adoption-capabilities/v1",
"status": "ready",
"repository": "jens/repopulse",
"adoptionStore": {"executionEnabled": True},
"privateTls": {"status": "ready"},
"controller": {
"aliases": "ready",
"registry": "ready",
"secretRoot": "ready",
},
}
def repopulse_adoption_reserve(self, **kwargs) -> dict:
self.reserve_calls.append(kwargs)
if self.reserve_result is not None:
return self.reserve_result
binding = hashlib.sha256(
json.dumps(
{
"intentId": kwargs["intent_id"],
"repository": "jens/repopulse",
"applicationId": "repopulse",
"preflightSha256": kwargs["preflight_sha256"],
"idempotencySha256": kwargs["idempotency_sha256"],
},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("ascii")
).hexdigest()
return {
"apiVersion": "itworx.repopulse-adoption-reserve/v1",
"status": "reserved",
"intentId": kwargs["intent_id"],
"phase": "reserved",
"intentSha256": _sha("7"),
"intentBindingSha256": binding,
}
def repopulse_adoption_execute(self, **kwargs) -> dict:
self.execute_calls.append(kwargs)
raise AssertionError("AppOps must not invoke the controller cutover")
def repopulse_adoption_status(self, *, secret: str) -> dict:
self.status_calls += 1
return self.status_result or {
"apiVersion": "itworx.repopulse-adoption-status/v1",
"status": "not_reserved",
"phase": None,
"hashes": {},
}
def _runner_type():
try:
module = importlib.import_module("backend.app.services.repopulse_adoption_runner")
except ModuleNotFoundError:
pytest.fail("fixed RepoPulse AppOps runner is missing")
return module.RepoPulseAdoptionRunner
def _controller_status(intent: dict, controller_result: dict) -> dict:
snapshot = controller_result["snapshot"]
gates = controller_result["targetGates"]
registry = controller_result["registryReceipt"]
return {
"apiVersion": "itworx.repopulse-adoption-status/v1",
"status": "in_progress",
"phase": "snapshot_verified",
"hashes": {
"intentSha256": _sha("7"),
"intentBindingSha256": protocol.intent_binding_sha256(intent),
"idempotencySha256": intent["idempotencySha256"],
"preflightSha256": intent["preflightSha256"],
"expectedProjectsSha256": registry["projectsSha256"],
"quiescenceSha256": _sha("d"),
"planSha256": gates["planSha256"],
"snapshotFileSha256": snapshot["snapshotFileSha256"],
"snapshotLogicalSha256": snapshot["snapshotLogicalSha256"],
"snapshotQueueSha256": snapshot["snapshotQueueSha256"],
"targetProjectsSha256": registry["projectsSha256"],
"receiptSha256": registry["receiptSha256"],
},
}
def _session(monkeypatch) -> Session:
monkeypatch.setattr(settings, "environment", "production")
monkeypatch.setattr(settings, "secret_key", "appops-test-key-with-at-least-32-bytes")
monkeypatch.setattr(settings, "secret_key_source", "env")
engine = create_engine("sqlite://")
SQLModel.metadata.create_all(engine)
session = Session(engine)
session.add(
AppSetting(
key="deployment_enrollment:trust",
is_secret=False,
value_json={
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "APPOPS_DEPLOYMENT_BOOTSTRAP",
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 1,
},
)
)
session.commit()
set_secret(session, "APPOPS_DEPLOYMENT_BOOTSTRAP", "B" * 64)
return session
def test_synthetic_prepare_and_finalize_commits_protocol_ownership_and_completion(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
preflight = FakePreflight()
report = _passing_preflight()
candidate = protocol.create_intent(report, "repopulse-adoption-2026")
binding = protocol.intent_binding_sha256(candidate)
controller_result = _controller_result(candidate["intentId"], binding)
broker = FakeBroker()
runner = runner_type(session=session, preflight=preflight, broker=broker)
handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
prepared = journal.get_repopulse_adoption_status(session)
broker.status_result = _controller_status(candidate, controller_result)
result = runner.finalize(controller_result)
assert prepared["status"] == "in_progress"
assert prepared["phase"] == "reserved"
assert handoff["intentId"] == candidate["intentId"]
assert result["status"] == "completed"
assert result["phase"] == "completed"
assert result["revision"] == 7
assert preflight.calls == 1
assert len(broker.reserve_calls) == 1
assert broker.reserve_calls[0]["intent_id"] == candidate["intentId"]
assert broker.reserve_calls[0]["preflight_sha256"] == candidate["preflightSha256"]
assert broker.execute_calls == []
def test_prepared_and_completed_replays_never_repeat_external_mutations(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
report = _passing_preflight()
candidate = protocol.create_intent(report, "repopulse-adoption-2026")
binding = protocol.intent_binding_sha256(candidate)
controller_result = _controller_result(candidate["intentId"], binding)
broker = FakeBroker()
runner = runner_type(session=session, preflight=FakePreflight(), broker=broker)
broker.status_result = _controller_status(candidate, controller_result)
handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
replay = runner.prepare("repopulse-adoption-2026")
completed = runner.finalize(controller_result)
completed_replay = runner.prepare("repopulse-adoption-2026")
assert handoff["intentId"] == candidate["intentId"]
assert replay["status"] == "prepared"
assert completed["status"] == completed_replay["status"] == "completed"
assert completed_replay["revision"] == completed["revision"]
assert len(broker.reserve_calls) == 1
assert broker.execute_calls == []
def test_controller_cli_handoff_is_exact_in_memory_contract_and_not_printed(monkeypatch, capsys):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
report = _passing_preflight()
candidate = protocol.create_intent(report, "repopulse-adoption-2026")
broker = FakeBroker()
runner = runner_type(session=session, preflight=FakePreflight(), broker=broker)
handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
assert set(handoff) == {
"intentId",
"idempotencySha256",
"preflightSha256",
"preflightProjection",
"targetCredential",
}
assert handoff["intentId"] == candidate["intentId"]
assert handoff["idempotencySha256"] == candidate["idempotencySha256"]
assert handoff["preflightSha256"] == candidate["preflightSha256"]
assert handoff["preflightProjection"] == candidate["evidence"]["preflight"]
assert isinstance(handoff["targetCredential"], str)
assert 32 <= len(handoff["targetCredential"]) <= 256
assert capsys.readouterr().out == ""
assert len(broker.reserve_calls) == 1
assert broker.execute_calls == []
def test_controller_cli_handoff_cannot_be_reissued(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026")
controller_result = _controller_result(
candidate["intentId"], protocol.intent_binding_sha256(candidate)
)
broker = FakeBroker(status_result=_controller_status(candidate, controller_result))
runner = runner_type(session=session, preflight=FakePreflight(), broker=broker)
first = runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
with pytest.raises(ValueError, match="controller_cli_handoff_replay_requires_recovery"):
runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
status = journal.get_repopulse_adoption_status(session)
assert first["intentId"] == candidate["intentId"]
assert status["status"] == "recovery_required"
assert status["phase"] == "reserved"
assert len(broker.reserve_calls) == 1
assert broker.execute_calls == []
def test_operator_runner_hydrates_portainer_settings_without_fastapi_lifespan(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
calls = []
runner_module = importlib.import_module(
"backend.app.services.repopulse_adoption_runner"
)
monkeypatch.setattr(
runner_module,
"apply_stored_connector_settings",
lambda active_session, connector_ids: calls.append(
(active_session, connector_ids)
),
)
runner = runner_type(session=session, preflight=FakePreflight(), broker=FakeBroker())
assert calls == []
runner.prepare("repopulse-adoption-2026")
assert calls == [(session, {"portainer"})]
def test_execution_flag_remains_fail_closed_in_source(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", False)
session = _session(monkeypatch)
preflight = FakePreflight()
broker = FakeBroker()
runner = runner_type(session=session, preflight=preflight, broker=broker)
assert runner.prepare("repopulse-adoption-2026")["status"] == "disabled"
assert preflight.calls == 0
assert broker.reserve_calls == []
assert broker.execute_calls == []
def test_tampered_completion_is_rejected_and_marked_for_recovery(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026")
binding = protocol.intent_binding_sha256(candidate)
controller_result = _controller_result(candidate["intentId"], binding)
broker = FakeBroker()
runner = runner_type(session=session, preflight=FakePreflight(), broker=broker)
runner.prepare_controller_cli_handoff("repopulse-adoption-2026")
broker.status_result = _controller_status(candidate, controller_result)
controller_result["completion"]["completionSha256"] = _sha("0")
result = runner.finalize(controller_result)
assert result["status"] == "recovery_required"
assert result["phase"] == "reserved"
assert broker.status_calls == 1
assert broker.execute_calls == []
def test_ambiguous_controller_reserve_queries_status_and_requires_recovery(monkeypatch):
runner_type = _runner_type()
monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True)
session = _session(monkeypatch)
broker = FakeBroker(
reserve_result={
"status": "unreachable",
"reason": "secret-bearing raw transport error",
}
)
runner = runner_type(session=session, preflight=FakePreflight(), broker=broker)
result = runner.prepare("repopulse-adoption-2026")
assert result["status"] == "recovery_required"
assert result["reason"] == "unknown_external_effect"
assert broker.status_calls == 1
assert len(broker.reserve_calls) == 1
assert broker.execute_calls == []
assert "secret-bearing raw transport error" not in json.dumps(result)
replay = runner.prepare("repopulse-adoption-2026")
assert replay["status"] == "recovery_required"
assert len(broker.reserve_calls) == 1