607 lines
21 KiB
Python
607 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
from sqlmodel import Session, SQLModel, create_engine, select
|
|
|
|
from backend.app.db.models import (
|
|
AppSetting,
|
|
Enrollment,
|
|
Policy,
|
|
RepoPulseAdoptionJournal,
|
|
)
|
|
from backend.app.schemas.enrollment import EnrollmentPlanV1
|
|
from backend.app.services import repopulse_adoption_journal as journal
|
|
from backend.app.services import repopulse_adoption_ownership as ownership
|
|
from backend.app.services.repopulse_legacy_adoption_protocol import (
|
|
APPLICATION_ID,
|
|
CONTROLLER_RECEIPT_VERSION,
|
|
LEGACY_VOLUME,
|
|
REPOSITORY,
|
|
intent_binding_sha256,
|
|
)
|
|
from backend.app.services.secret_store import set_secret
|
|
|
|
|
|
def _sha(char: str) -> str:
|
|
return char * 64
|
|
|
|
|
|
def _plan_payload(revision: str) -> dict:
|
|
return 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")
|
|
|
|
|
|
def _plan_sha(revision: str) -> str:
|
|
payload = _plan_payload(revision)
|
|
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
return hashlib.sha256(canonical).hexdigest()
|
|
|
|
|
|
def _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": REPOSITORY,
|
|
"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 _target_gates() -> dict:
|
|
return {
|
|
"profileId": "repopulse-authenticated",
|
|
"profileVersion": "enrollment-profile/v1",
|
|
"planSha256": _plan_sha("a" * 40),
|
|
**{
|
|
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",
|
|
)
|
|
},
|
|
}
|
|
|
|
|
|
def _quiescence(intent: dict) -> dict:
|
|
return {
|
|
"intentId": intent["intentId"],
|
|
"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": LEGACY_VOLUME,
|
|
"controllerIntentDurable": True,
|
|
"controllerIntentBindingSha256": intent_binding_sha256(intent),
|
|
}
|
|
|
|
|
|
def _snapshot(intent: dict) -> dict:
|
|
return {
|
|
"intentId": intent["intentId"],
|
|
"globalDeployLockHeld": True,
|
|
"projectLockHeld": True,
|
|
"legacyContainerStopped": True,
|
|
"legacyVolume": LEGACY_VOLUME,
|
|
"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": intent_binding_sha256(intent),
|
|
}
|
|
|
|
|
|
def _registration(intent: dict, *, enrollment_id: str | None = None) -> dict:
|
|
gates = intent["evidence"]["targetGates"]
|
|
snapshot = intent["evidence"]["snapshot"]
|
|
return {
|
|
"receiptVersion": CONTROLLER_RECEIPT_VERSION,
|
|
"intentId": intent["intentId"],
|
|
"receiptSha256": _sha("2"),
|
|
"repository": REPOSITORY,
|
|
"applicationId": APPLICATION_ID,
|
|
"enrollmentId": enrollment_id or f"enr-{intent['intentId'][4:]}",
|
|
"profileId": gates["profileId"],
|
|
"planSha256": gates["planSha256"],
|
|
"snapshotFileSha256": snapshot["snapshotFileSha256"],
|
|
"targetLogicalSha256": snapshot["snapshotLogicalSha256"],
|
|
"targetScanCount": snapshot["snapshotScanCount"],
|
|
"targetCompactedScanCount": snapshot["snapshotCompactedScanCount"],
|
|
"targetQueueSha256": snapshot["snapshotQueueSha256"],
|
|
"targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"],
|
|
"targetAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"],
|
|
"targetQueuedJobs": 0,
|
|
"targetActiveJobs": 0,
|
|
"targetQuickCheck": "ok",
|
|
"targetIntegrityCheck": "ok",
|
|
"targetRecoveryStatus": "healthy",
|
|
"targetDataUid": 10001,
|
|
"targetDataGid": 10001,
|
|
"managedDataMountMatches": True,
|
|
"copySourceIsVerifiedSnapshot": True,
|
|
"registryTargetMatches": True,
|
|
"controllerStateReceiptVerified": True,
|
|
"deploymentReceiptSchema": "gitea-deploy-receipt.v1",
|
|
"deploymentReceiptSha256": _sha("4"),
|
|
"deploymentReceiptVerified": True,
|
|
"runtimeHealthy": True,
|
|
"publishedHostPorts": [],
|
|
"globalDeployLockHeld": True,
|
|
"projectLockHeld": True,
|
|
"legacyVolume": LEGACY_VOLUME,
|
|
"legacyVolumeRetained": True,
|
|
"legacyEnvironmentRetained": True,
|
|
"managedEnvironmentFilePrivate": True,
|
|
"scanSourceMatches": True,
|
|
"scanMountReadOnly": True,
|
|
"scanPermissionPolicyPersistent": True,
|
|
"scanAccessForRuntimeIdentityReady": True,
|
|
"controllerIntentDurable": True,
|
|
"controllerIntentBindingSha256": intent_binding_sha256(intent),
|
|
}
|
|
|
|
|
|
def _ready_engine():
|
|
engine = create_engine("sqlite://")
|
|
SQLModel.metadata.create_all(engine)
|
|
with Session(engine) as session:
|
|
session.add(
|
|
AppSetting(
|
|
key="deployment_enrollment:trust",
|
|
is_secret=False,
|
|
value_json={
|
|
"controller_identity": "unraid-controller",
|
|
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
|
"approved_networks": ["itworx-mcp-hub-npm"],
|
|
"allowed_domain_suffixes": ["example.com"],
|
|
"autonomous_profiles": ["repopulse-authenticated"],
|
|
"repopulse_proxy_access_list_id": 1,
|
|
},
|
|
)
|
|
)
|
|
set_secret(
|
|
session,
|
|
"ENROLL_REPOPULSE_TARGET_HMAC",
|
|
"test-target-credential-only-for-fixture",
|
|
commit=False,
|
|
)
|
|
session.commit()
|
|
return engine
|
|
|
|
|
|
def _register_target(
|
|
engine, *, enrollment_id: str | None = None
|
|
) -> tuple[dict, dict, int]:
|
|
preflight = _preflight()
|
|
with Session(engine) as session:
|
|
reserved = journal.reserve_repopulse_adoption(
|
|
session, preflight, "repopulse-adoption-2026"
|
|
)
|
|
intent = journal.protocol.create_intent(preflight, "repopulse-adoption-2026")
|
|
gates = _target_gates()
|
|
with Session(engine) as session:
|
|
journal.advance_repopulse_adoption(
|
|
session, "target_gates", gates, expected_revision=reserved["revision"]
|
|
)
|
|
intent = journal.protocol.advance_intent(intent, "target_gates", gates)
|
|
quiescence = _quiescence(intent)
|
|
with Session(engine) as session:
|
|
journal.confirm_repopulse_controller_intent(
|
|
session, quiescence, expected_revision=2
|
|
)
|
|
intent = journal.protocol.advance_intent(intent, "quiescence", quiescence)
|
|
snapshot = _snapshot(intent)
|
|
with Session(engine) as session:
|
|
journal.advance_repopulse_adoption(
|
|
session, "snapshot", snapshot, expected_revision=3
|
|
)
|
|
intent = journal.protocol.advance_intent(intent, "snapshot", snapshot)
|
|
registration = _registration(intent, enrollment_id=enrollment_id)
|
|
with Session(engine) as session:
|
|
registered = journal.advance_repopulse_adoption(
|
|
session,
|
|
"controller_registration",
|
|
registration,
|
|
expected_revision=4,
|
|
)
|
|
return intent, registration, registered["revision"]
|
|
|
|
|
|
@pytest.fixture
|
|
def engine():
|
|
db = _ready_engine()
|
|
yield db
|
|
db.dispose()
|
|
|
|
|
|
def test_ownership_commit_is_atomic_fixed_and_replayable(engine) -> None:
|
|
intent, registration, revision = _register_target(engine)
|
|
|
|
with Session(engine) as session:
|
|
result = ownership.commit_repopulse_adoption_ownership(
|
|
session,
|
|
registration,
|
|
expected_revision=revision,
|
|
)
|
|
|
|
assert result["phase"] == "appops_owned"
|
|
assert result["revision"] == revision + 1
|
|
with Session(engine) as session:
|
|
row = session.get(Enrollment, registration["enrollmentId"])
|
|
assert row is not None
|
|
assert row.repository == "jens/repopulse"
|
|
assert row.application_id == "repopulse"
|
|
assert row.revision == "a" * 40
|
|
assert row.profile == "repopulse-authenticated"
|
|
assert row.controller_identity == "unraid-controller"
|
|
assert row.plan_sha256 == registration["planSha256"]
|
|
assert row.target_secret_ref == "ENROLL_REPOPULSE_TARGET_HMAC"
|
|
assert row.oidc_secret_ref is None
|
|
assert row.plan_json == _plan_payload("a" * 40)
|
|
assert row.provenance_json["createdBy"] == "repopulse-legacy-adoption/v1"
|
|
assert (
|
|
row.provenance_json["controllerReceiptSha256"]
|
|
== registration["receiptSha256"]
|
|
)
|
|
policies = session.exec(select(Policy).order_by(Policy.id)).all()
|
|
assert [policy.id for policy in policies] == [
|
|
"enrollment-repopulse-deployment-apply",
|
|
"enrollment-repopulse-deployment-rollback",
|
|
]
|
|
assert {
|
|
(item.action, item.target, item.voice, item.manual, item.risk)
|
|
for item in policies
|
|
} == {
|
|
("deployment.apply", "repopulse", "blocked", "allowed", "high"),
|
|
("deployment.rollback", "repopulse", "blocked", "allowed", "high"),
|
|
}
|
|
assert all(
|
|
item.provenance_json["adoptionIntentId"] == intent["intentId"]
|
|
for item in policies
|
|
)
|
|
stored_journal = session.get(RepoPulseAdoptionJournal, "repopulse")
|
|
assert stored_journal.phase == "appops_owned"
|
|
assert (
|
|
stored_journal.intent_json["evidence"]["appopsOwnership"][
|
|
"secretReferenceReady"
|
|
]
|
|
is True
|
|
)
|
|
|
|
with Session(engine) as session:
|
|
replay = ownership.commit_repopulse_adoption_ownership(
|
|
session,
|
|
registration,
|
|
expected_revision=revision + 1,
|
|
)
|
|
assert replay["phase"] == "appops_owned"
|
|
assert replay["revision"] == revision + 1
|
|
|
|
|
|
def test_ownership_commit_rejects_live_controller_receipt_drift(engine) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
changed = {**registration, "deploymentReceiptSha256": _sha("5")}
|
|
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.commit_repopulse_adoption_ownership(
|
|
session, changed, expected_revision=revision
|
|
)
|
|
|
|
assert error.value.code == "idempotency_conflict"
|
|
with Session(engine) as session:
|
|
assert session.exec(select(Enrollment)).all() == []
|
|
assert session.exec(select(Policy)).all() == []
|
|
|
|
|
|
def test_enrollment_identity_is_derived_from_persisted_intent(engine) -> None:
|
|
_, registration, revision = _register_target(
|
|
engine, enrollment_id="enr-0123456789abcdefabcd"
|
|
)
|
|
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
|
|
assert error.value.code == "fixed_enrollment_id_mismatch"
|
|
with Session(engine) as session:
|
|
assert session.exec(select(Enrollment)).all() == []
|
|
assert session.exec(select(Policy)).all() == []
|
|
|
|
|
|
def test_preexisting_exact_policy_id_is_not_adopted(engine) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
with Session(engine) as session:
|
|
session.add(
|
|
Policy(
|
|
id="enrollment-repopulse-deployment-apply",
|
|
action="deployment.apply",
|
|
target="repopulse",
|
|
voice="blocked",
|
|
manual="denied",
|
|
risk="high",
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
assert error.value.code == "ownership_resource_conflict"
|
|
with Session(engine) as session:
|
|
assert session.exec(select(Enrollment)).all() == []
|
|
assert (
|
|
session.get(Policy, "enrollment-repopulse-deployment-apply").manual
|
|
== "denied"
|
|
)
|
|
assert session.get(Policy, "enrollment-repopulse-deployment-rollback") is None
|
|
assert (
|
|
session.get(RepoPulseAdoptionJournal, "repopulse").phase
|
|
== "target_registered"
|
|
)
|
|
|
|
|
|
def test_ownership_commit_rejects_missing_fixed_secret_ref(engine) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
with Session(engine) as session:
|
|
from backend.app.db.models import Secret
|
|
|
|
session.delete(session.get(Secret, "ENROLL_REPOPULSE_TARGET_HMAC"))
|
|
session.commit()
|
|
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
assert error.value.code == "target_secret_reference_unavailable"
|
|
with Session(engine) as session:
|
|
assert session.exec(select(Enrollment)).all() == []
|
|
assert session.exec(select(Policy)).all() == []
|
|
|
|
|
|
def test_completion_is_a_separate_idempotent_step_bound_to_live_receipt(engine) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
with Session(engine) as session:
|
|
owned = ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
|
|
with Session(engine) as session:
|
|
completed = ownership.complete_repopulse_adoption(
|
|
session,
|
|
registration,
|
|
expected_revision=owned["revision"],
|
|
)
|
|
assert completed["phase"] == "completed"
|
|
assert completed["status"] == "completed"
|
|
assert completed["revision"] == owned["revision"] + 1
|
|
|
|
with Session(engine) as session:
|
|
replay = ownership.complete_repopulse_adoption(
|
|
session,
|
|
registration,
|
|
expected_revision=completed["revision"],
|
|
)
|
|
assert replay["revision"] == completed["revision"]
|
|
assert replay["phase"] == "completed"
|
|
|
|
with Session(engine) as session:
|
|
ownership_replay = ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=completed["revision"]
|
|
)
|
|
assert ownership_replay["revision"] == completed["revision"]
|
|
assert ownership_replay["phase"] == "completed"
|
|
|
|
changed = {**registration, "receiptSha256": _sha("9")}
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.complete_repopulse_adoption(
|
|
session, changed, expected_revision=completed["revision"]
|
|
)
|
|
assert error.value.code == "idempotency_conflict"
|
|
|
|
|
|
def test_completion_replay_rechecks_owned_policy_rows(engine) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
with Session(engine) as session:
|
|
owned = ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
with Session(engine) as session:
|
|
completed = ownership.complete_repopulse_adoption(
|
|
session, registration, expected_revision=owned["revision"]
|
|
)
|
|
with Session(engine) as session:
|
|
policy = session.get(Policy, "enrollment-repopulse-deployment-apply")
|
|
policy.manual = "denied"
|
|
session.add(policy)
|
|
session.commit()
|
|
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.complete_repopulse_adoption(
|
|
session, registration, expected_revision=completed["revision"]
|
|
)
|
|
assert error.value.code == "ownership_policy_readback_mismatch"
|
|
|
|
|
|
def test_half_write_failure_rolls_back_all_ownership_rows_and_marks_recovery(
|
|
engine, monkeypatch
|
|
) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
original_commit = Session.commit
|
|
calls = 0
|
|
|
|
def fail_first_commit(session):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise RuntimeError("simulated commit interruption")
|
|
return original_commit(session)
|
|
|
|
monkeypatch.setattr(Session, "commit", fail_first_commit)
|
|
with (
|
|
Session(engine) as session,
|
|
pytest.raises(ownership.RepoPulseOwnershipError) as error,
|
|
):
|
|
ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
assert error.value.code == "appops_commit_uncertain"
|
|
with Session(engine) as session:
|
|
assert session.exec(select(Enrollment)).all() == []
|
|
assert session.exec(select(Policy)).all() == []
|
|
stored = session.get(RepoPulseAdoptionJournal, "repopulse")
|
|
assert stored.phase == "target_registered"
|
|
assert stored.status == "recovery_required"
|
|
assert stored.intent_json["recovery"]["reason"] == "appops_commit_uncertain"
|
|
|
|
|
|
def test_commit_exception_after_durable_commit_is_resolved_by_readback(
|
|
engine, monkeypatch
|
|
) -> None:
|
|
_, registration, revision = _register_target(engine)
|
|
original_commit = Session.commit
|
|
calls = 0
|
|
|
|
def commit_then_lose_response(session):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
original_commit(session)
|
|
raise RuntimeError("simulated lost commit response")
|
|
return original_commit(session)
|
|
|
|
monkeypatch.setattr(Session, "commit", commit_then_lose_response)
|
|
with Session(engine) as session:
|
|
result = ownership.commit_repopulse_adoption_ownership(
|
|
session, registration, expected_revision=revision
|
|
)
|
|
|
|
assert result["phase"] == "appops_owned"
|
|
with Session(engine) as session:
|
|
assert session.get(Enrollment, registration["enrollmentId"]) is not None
|
|
stored = session.get(RepoPulseAdoptionJournal, "repopulse")
|
|
assert stored.phase == "appops_owned"
|
|
assert stored.status == "in_progress"
|