1278 lines
53 KiB
Python
1278 lines
53 KiB
Python
"""Fixed operator-only coordinator for the one RepoPulse adoption.
|
|
|
|
AppOps prepares and reserves the fixed intent, then finalizes only evidence
|
|
that has been collected by the separately operated root controller. This
|
|
module never asks the controller to execute a cutover and exposes no API
|
|
route, Portainer operation, caller-selected path, or arbitrary command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlmodel import Session
|
|
|
|
from ..core.time import utc_now
|
|
from ..db.models import AppSetting
|
|
from ..db.session import engine
|
|
from ..schemas.enrollment import EnrollmentTrustConfigV1
|
|
from . import (
|
|
repopulse_adoption_journal as journal,
|
|
)
|
|
from . import (
|
|
repopulse_adoption_ownership as ownership,
|
|
)
|
|
from . import (
|
|
repopulse_legacy_adoption_protocol as protocol,
|
|
)
|
|
from . import (
|
|
secret_store,
|
|
)
|
|
from .deployment_broker import DeploymentBrokerClient
|
|
from .enrollment_plane import TRUST_SETTING_KEY
|
|
from .repopulse_adoption_preflight import RepoPulseAdoptionPreflight
|
|
from .repopulse_target_credential import (
|
|
RepoPulseTargetCredentialError,
|
|
get_or_create_repopulse_target_credential,
|
|
)
|
|
from .setup_service import apply_stored_connector_settings
|
|
|
|
_HANDOFF_KEY = "repopulse_adoption:controller_handoff"
|
|
_BOOTSTRAP_SECRET_MINIMUM = 32
|
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
|
_CAPABILITIES_VERSION = "itworx.repopulse-adoption-capabilities/v1"
|
|
_RESERVE_VERSION = "itworx.repopulse-adoption-reserve/v1"
|
|
_STATUS_VERSION = "itworx.repopulse-adoption-status/v1"
|
|
_EXECUTION_RESULT_VERSION = "itworx.repopulse-controller-adoption-execution-result/v1"
|
|
_REGISTRY_RECEIPT_VERSION = "itworx.repopulse-controller-adoption-store-receipt/v1"
|
|
_COMPLETION_VERSION = "itworx.repopulse-controller-adoption-completion/v1"
|
|
_LEGACY_VOLUME = "repopulse_data"
|
|
_MAX_CONTROLLER_RESULT_BYTES = 256 * 1024
|
|
_RECOVERY_STATUS_PHASES = {
|
|
"reserved",
|
|
"target_gates_ready",
|
|
"quiesced",
|
|
"snapshot_verified",
|
|
}
|
|
_COPY_KEYS = {
|
|
"intentId",
|
|
"snapshotFileSha256",
|
|
"targetLogicalSha256",
|
|
"targetScanCount",
|
|
"targetCompactedScanCount",
|
|
"targetQueueSha256",
|
|
"sourceAuxiliaryTreeSha256",
|
|
"targetAuxiliaryTreeSha256",
|
|
"sourceAuxiliaryFileCount",
|
|
"targetAuxiliaryFileCount",
|
|
"targetQuickCheck",
|
|
"targetIntegrityCheck",
|
|
"targetDataUid",
|
|
"targetDataGid",
|
|
"targetRecoveryStatus",
|
|
"managedDataMountMatches",
|
|
"copySourceIsVerifiedSnapshot",
|
|
"legacyVolumeRetained",
|
|
"legacyEnvironmentRetained",
|
|
"managedEnvironmentFilePrivate",
|
|
}
|
|
_DEPLOYMENT_RECEIPT_KEYS = {
|
|
"repository",
|
|
"revision",
|
|
"containerRevision",
|
|
"containerIdSha256",
|
|
"deploymentReceiptSchema",
|
|
"deploymentReceiptSha256",
|
|
"deploymentReceiptVerified",
|
|
"runtimeHealthy",
|
|
"publishedHostPorts",
|
|
"managedDataMountMatches",
|
|
"targetDataUid",
|
|
"targetDataGid",
|
|
"targetLogicalSha256",
|
|
"targetScanCount",
|
|
"targetCompactedScanCount",
|
|
"targetQueueSha256",
|
|
"targetAuxiliaryTreeSha256",
|
|
"targetAuxiliaryFileCount",
|
|
"targetQuickCheck",
|
|
"targetIntegrityCheck",
|
|
"targetRecoveryStatus",
|
|
"targetQueuedJobs",
|
|
"targetActiveJobs",
|
|
"legacyVolume",
|
|
"legacyVolumeRetained",
|
|
"legacyEnvironmentRetained",
|
|
"managedEnvironmentFilePrivate",
|
|
"scanSourceMatches",
|
|
"scanMountReadOnly",
|
|
"scanPermissionPolicyPersistent",
|
|
"scanAccessForRuntimeIdentityReady",
|
|
"containerName",
|
|
"containerImageSha256",
|
|
"snapshotFileSha256",
|
|
}
|
|
_REGISTRY_RECEIPT_KEYS = {
|
|
"apiVersion",
|
|
"intentId",
|
|
"repository",
|
|
"applicationId",
|
|
"phase",
|
|
"intentBindingSha256",
|
|
"legacyVolumeRetained",
|
|
"intentDurable",
|
|
"managedEnrollmentId",
|
|
"projectsSha256",
|
|
"snapshotFileSha256",
|
|
"receiptSha256",
|
|
}
|
|
_COMPLETION_KEYS = {
|
|
"apiVersion",
|
|
"intentId",
|
|
"intentBindingSha256",
|
|
"controllerRegistrationReceiptSha256",
|
|
"mainRevision",
|
|
"deploymentReceiptSha256",
|
|
"snapshotFileSha256",
|
|
"snapshotLogicalSha256",
|
|
"snapshotQueueSha256",
|
|
"auxiliaryTreeSha256",
|
|
"containerIdSha256",
|
|
"containerImageSha256",
|
|
"legacyVolume",
|
|
"legacyVolumeRetained",
|
|
"completionSha256",
|
|
}
|
|
_RESULT_KEYS = {
|
|
"apiVersion",
|
|
"repository",
|
|
"applicationId",
|
|
"intentId",
|
|
"status",
|
|
"phase",
|
|
"legacyVolume",
|
|
"legacyVolumeRetained",
|
|
"targetGates",
|
|
"exactShaValidation",
|
|
"quiescence",
|
|
"snapshot",
|
|
"copy",
|
|
"registryReceipt",
|
|
"deploymentReceipt",
|
|
"completion",
|
|
}
|
|
_STATUS_HASH_KEYS = {
|
|
"intentSha256",
|
|
"intentBindingSha256",
|
|
"idempotencySha256",
|
|
"preflightSha256",
|
|
"expectedProjectsSha256",
|
|
"quiescenceSha256",
|
|
"planSha256",
|
|
"snapshotFileSha256",
|
|
"snapshotLogicalSha256",
|
|
"snapshotQueueSha256",
|
|
"targetProjectsSha256",
|
|
"receiptSha256",
|
|
}
|
|
|
|
|
|
class RepoPulseAdoptionRunnerError(ValueError):
|
|
"""Sanitized fixed-runner failure."""
|
|
|
|
def __init__(self, code: str):
|
|
super().__init__(code)
|
|
self.code = code
|
|
|
|
|
|
def _need(condition: bool, code: str) -> None:
|
|
if not condition:
|
|
raise RepoPulseAdoptionRunnerError(code)
|
|
|
|
|
|
def _sha(value: object) -> bool:
|
|
return isinstance(value, str) and _SHA256.fullmatch(value) is not None
|
|
|
|
|
|
def _count(value: object) -> bool:
|
|
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
|
|
|
|
def _canonical_sha(value: object) -> str:
|
|
try:
|
|
encoded = json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
allow_nan=False,
|
|
).encode("ascii")
|
|
except (TypeError, ValueError, UnicodeEncodeError):
|
|
raise RepoPulseAdoptionRunnerError("controller_evidence_encoding_invalid") from None
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _controller_document_sha(value: object) -> str:
|
|
try:
|
|
encoded = (
|
|
json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
allow_nan=False,
|
|
)
|
|
+ "\n"
|
|
).encode("ascii")
|
|
except (TypeError, ValueError, UnicodeEncodeError):
|
|
raise RepoPulseAdoptionRunnerError("controller_evidence_encoding_invalid") from None
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _safe_error_code(value: object, fallback: str) -> str:
|
|
code = getattr(value, "code", None)
|
|
if isinstance(code, str) and re.fullmatch(r"[a-z0-9_]{1,64}", code):
|
|
return code
|
|
return fallback
|
|
|
|
|
|
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError("duplicate_json_key")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _reject_json_constant(_: str) -> None:
|
|
raise ValueError("non_finite_json_number")
|
|
|
|
|
|
def _read_controller_result_stdin() -> object:
|
|
stream = getattr(sys.stdin, "buffer", None)
|
|
if stream is None:
|
|
raw = sys.stdin.read(_MAX_CONTROLLER_RESULT_BYTES + 1).encode("utf-8")
|
|
else:
|
|
raw = stream.read(_MAX_CONTROLLER_RESULT_BYTES + 1)
|
|
_need(
|
|
isinstance(raw, bytes) and 0 < len(raw) <= _MAX_CONTROLLER_RESULT_BYTES,
|
|
"controller_result_input_invalid",
|
|
)
|
|
try:
|
|
return json.loads(
|
|
raw.decode("utf-8"),
|
|
object_pairs_hook=_unique_json_object,
|
|
parse_constant=_reject_json_constant,
|
|
)
|
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
raise RepoPulseAdoptionRunnerError("controller_result_input_invalid") from None
|
|
|
|
|
|
def _safe_status(status: dict[str, Any], *, reason: str | None = None) -> dict[str, Any]:
|
|
result = {
|
|
key: status.get(key)
|
|
for key in ("status", "phase", "revision", "intentId")
|
|
if key in status
|
|
}
|
|
if reason is not None:
|
|
result["reason"] = reason
|
|
elif status.get("status") == "recovery_required" and isinstance(
|
|
status.get("recoveryReason"), str
|
|
):
|
|
result["reason"] = status["recoveryReason"]
|
|
return result
|
|
|
|
|
|
def _trust_and_bootstrap(session: Session) -> tuple[EnrollmentTrustConfigV1, str]:
|
|
row = session.get(AppSetting, TRUST_SETTING_KEY)
|
|
_need(row is not None and row.is_secret is False, "enrollment_trust_unavailable")
|
|
try:
|
|
trust = EnrollmentTrustConfigV1.model_validate(row.value_json)
|
|
except (TypeError, ValueError):
|
|
raise RepoPulseAdoptionRunnerError("enrollment_trust_invalid") from None
|
|
_need(
|
|
trust.controller_identity == "unraid-controller"
|
|
and "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",
|
|
)
|
|
value = secret_store.get_secret(session, trust.bootstrap_secret_ref, touch=False)
|
|
_need(
|
|
isinstance(value, str)
|
|
and len(value) >= _BOOTSTRAP_SECRET_MINIMUM
|
|
and value.isascii()
|
|
and value.isprintable(),
|
|
"controller_bootstrap_secret_unavailable",
|
|
)
|
|
return trust, value
|
|
|
|
|
|
def _validate_transport(broker: DeploymentBrokerClient) -> None:
|
|
parsed = urlparse(str(getattr(broker, "base_url", "")))
|
|
_need(
|
|
parsed.scheme == "https"
|
|
and bool(parsed.netloc)
|
|
and getattr(broker, "verify_tls", False) is True
|
|
and broker.configuration_error() is None,
|
|
"controller_private_tls_required",
|
|
)
|
|
|
|
|
|
def _validate_capabilities(value: object) -> None:
|
|
expected = {
|
|
"apiVersion",
|
|
"status",
|
|
"repository",
|
|
"adoptionStore",
|
|
"privateTls",
|
|
"controller",
|
|
}
|
|
_need(isinstance(value, dict) and set(value) == expected, "controller_capabilities_invalid")
|
|
_need(
|
|
value.get("apiVersion") == _CAPABILITIES_VERSION
|
|
and value.get("status") == "ready"
|
|
and value.get("repository") == protocol.REPOSITORY
|
|
and value.get("adoptionStore") == {"executionEnabled": True}
|
|
and value.get("privateTls") == {"status": "ready"}
|
|
and value.get("controller")
|
|
== {"aliases": "ready", "registry": "ready", "secretRoot": "ready"},
|
|
"controller_capabilities_not_ready",
|
|
)
|
|
|
|
|
|
def _validate_reservation(value: object, intent: dict[str, Any]) -> str:
|
|
expected = {
|
|
"apiVersion",
|
|
"status",
|
|
"intentId",
|
|
"phase",
|
|
"intentSha256",
|
|
"intentBindingSha256",
|
|
}
|
|
_need(isinstance(value, dict) and set(value) == expected, "controller_reservation_invalid")
|
|
binding = protocol.intent_binding_sha256(intent)
|
|
_need(
|
|
value.get("apiVersion") == _RESERVE_VERSION
|
|
and value.get("status") == "reserved"
|
|
and value.get("intentId") == intent["intentId"]
|
|
and value.get("phase") == "reserved"
|
|
and _sha(value.get("intentSha256"))
|
|
and value.get("intentBindingSha256") == binding,
|
|
"controller_reservation_unbound",
|
|
)
|
|
return value["intentSha256"]
|
|
|
|
|
|
def _read_handoff(session: Session) -> dict[str, Any] | None:
|
|
row = session.get(AppSetting, _HANDOFF_KEY, populate_existing=True)
|
|
if row is None:
|
|
return None
|
|
expected = {
|
|
"intentId",
|
|
"idempotencySha256",
|
|
"preflightSha256",
|
|
"intentBindingSha256",
|
|
"controllerIntentSha256",
|
|
"state",
|
|
}
|
|
value = row.value_json
|
|
if (
|
|
row.is_secret is not False
|
|
or not isinstance(value, dict)
|
|
or set(value) != expected
|
|
or value.get("state")
|
|
not in {
|
|
"reserve_attempted",
|
|
"handoff_prepared",
|
|
"controller_cli_handoff_issued",
|
|
}
|
|
or not isinstance(value.get("intentId"), str)
|
|
or not _sha(value.get("idempotencySha256"))
|
|
or not _sha(value.get("preflightSha256"))
|
|
or not _sha(value.get("intentBindingSha256"))
|
|
or (
|
|
value.get("state") == "reserve_attempted"
|
|
and value.get("controllerIntentSha256") is not None
|
|
)
|
|
or (
|
|
value.get("state") in {"handoff_prepared", "controller_cli_handoff_issued"}
|
|
and not _sha(value.get("controllerIntentSha256"))
|
|
)
|
|
):
|
|
raise RepoPulseAdoptionRunnerError("controller_handoff_marker_invalid")
|
|
return value
|
|
|
|
|
|
def _handoff_record(
|
|
intent: dict[str, Any], state: str, controller_intent_sha256: str | None
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"intentId": intent["intentId"],
|
|
"idempotencySha256": intent["idempotencySha256"],
|
|
"preflightSha256": intent["preflightSha256"],
|
|
"intentBindingSha256": protocol.intent_binding_sha256(intent),
|
|
"controllerIntentSha256": controller_intent_sha256,
|
|
"state": state,
|
|
}
|
|
|
|
|
|
def _persist_reserve_attempt(session: Session, intent: dict[str, Any]) -> dict[str, Any]:
|
|
"""Persist the one fixed reserve attempt before its external POST."""
|
|
value = _handoff_record(intent, "reserve_attempted", None)
|
|
statement = sqlite_insert(AppSetting).values(
|
|
key=_HANDOFF_KEY,
|
|
value_json=value,
|
|
is_secret=False,
|
|
updated_at=utc_now(),
|
|
)
|
|
try:
|
|
session.execute(statement.on_conflict_do_nothing(index_elements=[AppSetting.key]))
|
|
session.commit()
|
|
existing = _read_handoff(session)
|
|
except SQLAlchemyError:
|
|
session.rollback()
|
|
raise RepoPulseAdoptionRunnerError("controller_handoff_marker_unavailable") from None
|
|
_need(
|
|
existing is not None
|
|
and _handoff_matches(existing, intent)
|
|
and existing.get("state")
|
|
in {"reserve_attempted", "handoff_prepared", "controller_cli_handoff_issued"},
|
|
"controller_handoff_marker_conflict",
|
|
)
|
|
return existing
|
|
|
|
|
|
def _persist_handoff(
|
|
session: Session, intent: dict[str, Any], controller_intent_sha256: str
|
|
) -> dict[str, Any]:
|
|
value = _handoff_record(intent, "handoff_prepared", controller_intent_sha256)
|
|
try:
|
|
current = _read_handoff(session)
|
|
if current is None:
|
|
session.execute(
|
|
sqlite_insert(AppSetting)
|
|
.values(
|
|
key=_HANDOFF_KEY,
|
|
value_json=value,
|
|
is_secret=False,
|
|
updated_at=utc_now(),
|
|
)
|
|
.on_conflict_do_nothing(index_elements=[AppSetting.key])
|
|
)
|
|
elif _handoff_matches(current, intent):
|
|
_need(
|
|
current.get("state") in {"reserve_attempted", "controller_cli_handoff_issued"}
|
|
or current.get("controllerIntentSha256") == controller_intent_sha256,
|
|
"controller_handoff_marker_conflict",
|
|
)
|
|
if current.get("state") == "controller_cli_handoff_issued":
|
|
_need(
|
|
current.get("controllerIntentSha256") == controller_intent_sha256,
|
|
"controller_handoff_marker_conflict",
|
|
)
|
|
value = current
|
|
else:
|
|
session.execute(
|
|
AppSetting.__table__.update()
|
|
.where(AppSetting.key == _HANDOFF_KEY)
|
|
.values(value_json=value, is_secret=False, updated_at=utc_now())
|
|
)
|
|
else:
|
|
raise RepoPulseAdoptionRunnerError("controller_handoff_marker_conflict")
|
|
session.commit()
|
|
existing = _read_handoff(session)
|
|
except SQLAlchemyError:
|
|
session.rollback()
|
|
raise RepoPulseAdoptionRunnerError("controller_handoff_marker_unavailable") from None
|
|
_need(existing == value, "controller_handoff_marker_conflict")
|
|
return value
|
|
|
|
|
|
def _persist_cli_handoff_issued(
|
|
session: Session, intent: dict[str, Any], controller_intent_sha256: str
|
|
) -> dict[str, Any]:
|
|
"""Consume the one secret-bearing pipe handoff before returning it."""
|
|
current = _read_handoff(session)
|
|
_need(
|
|
current is not None
|
|
and _handoff_matches(current, intent)
|
|
and current.get("state") == "handoff_prepared"
|
|
and current.get("controllerIntentSha256") == controller_intent_sha256,
|
|
"controller_cli_handoff_replay_requires_recovery",
|
|
)
|
|
value = _handoff_record(
|
|
intent, "controller_cli_handoff_issued", controller_intent_sha256
|
|
)
|
|
try:
|
|
result = session.execute(
|
|
AppSetting.__table__.update()
|
|
.where(
|
|
AppSetting.key == _HANDOFF_KEY,
|
|
AppSetting.value_json == current,
|
|
)
|
|
.values(value_json=value, is_secret=False, updated_at=utc_now())
|
|
)
|
|
_need(result.rowcount == 1, "controller_cli_handoff_replay_requires_recovery")
|
|
session.commit()
|
|
existing = _read_handoff(session)
|
|
except SQLAlchemyError:
|
|
session.rollback()
|
|
raise RepoPulseAdoptionRunnerError("controller_cli_handoff_marker_unavailable") from None
|
|
_need(existing == value, "controller_cli_handoff_marker_conflict")
|
|
return value
|
|
|
|
|
|
def _status_projection(value: object) -> dict[str, Any]:
|
|
expected = {"apiVersion", "status", "phase", "hashes"}
|
|
_need(isinstance(value, dict) and set(value) == expected, "controller_status_invalid")
|
|
hashes = value.get("hashes")
|
|
_need(
|
|
value.get("apiVersion") == _STATUS_VERSION
|
|
and isinstance(value.get("status"), str)
|
|
and value.get("status") in {"in_progress", "recovery_required", "not_reserved"}
|
|
and (value.get("phase") is None or value.get("phase") in _RECOVERY_STATUS_PHASES)
|
|
and isinstance(hashes, dict)
|
|
and set(hashes).issubset(_STATUS_HASH_KEYS)
|
|
and all(item is None or _sha(item) for item in hashes.values()),
|
|
"controller_status_invalid",
|
|
)
|
|
if value["status"] == "not_reserved":
|
|
_need(value.get("phase") is None and hashes == {}, "controller_status_invalid")
|
|
else:
|
|
_need(value.get("phase") in _RECOVERY_STATUS_PHASES, "controller_status_invalid")
|
|
required = {
|
|
"intentSha256",
|
|
"intentBindingSha256",
|
|
"idempotencySha256",
|
|
"preflightSha256",
|
|
"expectedProjectsSha256",
|
|
}
|
|
_need(required.issubset(hashes), "controller_status_invalid")
|
|
return value
|
|
|
|
|
|
def _status_failure_evidence(value: object) -> dict[str, Any]:
|
|
try:
|
|
checked = _status_projection(value)
|
|
except RepoPulseAdoptionRunnerError:
|
|
return {"controllerStatus": "unavailable"}
|
|
return {
|
|
"controllerStatus": checked["status"],
|
|
"controllerPhase": checked["phase"],
|
|
"statusHashes": checked["hashes"],
|
|
}
|
|
|
|
|
|
def _validate_registry_receipt(value: object, intent: dict[str, Any]) -> dict[str, Any]:
|
|
_need(isinstance(value, dict) and set(value) == _REGISTRY_RECEIPT_KEYS, "controller_registry_receipt_invalid")
|
|
receipt = dict(value)
|
|
digest = receipt.pop("receiptSha256")
|
|
_need(
|
|
receipt.get("apiVersion") == _REGISTRY_RECEIPT_VERSION
|
|
and receipt.get("intentId") == intent["intentId"]
|
|
and receipt.get("repository") == protocol.REPOSITORY
|
|
and receipt.get("applicationId") == protocol.APPLICATION_ID
|
|
and receipt.get("phase") == "registry_committed"
|
|
and receipt.get("intentBindingSha256") == protocol.intent_binding_sha256(intent)
|
|
and receipt.get("legacyVolumeRetained") is True
|
|
and receipt.get("intentDurable") is True
|
|
and receipt.get("managedEnrollmentId") == f"enr-{intent['intentId'][4:]}"
|
|
and _sha(receipt.get("projectsSha256"))
|
|
and _sha(receipt.get("snapshotFileSha256"))
|
|
and _sha(digest)
|
|
and digest == _controller_document_sha(receipt),
|
|
"controller_registry_receipt_invalid",
|
|
)
|
|
receipt["receiptSha256"] = digest
|
|
return receipt
|
|
|
|
|
|
def _validate_copy(value: object, snapshot: dict[str, Any], intent_id: str) -> dict[str, Any]:
|
|
_need(isinstance(value, dict) and set(value) == _COPY_KEYS, "controller_copy_evidence_invalid")
|
|
evidence = dict(value)
|
|
_need(
|
|
evidence.get("intentId") == intent_id
|
|
and evidence.get("snapshotFileSha256") == snapshot["snapshotFileSha256"]
|
|
and evidence.get("targetLogicalSha256") == snapshot["snapshotLogicalSha256"]
|
|
and _count(evidence.get("targetScanCount"))
|
|
and evidence.get("targetScanCount") == snapshot["snapshotScanCount"]
|
|
and _count(evidence.get("targetCompactedScanCount"))
|
|
and evidence.get("targetCompactedScanCount") == snapshot["snapshotCompactedScanCount"]
|
|
and evidence.get("targetQueueSha256") == snapshot["snapshotQueueSha256"]
|
|
and evidence.get("sourceAuxiliaryTreeSha256") == snapshot["sourceAuxiliaryTreeSha256"]
|
|
and evidence.get("targetAuxiliaryTreeSha256") == snapshot["sourceAuxiliaryTreeSha256"]
|
|
and _count(evidence.get("sourceAuxiliaryFileCount"))
|
|
and evidence.get("sourceAuxiliaryFileCount") == snapshot["sourceAuxiliaryFileCount"]
|
|
and _count(evidence.get("targetAuxiliaryFileCount"))
|
|
and evidence.get("targetAuxiliaryFileCount") == snapshot["sourceAuxiliaryFileCount"]
|
|
and evidence.get("targetQuickCheck") == "ok"
|
|
and evidence.get("targetIntegrityCheck") == "ok"
|
|
and evidence.get("targetDataUid") == 10001
|
|
and evidence.get("targetDataGid") == 10001
|
|
and evidence.get("targetRecoveryStatus") in {"healthy", "acknowledged"}
|
|
and evidence.get("managedDataMountMatches") is True
|
|
and evidence.get("copySourceIsVerifiedSnapshot") is True
|
|
and evidence.get("legacyVolumeRetained") is True
|
|
and evidence.get("legacyEnvironmentRetained") is True
|
|
and evidence.get("managedEnvironmentFilePrivate") is True,
|
|
"controller_copy_evidence_unverified",
|
|
)
|
|
return evidence
|
|
|
|
|
|
def _validate_deployment_receipt(
|
|
value: object, intent: dict[str, Any], snapshot: dict[str, Any], copied: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
_need(isinstance(value, dict) and set(value) == _DEPLOYMENT_RECEIPT_KEYS, "controller_deployment_receipt_invalid")
|
|
receipt = dict(value)
|
|
revision = intent["evidence"]["preflight"]["main_revision"]
|
|
_need(
|
|
receipt.get("repository") == protocol.REPOSITORY
|
|
and receipt.get("revision") == revision
|
|
and receipt.get("containerRevision") == revision
|
|
and _sha(receipt.get("containerIdSha256"))
|
|
and receipt.get("deploymentReceiptSchema") == "gitea-deploy-receipt.v1"
|
|
and _sha(receipt.get("deploymentReceiptSha256"))
|
|
and receipt.get("deploymentReceiptVerified") is True
|
|
and receipt.get("runtimeHealthy") is True
|
|
and receipt.get("publishedHostPorts") == []
|
|
and receipt.get("managedDataMountMatches") is True
|
|
and receipt.get("targetDataUid") == copied["targetDataUid"]
|
|
and receipt.get("targetDataGid") == copied["targetDataGid"]
|
|
and receipt.get("targetLogicalSha256") == copied["targetLogicalSha256"]
|
|
and receipt.get("targetScanCount") == copied["targetScanCount"]
|
|
and receipt.get("targetCompactedScanCount") == copied["targetCompactedScanCount"]
|
|
and receipt.get("targetQueueSha256") == copied["targetQueueSha256"]
|
|
and receipt.get("targetAuxiliaryTreeSha256") == copied["targetAuxiliaryTreeSha256"]
|
|
and receipt.get("targetAuxiliaryFileCount") == copied["targetAuxiliaryFileCount"]
|
|
and receipt.get("targetQuickCheck") == copied["targetQuickCheck"]
|
|
and receipt.get("targetIntegrityCheck") == copied["targetIntegrityCheck"]
|
|
and receipt.get("targetRecoveryStatus") == copied["targetRecoveryStatus"]
|
|
and receipt.get("targetQueuedJobs") == 0
|
|
and receipt.get("targetActiveJobs") == 0
|
|
and receipt.get("legacyVolume") == _LEGACY_VOLUME
|
|
and receipt.get("legacyVolumeRetained") is True
|
|
and receipt.get("legacyEnvironmentRetained") is True
|
|
and receipt.get("managedEnvironmentFilePrivate") is True
|
|
and receipt.get("scanSourceMatches") is True
|
|
and receipt.get("scanMountReadOnly") is True
|
|
and receipt.get("scanPermissionPolicyPersistent") is True
|
|
and receipt.get("scanAccessForRuntimeIdentityReady") is True
|
|
and receipt.get("containerName") == "repopulse"
|
|
and _sha(receipt.get("containerImageSha256"))
|
|
and receipt.get("snapshotFileSha256") == snapshot["snapshotFileSha256"],
|
|
"controller_deployment_receipt_unverified",
|
|
)
|
|
for key in ("targetScanCount", "targetCompactedScanCount", "targetAuxiliaryFileCount", "targetQueuedJobs", "targetActiveJobs"):
|
|
_need(_count(receipt.get(key)), "controller_deployment_receipt_invalid")
|
|
return receipt
|
|
|
|
|
|
def _validate_completion(
|
|
value: object,
|
|
intent: dict[str, Any],
|
|
snapshot: dict[str, Any],
|
|
copied: dict[str, Any],
|
|
registry: dict[str, Any],
|
|
deployment: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
_need(isinstance(value, dict) and set(value) == _COMPLETION_KEYS, "controller_completion_invalid")
|
|
completion = dict(value)
|
|
digest = completion.pop("completionSha256")
|
|
_need(
|
|
completion.get("apiVersion") == _COMPLETION_VERSION
|
|
and completion.get("intentId") == intent["intentId"]
|
|
and completion.get("intentBindingSha256") == protocol.intent_binding_sha256(intent)
|
|
and completion.get("controllerRegistrationReceiptSha256") == registry["receiptSha256"]
|
|
and completion.get("mainRevision") == intent["evidence"]["preflight"]["main_revision"]
|
|
and completion.get("deploymentReceiptSha256") == deployment["deploymentReceiptSha256"]
|
|
and completion.get("snapshotFileSha256") == snapshot["snapshotFileSha256"]
|
|
and completion.get("snapshotLogicalSha256") == snapshot["snapshotLogicalSha256"]
|
|
and completion.get("snapshotQueueSha256") == snapshot["snapshotQueueSha256"]
|
|
and completion.get("auxiliaryTreeSha256") == copied["targetAuxiliaryTreeSha256"]
|
|
and completion.get("containerIdSha256") == deployment["containerIdSha256"]
|
|
and completion.get("containerImageSha256") == deployment["containerImageSha256"]
|
|
and completion.get("legacyVolume") == _LEGACY_VOLUME
|
|
and completion.get("legacyVolumeRetained") is True
|
|
and _sha(digest)
|
|
and digest == _canonical_sha(completion),
|
|
"controller_completion_binding_invalid",
|
|
)
|
|
completion["completionSha256"] = digest
|
|
return completion
|
|
|
|
|
|
def _map_controller_result(
|
|
value: object, intent: dict[str, Any]
|
|
) -> tuple[dict[str, dict[str, Any]], str, str]:
|
|
_need(isinstance(value, dict) and set(value) == _RESULT_KEYS, "controller_result_envelope_invalid")
|
|
result = value
|
|
_need(
|
|
result.get("apiVersion") == _EXECUTION_RESULT_VERSION
|
|
and result.get("repository") == protocol.REPOSITORY
|
|
and result.get("applicationId") == protocol.APPLICATION_ID
|
|
and result.get("intentId") == intent["intentId"]
|
|
and result.get("status") == "deployment_verified"
|
|
and result.get("phase") == "completion_verified"
|
|
and result.get("legacyVolume") == _LEGACY_VOLUME
|
|
and result.get("legacyVolumeRetained") is True,
|
|
"controller_result_identity_invalid",
|
|
)
|
|
gates = result.get("targetGates")
|
|
quiescence = result.get("quiescence")
|
|
snapshot = result.get("snapshot")
|
|
_need(isinstance(gates, dict), "controller_target_gates_invalid")
|
|
_need(isinstance(quiescence, dict), "controller_quiescence_invalid")
|
|
_need(isinstance(snapshot, dict), "controller_snapshot_invalid")
|
|
|
|
validation = result.get("exactShaValidation")
|
|
_need(
|
|
isinstance(validation, dict)
|
|
and set(validation) == {"repository", "revision", "contexts"}
|
|
and validation.get("repository") == protocol.REPOSITORY
|
|
and validation.get("revision") == intent["evidence"]["preflight"]["main_revision"]
|
|
and validation.get("contexts") == {"Managed validation / full (push)": "success"},
|
|
"controller_exact_sha_validation_invalid",
|
|
)
|
|
|
|
checked_registry = _validate_registry_receipt(result.get("registryReceipt"), intent)
|
|
_need(
|
|
checked_registry["snapshotFileSha256"] == snapshot.get("snapshotFileSha256"),
|
|
"controller_registry_snapshot_mismatch",
|
|
)
|
|
copied = _validate_copy(result.get("copy"), snapshot, intent["intentId"])
|
|
deployment = _validate_deployment_receipt(
|
|
result.get("deploymentReceipt"), intent, snapshot, copied
|
|
)
|
|
_need(
|
|
copied["managedDataMountMatches"] is True
|
|
and deployment["managedDataMountMatches"] is True,
|
|
"controller_data_mount_mismatch",
|
|
)
|
|
completion = _validate_completion(
|
|
result.get("completion"),
|
|
intent,
|
|
snapshot,
|
|
copied,
|
|
checked_registry,
|
|
deployment,
|
|
)
|
|
|
|
registration = {
|
|
"receiptVersion": protocol.CONTROLLER_RECEIPT_VERSION,
|
|
"intentId": intent["intentId"],
|
|
# The completion receipt binds the registry, copied data, deployment,
|
|
# and live container proof as one immutable controller result.
|
|
"receiptSha256": completion["completionSha256"],
|
|
"repository": protocol.REPOSITORY,
|
|
"applicationId": protocol.APPLICATION_ID,
|
|
"enrollmentId": checked_registry["managedEnrollmentId"],
|
|
"profileId": gates.get("profileId"),
|
|
"planSha256": gates.get("planSha256"),
|
|
"snapshotFileSha256": snapshot.get("snapshotFileSha256"),
|
|
"targetLogicalSha256": copied["targetLogicalSha256"],
|
|
"targetScanCount": copied["targetScanCount"],
|
|
"targetCompactedScanCount": copied["targetCompactedScanCount"],
|
|
"targetQueueSha256": copied["targetQueueSha256"],
|
|
"targetAuxiliaryTreeSha256": copied["targetAuxiliaryTreeSha256"],
|
|
"targetAuxiliaryFileCount": copied["targetAuxiliaryFileCount"],
|
|
"targetQueuedJobs": deployment["targetQueuedJobs"],
|
|
"targetActiveJobs": deployment["targetActiveJobs"],
|
|
"targetQuickCheck": copied["targetQuickCheck"],
|
|
"targetIntegrityCheck": copied["targetIntegrityCheck"],
|
|
"targetRecoveryStatus": copied["targetRecoveryStatus"],
|
|
"targetDataUid": copied["targetDataUid"],
|
|
"targetDataGid": copied["targetDataGid"],
|
|
"managedDataMountMatches": deployment["managedDataMountMatches"],
|
|
"copySourceIsVerifiedSnapshot": copied["copySourceIsVerifiedSnapshot"],
|
|
"registryTargetMatches": (
|
|
checked_registry["managedEnrollmentId"] == f"enr-{intent['intentId'][4:]}"
|
|
and checked_registry["snapshotFileSha256"] == snapshot["snapshotFileSha256"]
|
|
),
|
|
"controllerStateReceiptVerified": checked_registry["receiptSha256"]
|
|
== completion["controllerRegistrationReceiptSha256"],
|
|
"deploymentReceiptSchema": deployment["deploymentReceiptSchema"],
|
|
"deploymentReceiptSha256": deployment["deploymentReceiptSha256"],
|
|
"deploymentReceiptVerified": deployment["deploymentReceiptVerified"],
|
|
"runtimeHealthy": deployment["runtimeHealthy"],
|
|
"publishedHostPorts": deployment["publishedHostPorts"],
|
|
"globalDeployLockHeld": quiescence.get("globalDeployLockHeld"),
|
|
"projectLockHeld": quiescence.get("projectLockHeld"),
|
|
"legacyVolume": deployment["legacyVolume"],
|
|
"legacyVolumeRetained": deployment["legacyVolumeRetained"],
|
|
"legacyEnvironmentRetained": deployment["legacyEnvironmentRetained"],
|
|
"managedEnvironmentFilePrivate": deployment["managedEnvironmentFilePrivate"],
|
|
"scanSourceMatches": deployment["scanSourceMatches"],
|
|
"scanMountReadOnly": deployment["scanMountReadOnly"],
|
|
"scanPermissionPolicyPersistent": deployment["scanPermissionPolicyPersistent"],
|
|
"scanAccessForRuntimeIdentityReady": deployment[
|
|
"scanAccessForRuntimeIdentityReady"
|
|
],
|
|
"controllerIntentDurable": quiescence.get("controllerIntentDurable"),
|
|
"controllerIntentBindingSha256": quiescence.get(
|
|
"controllerIntentBindingSha256"
|
|
),
|
|
}
|
|
|
|
# Run the exact pure state machine in memory before committing any
|
|
# checkpoints. This also verifies the result against the fresh preflight.
|
|
candidate = protocol.advance_intent(intent, "target_gates", gates)
|
|
candidate = protocol.advance_intent(candidate, "quiescence", quiescence)
|
|
candidate = protocol.advance_intent(candidate, "snapshot", snapshot)
|
|
candidate = protocol.advance_intent(candidate, "controller_registration", registration)
|
|
return (
|
|
{
|
|
"target_gates": gates,
|
|
"quiescence": quiescence,
|
|
"snapshot": snapshot,
|
|
"controller_registration": candidate["evidence"]["controllerRegistration"],
|
|
},
|
|
checked_registry["receiptSha256"],
|
|
checked_registry["projectsSha256"],
|
|
)
|
|
|
|
|
|
def _validate_status_binding(
|
|
value: object,
|
|
intent: dict[str, Any],
|
|
registration: dict[str, Any],
|
|
*,
|
|
controller_intent_sha256: str,
|
|
registry_receipt_sha256: str,
|
|
projects_sha256: str,
|
|
) -> dict[str, Any]:
|
|
status = _status_projection(value)
|
|
hashes = status["hashes"]
|
|
snapshot = intent["evidence"]["snapshot"]
|
|
gates = intent["evidence"]["targetGates"]
|
|
_need(
|
|
status.get("status") == "in_progress"
|
|
and status.get("phase") == "snapshot_verified"
|
|
and hashes.get("intentSha256") == controller_intent_sha256
|
|
and hashes.get("intentBindingSha256") == protocol.intent_binding_sha256(intent)
|
|
and hashes.get("idempotencySha256") == intent["idempotencySha256"]
|
|
and hashes.get("preflightSha256") == intent["preflightSha256"]
|
|
and hashes.get("planSha256") == gates.get("planSha256")
|
|
and hashes.get("snapshotFileSha256") == snapshot.get("snapshotFileSha256")
|
|
and hashes.get("snapshotLogicalSha256") == snapshot.get("snapshotLogicalSha256")
|
|
and hashes.get("snapshotQueueSha256") == snapshot.get("snapshotQueueSha256")
|
|
and hashes.get("expectedProjectsSha256") == projects_sha256
|
|
and hashes.get("targetProjectsSha256") == projects_sha256
|
|
and hashes.get("receiptSha256") == registry_receipt_sha256
|
|
and registration.get("receiptSha256") is not None,
|
|
"controller_status_binding_mismatch",
|
|
)
|
|
return status
|
|
|
|
|
|
def _failure_evidence(stage: str, status_value: object) -> dict[str, Any]:
|
|
return {"stage": stage, **_status_failure_evidence(status_value)}
|
|
|
|
|
|
def _mark_recovery(
|
|
session: Session,
|
|
status: dict[str, Any],
|
|
*,
|
|
reason: str = "unknown_external_effect",
|
|
stage: str,
|
|
controller_status: object,
|
|
) -> dict[str, Any]:
|
|
if status.get("status") == "recovery_required" or status.get("status") == "completed":
|
|
return _safe_status(status)
|
|
try:
|
|
updated = journal.mark_repopulse_adoption_recovery(
|
|
session,
|
|
reason,
|
|
_failure_evidence(stage, controller_status),
|
|
expected_revision=status.get("revision"),
|
|
)
|
|
except Exception: # noqa: BLE001 - persistence failures must become a safe recovery result.
|
|
try:
|
|
updated = journal.get_repopulse_adoption_status(session)
|
|
except Exception: # noqa: BLE001 - never return database exception text.
|
|
return {"status": "recovery_required", "phase": status.get("phase"), "reason": reason}
|
|
return _safe_status(updated)
|
|
|
|
|
|
class RepoPulseAdoptionRunner:
|
|
"""Operator-only prepare/finalize service for fixed RepoPulse adoption."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
preflight: RepoPulseAdoptionPreflight | None = None,
|
|
broker: DeploymentBrokerClient | None = None,
|
|
):
|
|
self.session = session
|
|
self._runtime_settings_loaded = False
|
|
self.preflight = preflight or RepoPulseAdoptionPreflight()
|
|
self.broker = broker or DeploymentBrokerClient()
|
|
|
|
def _initialize_operator_runtime(self) -> None:
|
|
if self._runtime_settings_loaded:
|
|
return
|
|
# FastAPI routes hydrate connector settings before using Portainer.
|
|
# The operator CLI has no lifespan, so perform the same bounded load.
|
|
apply_stored_connector_settings(self.session, {"portainer"})
|
|
self._runtime_settings_loaded = True
|
|
|
|
def _fresh_intent(self, idempotency_key: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
_need(
|
|
isinstance(idempotency_key, str)
|
|
and _IDEMPOTENCY_KEY.fullmatch(idempotency_key) is not None,
|
|
"idempotency_key_invalid",
|
|
)
|
|
report = self.preflight.run()
|
|
candidate = protocol.create_intent(report, idempotency_key)
|
|
local = journal.reserve_repopulse_adoption(
|
|
self.session, report, idempotency_key
|
|
)
|
|
row, intent = journal._load(self.session)
|
|
_need(row is not None and intent is not None, "adoption_intent_not_reserved")
|
|
_need(intent["intentId"] == candidate["intentId"], "adoption_intent_conflict")
|
|
return local, intent
|
|
|
|
def _controller_status(self, secret: str) -> dict[str, Any]:
|
|
return self.broker.repopulse_adoption_status(secret=secret)
|
|
|
|
def prepare(self, idempotency_key: str) -> dict[str, Any]:
|
|
"""Freshly preflight and reserve; never invoke controller cutover."""
|
|
if protocol.EXECUTION_ENABLED is not True:
|
|
return {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"}
|
|
local: dict[str, Any] | None = None
|
|
bootstrap: str | None = None
|
|
reserve_attempted = False
|
|
try:
|
|
self._initialize_operator_runtime()
|
|
local, intent = self._fresh_intent(idempotency_key)
|
|
if local.get("status") in {"completed", "recovery_required"}:
|
|
return _safe_status(local)
|
|
|
|
_validate_transport(self.broker)
|
|
_trust, bootstrap = _trust_and_bootstrap(self.session)
|
|
|
|
handoff = _read_handoff(self.session)
|
|
if handoff is not None:
|
|
if not _handoff_matches(handoff, intent):
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage="handoff_conflict",
|
|
controller_status={"status": "unavailable"},
|
|
)
|
|
remote = self._controller_status(bootstrap)
|
|
try:
|
|
remote = _validate_reservation_status(remote, intent)
|
|
except RepoPulseAdoptionRunnerError:
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage="prepare_replay",
|
|
controller_status=remote,
|
|
)
|
|
if handoff.get("state") == "reserve_attempted":
|
|
remote_intent_sha = remote["hashes"].get("intentSha256")
|
|
if not _sha(remote_intent_sha):
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage="prepare_replay",
|
|
controller_status=remote,
|
|
)
|
|
_persist_handoff(self.session, intent, remote_intent_sha)
|
|
return {**_safe_status(local), "status": "prepared"}
|
|
|
|
if local.get("phase") != "reserved":
|
|
remote = self._controller_status(bootstrap)
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage="handoff_missing",
|
|
controller_status=remote,
|
|
)
|
|
|
|
capabilities = self.broker.repopulse_adoption_capabilities(
|
|
secret=bootstrap
|
|
)
|
|
_validate_capabilities(capabilities)
|
|
_persist_reserve_attempt(self.session, intent)
|
|
reserve_attempted = True
|
|
try:
|
|
reservation = self.broker.repopulse_adoption_reserve(
|
|
secret=bootstrap,
|
|
intent_id=intent["intentId"],
|
|
idempotency_sha256=intent["idempotencySha256"],
|
|
preflight_sha256=intent["preflightSha256"],
|
|
)
|
|
except Exception: # noqa: BLE001 - reserve may have committed remotely.
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
if isinstance(reservation, dict) and reservation.get("status") == "execution_disabled":
|
|
return {**_safe_status(local), "status": "blocked", "reason": "controller_execution_disabled"}
|
|
try:
|
|
controller_intent_sha = _validate_reservation(reservation, intent)
|
|
enrollment_id, target_credential = get_or_create_repopulse_target_credential(
|
|
self.session, intent_id=intent["intentId"]
|
|
)
|
|
del target_credential
|
|
_need(
|
|
enrollment_id == f"enr-{intent['intentId'][4:]}",
|
|
"target_credential_binding_mismatch",
|
|
)
|
|
_persist_handoff(self.session, intent, controller_intent_sha)
|
|
except Exception: # noqa: BLE001 - the durable reserve may precede local failure.
|
|
# The controller may have durably reserved before this process
|
|
# failed; reconcile through status and never POST reserve again.
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
return {**_safe_status(local), "status": "prepared"}
|
|
except RepoPulseAdoptionRunnerError as error:
|
|
if reserve_attempted and local is not None:
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
return {"status": "blocked", "reason": error.code}
|
|
except (journal.RepoPulseAdoptionJournalError, protocol.AdoptionProtocolError) as error:
|
|
if reserve_attempted and local is not None:
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
return {"status": "blocked", "reason": _safe_error_code(error, "adoption_prepare_failed")}
|
|
except RepoPulseTargetCredentialError as error:
|
|
if reserve_attempted and local is not None:
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
return {"status": "blocked", "reason": _safe_error_code(error, "target_credential_unavailable")}
|
|
except Exception: # noqa: BLE001 - transport and DB exceptions may contain credentials.
|
|
# HTTP clients and drivers can include credentials in exception
|
|
# text; this CLI boundary deliberately emits only fixed codes.
|
|
try:
|
|
local = local or journal.get_repopulse_adoption_status(self.session)
|
|
except Exception: # noqa: BLE001 - status read failure must not expose exception text.
|
|
return {"status": "blocked", "reason": "adoption_prepare_failed"}
|
|
if reserve_attempted and local.get("status") == "in_progress":
|
|
return self._uncertain_prepare(local, "controller_reserve", bootstrap)
|
|
return _safe_status(local, reason="adoption_prepare_failed")
|
|
|
|
def _uncertain_prepare(
|
|
self, local: dict[str, Any], stage: str, bootstrap: str | None
|
|
) -> dict[str, Any]:
|
|
remote: object = {"status": "unavailable"}
|
|
if bootstrap is not None:
|
|
try:
|
|
remote = self._controller_status(bootstrap)
|
|
except Exception: # noqa: BLE001 - record unavailable without logging transport details.
|
|
remote = {"status": "unavailable"}
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage=stage,
|
|
controller_status=remote,
|
|
)
|
|
|
|
def prepare_controller_cli_handoff(self, idempotency_key: str) -> dict[str, Any]:
|
|
"""Return the exact in-memory stdin contract for the fixed root CLI.
|
|
|
|
The caller must pipe this object directly to the separately operated
|
|
controller CLI. It must not print or persist the target credential.
|
|
"""
|
|
prepared = self.prepare(idempotency_key)
|
|
_need(prepared.get("status") == "prepared", "controller_handoff_not_ready")
|
|
local = journal.get_repopulse_adoption_status(self.session)
|
|
_need(
|
|
local.get("status") == "in_progress" and local.get("phase") == "reserved",
|
|
"controller_handoff_phase_invalid",
|
|
)
|
|
row, intent = journal._load(self.session)
|
|
_need(row is not None and intent is not None, "adoption_intent_not_reserved")
|
|
marker = _read_handoff(self.session)
|
|
if marker is not None and marker.get("state") == "controller_cli_handoff_issued":
|
|
_validate_transport(self.broker)
|
|
_trust, bootstrap = _trust_and_bootstrap(self.session)
|
|
remote = self._controller_status(bootstrap)
|
|
_mark_recovery(
|
|
self.session,
|
|
local,
|
|
stage="controller_cli_handoff_replay",
|
|
controller_status=remote,
|
|
)
|
|
raise RepoPulseAdoptionRunnerError(
|
|
"controller_cli_handoff_replay_requires_recovery"
|
|
)
|
|
_need(
|
|
marker is not None
|
|
and marker.get("state") == "handoff_prepared"
|
|
and _handoff_matches(marker, intent),
|
|
"controller_handoff_marker_invalid",
|
|
)
|
|
enrollment_id, target_credential = get_or_create_repopulse_target_credential(
|
|
self.session, intent_id=intent["intentId"]
|
|
)
|
|
_need(
|
|
enrollment_id == f"enr-{intent['intentId'][4:]}"
|
|
and isinstance(target_credential, bytes)
|
|
and 32 <= len(target_credential) <= 256,
|
|
"target_credential_binding_mismatch",
|
|
)
|
|
try:
|
|
credential = target_credential.decode("ascii")
|
|
except UnicodeDecodeError:
|
|
raise RepoPulseAdoptionRunnerError("target_credential_encoding_invalid") from None
|
|
_need(credential.isprintable(), "target_credential_encoding_invalid")
|
|
projection = intent["evidence"]["preflight"]
|
|
_need(isinstance(projection, dict), "controller_handoff_preflight_invalid")
|
|
_persist_cli_handoff_issued(
|
|
self.session, intent, marker["controllerIntentSha256"]
|
|
)
|
|
return {
|
|
"intentId": intent["intentId"],
|
|
"idempotencySha256": intent["idempotencySha256"],
|
|
"preflightSha256": intent["preflightSha256"],
|
|
"preflightProjection": dict(projection),
|
|
"targetCredential": credential,
|
|
}
|
|
|
|
def finalize(self, controller_result: object) -> dict[str, Any]:
|
|
"""Validate root-controller evidence, checkpoint it, and commit ownership.
|
|
|
|
The caller must obtain ``controller_result`` from the trusted fixed
|
|
controller handoff. This method validates every field and confirms
|
|
the matching controller journal over CA-verified HTTPS/HMAC before
|
|
AppOps writes ownership.
|
|
"""
|
|
if protocol.EXECUTION_ENABLED is not True:
|
|
return {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"}
|
|
try:
|
|
local = journal.get_repopulse_adoption_status(self.session)
|
|
if local.get("status") == "completed":
|
|
return _safe_status(local)
|
|
_need(local.get("status") == "in_progress", "adoption_not_ready_to_finalize")
|
|
_need(local.get("phase") == "reserved", "adoption_partial_finalize_requires_recovery")
|
|
row, intent = journal._load(self.session)
|
|
_need(row is not None and intent is not None, "adoption_intent_not_reserved")
|
|
handoff = _read_handoff(self.session)
|
|
_need(
|
|
handoff is not None
|
|
and handoff.get("state") == "controller_cli_handoff_issued"
|
|
and _handoff_matches(handoff, intent),
|
|
"controller_handoff_missing",
|
|
)
|
|
_validate_transport(self.broker)
|
|
_trust, bootstrap = _trust_and_bootstrap(self.session)
|
|
|
|
mapped, registry_receipt_sha256, projects_sha256 = _map_controller_result(
|
|
controller_result, intent
|
|
)
|
|
candidate = intent
|
|
for step in ("target_gates", "quiescence", "snapshot", "controller_registration"):
|
|
candidate = protocol.advance_intent(candidate, step, mapped[step])
|
|
registration = candidate["evidence"]["controllerRegistration"]
|
|
|
|
remote = self._controller_status(bootstrap)
|
|
_validate_status_binding(
|
|
remote,
|
|
candidate,
|
|
registration,
|
|
controller_intent_sha256=handoff["controllerIntentSha256"],
|
|
registry_receipt_sha256=registry_receipt_sha256,
|
|
projects_sha256=projects_sha256,
|
|
)
|
|
|
|
current_status = local
|
|
for step in ("target_gates", "quiescence", "snapshot", "controller_registration"):
|
|
current_status = journal.advance_repopulse_adoption(
|
|
self.session,
|
|
step,
|
|
mapped[step],
|
|
expected_revision=current_status["revision"],
|
|
)
|
|
current_status = ownership.commit_repopulse_adoption_ownership(
|
|
self.session,
|
|
registration,
|
|
expected_revision=current_status["revision"],
|
|
)
|
|
current_status = ownership.complete_repopulse_adoption(
|
|
self.session,
|
|
registration,
|
|
expected_revision=current_status["revision"],
|
|
)
|
|
return _safe_status(current_status)
|
|
except Exception as error: # noqa: BLE001 - recover any uncertain side effect without echoing it.
|
|
try:
|
|
local = journal.get_repopulse_adoption_status(self.session)
|
|
except Exception: # noqa: BLE001 - never expose database exception text.
|
|
return {"status": "recovery_required", "reason": "unknown_external_effect"}
|
|
if local.get("status") == "completed":
|
|
return _safe_status(local)
|
|
try:
|
|
_trust, bootstrap = _trust_and_bootstrap(self.session)
|
|
_validate_transport(self.broker)
|
|
remote = self._controller_status(bootstrap)
|
|
except Exception: # noqa: BLE001 - query failure is represented as unavailable.
|
|
remote = {"status": "unavailable"}
|
|
code = _safe_error_code(error, "controller_result_unverified")
|
|
reason = "appops_commit_uncertain" if code == "appops_commit_uncertain" else "unknown_external_effect"
|
|
return _mark_recovery(
|
|
self.session,
|
|
local,
|
|
reason=reason,
|
|
stage="finalize",
|
|
controller_status=remote,
|
|
)
|
|
|
|
|
|
def _handoff_matches(handoff: dict[str, Any], intent: dict[str, Any]) -> bool:
|
|
return bool(
|
|
handoff.get("intentId") == intent.get("intentId")
|
|
and handoff.get("idempotencySha256") == intent.get("idempotencySha256")
|
|
and handoff.get("preflightSha256") == intent.get("preflightSha256")
|
|
and handoff.get("intentBindingSha256") == protocol.intent_binding_sha256(intent)
|
|
)
|
|
|
|
|
|
def _validate_reservation_status(value: object, intent: dict[str, Any]) -> dict[str, Any]:
|
|
status = _status_projection(value)
|
|
hashes = status["hashes"]
|
|
_need(
|
|
status["status"] == "in_progress"
|
|
and status["phase"] in _RECOVERY_STATUS_PHASES
|
|
and hashes.get("intentBindingSha256") == protocol.intent_binding_sha256(intent)
|
|
and hashes.get("idempotencySha256") == intent["idempotencySha256"]
|
|
and hashes.get("preflightSha256") == intent["preflightSha256"],
|
|
"controller_status_binding_mismatch",
|
|
)
|
|
return status
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run fixed operator phases; finalize accepts only bounded JSON stdin."""
|
|
parser = argparse.ArgumentParser(description="Operate the fixed one-time RepoPulse adoption")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
prepare_parser = commands.add_parser("prepare", help="fresh preflight and reserve")
|
|
prepare_parser.add_argument("--idempotency-key", required=True)
|
|
commands.add_parser("finalize", help="verify controller result from stdin")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
with Session(engine) as session:
|
|
runner = RepoPulseAdoptionRunner(session=session)
|
|
if protocol.EXECUTION_ENABLED is not True:
|
|
result = {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"}
|
|
elif args.command == "prepare":
|
|
result = runner.prepare(args.idempotency_key)
|
|
else:
|
|
result = runner.finalize(_read_controller_result_stdin())
|
|
except Exception: # noqa: BLE001 - CLI boundary emits only a fixed sanitized reason.
|
|
result = {"status": "blocked", "reason": "adoption_operator_command_failed"}
|
|
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
return 0 if result.get("status") in {"prepared", "completed"} else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|