Files
Unraid-AppOps-Gateway-Public/backend/app/services/repopulse_adoption_ownership.py
T

630 lines
23 KiB
Python

"""Atomic AppOps ownership commit for the one RepoPulse adoption.
This is a database-only protocol step. The caller must authenticate the live
controller receipt before supplying the fixed registration evidence. No route,
controller client, secret writer, or generic enrollment orchestration is used.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from sqlalchemy import or_, update
from sqlalchemy.exc import SQLAlchemyError
from sqlmodel import Session, select
from ..core.time import utc_now
from ..db.models import AppSetting, Enrollment, Policy, RepoPulseAdoptionJournal
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
from . import repopulse_adoption_journal as journal
from . import repopulse_legacy_adoption_protocol as protocol
from .enrollment_profiles import PROFILE_VERSION
from .secret_store import has_secret
_SLOT = "repopulse"
_TRUST_SETTING_KEY = "deployment_enrollment:trust"
_CONTROLLER_IDENTITY = "unraid-controller"
_TARGET_SECRET_REF = "ENROLL_REPOPULSE_TARGET_HMAC"
_CREATED_BY = "repopulse-legacy-adoption/v1"
_APPLY_POLICY_ID = "enrollment-repopulse-deployment-apply"
_ROLLBACK_POLICY_ID = "enrollment-repopulse-deployment-rollback"
_POLICY_REASON = "Enrollment-generated exact-target deployment policy."
class RepoPulseOwnershipError(ValueError):
"""Sanitized fixed ownership precondition or persistence error."""
def __init__(self, code: str):
super().__init__(code)
self.code = code
def _need(condition: bool, code: str) -> None:
if not condition:
raise RepoPulseOwnershipError(code)
def _canonical_plan(plan: EnrollmentPlanV1) -> tuple[dict[str, Any], str]:
payload = plan.model_dump(mode="json")
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return payload, hashlib.sha256(encoded).hexdigest()
def _fixed_plan(
session: Session, intent: dict[str, Any]
) -> tuple[EnrollmentPlanV1, dict[str, Any], str]:
preflight = intent["evidence"].get("preflight")
gates = intent["evidence"].get("targetGates")
registration = intent["evidence"].get("controllerRegistration")
_need(
isinstance(preflight, dict) and isinstance(gates, dict),
"adoption_prerequisite_missing",
)
_need(isinstance(registration, dict), "controller_registration_missing")
trust_row = session.get(AppSetting, _TRUST_SETTING_KEY)
_need(
trust_row is not None and trust_row.is_secret is False,
"enrollment_trust_unavailable",
)
try:
trust = EnrollmentTrustConfigV1.model_validate(trust_row.value_json)
except (TypeError, ValueError):
raise RepoPulseOwnershipError("enrollment_trust_invalid") from None
_need(
trust.controller_identity == _CONTROLLER_IDENTITY,
"controller_identity_mismatch",
)
_need(
"repopulse-authenticated" in trust.autonomous_profiles
and "itworx-mcp-hub-npm" in trust.approved_networks
and "example.com" in trust.allowed_domain_suffixes
and trust.repopulse_proxy_access_list_id is not None,
"repopulse_trust_boundary_mismatch",
)
revision = preflight.get("main_revision")
_need(
isinstance(revision, str) and len(revision) == 40, "preflight_revision_invalid"
)
try:
plan = EnrollmentPlanV1.model_validate(
{
"application_id": protocol.APPLICATION_ID,
"display_name": "RepoPulse",
"environment": "production",
"repository": "Jens/RepoPulse",
"revision": revision,
"profile": "repopulse-authenticated",
"target": {
"target_identity": _CONTROLLER_IDENTITY,
"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"]},
}
)
except (TypeError, ValueError):
raise RepoPulseOwnershipError("fixed_repopulse_plan_invalid") from None
payload, plan_sha256 = _canonical_plan(plan)
_need(
gates.get("profileId") == "repopulse-authenticated"
and gates.get("profileVersion") == PROFILE_VERSION
and gates.get("planSha256") == plan_sha256
and registration.get("planSha256") == plan_sha256,
"fixed_repopulse_plan_digest_mismatch",
)
expected_enrollment_id = f"enr-{intent['intentId'][4:]}"
_need(
registration.get("enrollmentId") == expected_enrollment_id,
"fixed_enrollment_id_mismatch",
)
return plan, payload, plan_sha256
def _expected_provenance(
intent: dict[str, Any], registration: dict[str, Any], plan_sha256: str
) -> dict[str, Any]:
return {
"createdBy": _CREATED_BY,
"adoptionIntentId": intent["intentId"],
"profileVersion": PROFILE_VERSION,
"policyTemplateVersion": "exact-target/v1",
"sourceRevision": intent["evidence"]["preflight"]["main_revision"],
"planSha256": plan_sha256,
"controllerReceiptSha256": registration["receiptSha256"],
"deploymentReceiptSha256": registration["deploymentReceiptSha256"],
}
def _expected_resources(registration: dict[str, Any]) -> dict[str, Any]:
return {
"appdataPath": "/srv/example/appdata/repopulse",
"dataPath": "/srv/example/appdata/repopulse/data",
"network": "itworx-mcp-hub-npm",
"hostname": "repopulse.example.com",
"controllerReceiptSha256": registration["receiptSha256"],
"deploymentReceiptSha256": registration["deploymentReceiptSha256"],
}
def _expected_policy_rows(
intent: dict[str, Any], registration: dict[str, Any], plan_sha256: str
) -> tuple[Policy, Policy]:
provenance = _expected_provenance(intent, registration, plan_sha256)
return (
Policy(
id=_APPLY_POLICY_ID,
action="deployment.apply",
target=protocol.APPLICATION_ID,
voice="blocked",
manual="allowed",
risk="high",
reason=_POLICY_REASON,
provenance_json=provenance,
),
Policy(
id=_ROLLBACK_POLICY_ID,
action="deployment.rollback",
target=protocol.APPLICATION_ID,
voice="blocked",
manual="allowed",
risk="high",
reason=_POLICY_REASON,
provenance_json=provenance,
),
)
def _policy_matches(actual: Policy | None, expected: Policy) -> bool:
return bool(
actual is not None
and actual.id == expected.id
and actual.action == expected.action
and actual.target == expected.target
and actual.voice == expected.voice
and actual.manual == expected.manual
and actual.risk == expected.risk
and actual.reason == expected.reason
and actual.provenance_json == expected.provenance_json
)
def _expected_enrollment(
intent: dict[str, Any],
registration: dict[str, Any],
plan: EnrollmentPlanV1,
plan_json: dict[str, Any],
plan_sha256: str,
) -> Enrollment:
return Enrollment(
id=registration["enrollmentId"],
application_id=protocol.APPLICATION_ID,
repository=protocol.REPOSITORY,
revision=plan.revision,
profile="repopulse-authenticated",
controller_identity=_CONTROLLER_IDENTITY,
plan_sha256=plan_sha256,
status="ready",
target_secret_ref=_TARGET_SECRET_REF,
oidc_secret_ref=None,
plan_json=plan_json,
resources_json=_expected_resources(registration),
provenance_json=_expected_provenance(intent, registration, plan_sha256),
)
def _enrollment_matches(actual: Enrollment | None, expected: Enrollment) -> bool:
return bool(
actual is not None
and actual.id == expected.id
and actual.application_id == expected.application_id
and actual.repository == expected.repository
and actual.revision == expected.revision
and actual.profile == expected.profile
and actual.controller_identity == expected.controller_identity
and actual.plan_sha256 == expected.plan_sha256
and actual.status == expected.status
and actual.target_secret_ref == expected.target_secret_ref
and actual.oidc_secret_ref is None
and actual.plan_json == expected.plan_json
and actual.resources_json == expected.resources_json
and actual.provenance_json == expected.provenance_json
and actual.last_verified_at is not None
and actual.completed_at is not None
)
def _verify_owned_rows(
session: Session,
intent: dict[str, Any],
registration: dict[str, Any],
plan: EnrollmentPlanV1,
plan_json: dict[str, Any],
plan_sha256: str,
) -> tuple[Enrollment, tuple[Policy, Policy]]:
expected_enrollment = _expected_enrollment(
intent, registration, plan, plan_json, plan_sha256
)
matching_enrollments = session.exec(
select(Enrollment).where(
or_(
Enrollment.application_id == protocol.APPLICATION_ID,
Enrollment.repository == protocol.REPOSITORY,
)
)
).all()
_need(
len(matching_enrollments) == 1
and _enrollment_matches(matching_enrollments[0], expected_enrollment),
"ownership_enrollment_readback_mismatch",
)
expected_policies = _expected_policy_rows(intent, registration, plan_sha256)
_need(
all(
_policy_matches(session.get(Policy, expected.id), expected)
for expected in expected_policies
),
"ownership_policy_readback_mismatch",
)
_need(
has_secret(session, _TARGET_SECRET_REF), "target_secret_reference_unavailable"
)
return matching_enrollments[0], expected_policies
def _verify_live_registration(
intent: dict[str, Any], live_registration_evidence: object
) -> dict[str, Any]:
try:
if intent.get("status") == "completed":
checked = protocol.validate_controller_registration_evidence(
intent, live_registration_evidence
)
replayed_registration = checked
else:
replayed = protocol.advance_intent(
intent, "controller_registration", live_registration_evidence
)
replayed_registration = replayed["evidence"].get("controllerRegistration")
except protocol.AdoptionProtocolError as error:
raise RepoPulseOwnershipError(error.code) from None
_need(
replayed_registration == intent["evidence"].get("controllerRegistration"),
"idempotency_conflict",
)
return replayed_registration
def _ownership_evidence(
registration: dict[str, Any], plan_sha256: str
) -> dict[str, Any]:
return {
"repository": protocol.REPOSITORY,
"applicationId": protocol.APPLICATION_ID,
"enrollmentId": registration["enrollmentId"],
"profileId": registration["profileId"],
"planSha256": plan_sha256,
"controllerReceiptSha256": registration["receiptSha256"],
"enrollmentPersisted": True,
"exactApplyPolicyPersisted": True,
"exactRollbackPolicyPersisted": True,
"secretReferenceReady": True,
}
def _advance_or_verify_ownership(
intent: dict[str, Any], evidence: dict[str, Any]
) -> dict[str, Any]:
try:
if intent.get("status") == "completed":
checked = protocol.validate_appops_ownership_evidence(intent, evidence)
_need(
checked == intent["evidence"].get("appopsOwnership"),
"idempotency_conflict",
)
return intent
return protocol.advance_intent(intent, "appops_ownership", evidence)
except protocol.AdoptionProtocolError as error:
raise RepoPulseOwnershipError(error.code) from None
def _write_journal_update(
session: Session,
row: RepoPulseAdoptionJournal,
current: dict[str, Any],
updated: dict[str, Any],
expected_revision: int,
) -> int:
result = session.execute(
update(RepoPulseAdoptionJournal)
.where(
RepoPulseAdoptionJournal.slot == _SLOT,
RepoPulseAdoptionJournal.revision == expected_revision,
RepoPulseAdoptionJournal.intent_id == current["intentId"],
)
.values(
idempotency_sha256=updated["idempotencySha256"],
preflight_sha256=updated["preflightSha256"],
phase=updated["phase"],
status=updated["status"],
intent_json=updated,
revision=expected_revision + 1,
updated_at=utc_now(),
)
.execution_options(synchronize_session=False)
)
_need(result.rowcount == 1, "journal_revision_conflict")
return expected_revision + 1
def _readback_owned(
session: Session, registration: dict[str, Any]
) -> dict[str, Any] | None:
session.rollback()
session.expire_all()
try:
row, intent = journal._load(session)
if row is None or intent is None:
return None
live = _verify_live_registration(intent, registration)
plan, plan_json, plan_sha256 = _fixed_plan(session, intent)
_verify_owned_rows(session, intent, live, plan, plan_json, plan_sha256)
_advance_or_verify_ownership(intent, _ownership_evidence(live, plan_sha256))
if intent["phase"] not in {"appops_owned", "completed"}:
return None
if intent["phase"] == "completed":
_assert_completion_binding(intent, live, registration)
return journal.get_repopulse_adoption_status(session)
except (
RepoPulseOwnershipError,
journal.RepoPulseAdoptionJournalError,
protocol.AdoptionProtocolError,
SQLAlchemyError,
):
session.rollback()
return None
def _record_uncertain_commit(
session: Session, expected_revision: int, receipt_sha256: str
) -> None:
session.rollback()
session.expire_all()
try:
row, current = journal._load(session)
if (
row is None
or current is None
or row.revision != expected_revision
or current.get("phase") != "target_registered"
or current.get("status") != "in_progress"
):
return
journal.mark_repopulse_adoption_recovery(
session,
"appops_commit_uncertain",
{"controllerReceiptSha256": receipt_sha256},
expected_revision=expected_revision,
)
except (
RepoPulseOwnershipError,
journal.RepoPulseAdoptionJournalError,
protocol.AdoptionProtocolError,
SQLAlchemyError,
):
session.rollback()
def _assert_completion_binding(
intent: dict[str, Any],
registration: dict[str, Any],
live_registration: dict[str, Any],
) -> None:
stored = intent.get("evidence", {}).get("completion")
_need(isinstance(stored, dict), "completion_evidence_missing")
expected = {
"intentId": intent["intentId"],
"repository": protocol.REPOSITORY,
"applicationId": protocol.APPLICATION_ID,
"enrollmentId": registration["enrollmentId"],
"controllerReceiptSha256": registration["receiptSha256"],
"enrollmentStillPersisted": True,
"exactApplyPolicyStillPersisted": True,
"exactRollbackPolicyStillPersisted": True,
"controllerReceiptStillMatches": (
live_registration == registration
and live_registration["receiptSha256"] == registration["receiptSha256"]
),
"legacyVolumeRetained": live_registration["legacyVolumeRetained"],
}
_need(stored == expected, "completion_binding_mismatch")
def commit_repopulse_adoption_ownership(
session: Session,
authenticated_controller_registration: object,
*,
expected_revision: int,
) -> dict[str, Any]:
"""Atomically install fixed enrollment/policies and advance to `appops_owned`.
The caller is responsible for authenticating the live controller response.
The protocol validator then binds every field to the persisted fixed intent.
Exact replay reads back the DB rows and validates the fresh receipt again.
"""
try:
row, current = journal._load(session)
_need(row is not None and current is not None, "adoption_intent_not_reserved")
live = _verify_live_registration(current, authenticated_controller_registration)
plan, plan_json, plan_sha256 = _fixed_plan(session, current)
_need(
has_secret(session, _TARGET_SECRET_REF),
"target_secret_reference_unavailable",
)
if current["phase"] in {"appops_owned", "completed"}:
_verify_owned_rows(session, current, live, plan, plan_json, plan_sha256)
_advance_or_verify_ownership(
current, _ownership_evidence(live, plan_sha256)
)
if current["phase"] == "completed":
_assert_completion_binding(current, live, live)
return journal.get_repopulse_adoption_status(session)
_need(
current["phase"] == "target_registered"
and current["status"] == "in_progress",
"adoption_not_ready_for_ownership",
)
_need(
isinstance(expected_revision, int)
and not isinstance(expected_revision, bool)
and expected_revision == row.revision,
"journal_revision_conflict",
)
conflicting_enrollments = session.exec(
select(Enrollment).where(
or_(
Enrollment.application_id == protocol.APPLICATION_ID,
Enrollment.repository == protocol.REPOSITORY,
)
)
).all()
_need(not conflicting_enrollments, "ownership_resource_conflict")
expected_enrollment = _expected_enrollment(
current, live, plan, plan_json, plan_sha256
)
expected_policies = _expected_policy_rows(current, live, plan_sha256)
_need(
all(session.get(Policy, item.id) is None for item in expected_policies),
"ownership_resource_conflict",
)
now = utc_now()
expected_enrollment.created_at = now
expected_enrollment.updated_at = now
expected_enrollment.last_verified_at = now
expected_enrollment.completed_at = now
for policy in expected_policies:
session.add(policy)
session.add(expected_enrollment)
session.flush()
updated = _advance_or_verify_ownership(
current, _ownership_evidence(live, plan_sha256)
)
next_revision = _write_journal_update(
session, row, current, updated, expected_revision
)
try:
session.commit()
except (SQLAlchemyError, RuntimeError, OSError):
replay = _readback_owned(session, live)
if replay is not None:
return replay
_record_uncertain_commit(session, expected_revision, live["receiptSha256"])
raise RepoPulseOwnershipError("appops_commit_uncertain") from None
return journal._status(updated, next_revision)
except RepoPulseOwnershipError:
session.rollback()
raise
except journal.RepoPulseAdoptionJournalError as error:
session.rollback()
raise RepoPulseOwnershipError(error.code) from None
except protocol.AdoptionProtocolError as error:
session.rollback()
raise RepoPulseOwnershipError(error.code) from None
except SQLAlchemyError:
session.rollback()
raise RepoPulseOwnershipError("ownership_storage_unavailable") from None
def complete_repopulse_adoption(
session: Session,
authenticated_controller_registration: object,
*,
expected_revision: int,
) -> dict[str, Any]:
"""Persist AppOps completion after ownership, with fresh receipt readback.
Controller authentication and its completed adoption/deployment receipt
verification remain caller responsibilities. This step independently
rechecks the receipt binding and exact owned rows on every replay.
"""
try:
row, current = journal._load(session)
_need(row is not None and current is not None, "adoption_intent_not_reserved")
live = _verify_live_registration(current, authenticated_controller_registration)
plan, plan_json, plan_sha256 = _fixed_plan(session, current)
_verify_owned_rows(session, current, live, plan, plan_json, plan_sha256)
_advance_or_verify_ownership(current, _ownership_evidence(live, plan_sha256))
_need(
(current["phase"] == "appops_owned" and current["status"] == "in_progress")
or (current["phase"] == "completed" and current["status"] == "completed"),
"adoption_not_ready_to_complete",
)
completion_evidence = {
"intentId": current["intentId"],
"repository": protocol.REPOSITORY,
"applicationId": protocol.APPLICATION_ID,
"enrollmentId": live["enrollmentId"],
"controllerReceiptSha256": live["receiptSha256"],
"enrollmentStillPersisted": True,
"exactApplyPolicyStillPersisted": True,
"exactRollbackPolicyStillPersisted": True,
"controllerReceiptStillMatches": True,
"legacyVolumeRetained": live["legacyVolumeRetained"],
}
if current["phase"] == "completed":
_assert_completion_binding(current, live, live)
_need(
current["evidence"].get("completion") == completion_evidence,
"completion_binding_mismatch",
)
return journal.get_repopulse_adoption_status(session)
_need(
isinstance(expected_revision, int)
and not isinstance(expected_revision, bool)
and expected_revision == row.revision,
"journal_revision_conflict",
)
try:
updated = protocol.complete_intent(current, completion_evidence)
except protocol.AdoptionProtocolError as error:
raise RepoPulseOwnershipError(error.code) from None
return journal._persist_updated_intent(
session, row, current, updated, expected_revision
)
except RepoPulseOwnershipError:
session.rollback()
raise
except journal.RepoPulseAdoptionJournalError as error:
session.rollback()
raise RepoPulseOwnershipError(error.code) from None
except protocol.AdoptionProtocolError as error:
session.rollback()
raise RepoPulseOwnershipError(error.code) from None
except SQLAlchemyError:
session.rollback()
raise RepoPulseOwnershipError("ownership_storage_unavailable") from None