1747 lines
59 KiB
Python
1747 lines
59 KiB
Python
#!/usr/bin/env python3
|
|
"""Security boundary for one-approval silent release execution.
|
|
|
|
The signed plan binds immutable release evidence. Each capability is consumed
|
|
independently so runtime deployment, tag publication, and release finalization
|
|
remain separate controllers and separately recoverable Saga steps.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Mapping, Protocol
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
|
|
from silent_release_controller import (
|
|
CANDIDATE_ID_RE,
|
|
OID_RE,
|
|
SHA256_RE,
|
|
ReleaseCandidate,
|
|
ReleaseCandidateStore,
|
|
_atomic_json,
|
|
_parse_utc,
|
|
_sanitize,
|
|
utc_now,
|
|
)
|
|
|
|
|
|
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
|
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
|
ALLOWED_CAPABILITIES = frozenset(
|
|
{
|
|
"runtime_switch",
|
|
"tag_publish",
|
|
"baseline_registry_finalize",
|
|
}
|
|
)
|
|
FORBIDDEN_CAPABILITIES = frozenset(
|
|
{
|
|
"force_push",
|
|
"tag_rewrite",
|
|
"direct_main_push",
|
|
"baseline_mutation",
|
|
"registry_mutation",
|
|
}
|
|
)
|
|
|
|
|
|
class CapabilityAuthorizationError(RuntimeError):
|
|
"""The signed plan cannot authorize the requested operation."""
|
|
|
|
|
|
class StaleApprovalError(CapabilityAuthorizationError):
|
|
"""Live release evidence no longer matches the signed approval."""
|
|
|
|
|
|
class SagaConflict(RuntimeError):
|
|
"""A stale controller attempted a non-current Saga transition."""
|
|
|
|
|
|
class JournalIntegrityError(RuntimeError):
|
|
"""The durable Saga journal is truncated, reordered, or modified."""
|
|
|
|
|
|
class ClaimProbeResult(str, Enum):
|
|
NOT_APPLIED = "NOT_APPLIED"
|
|
APPLIED_VERIFIED = "APPLIED_VERIFIED"
|
|
INDETERMINATE = "INDETERMINATE"
|
|
CONFLICT = "CONFLICT"
|
|
|
|
|
|
JOURNAL_SCHEMA_VERSION = 1
|
|
JOURNAL_GENESIS_HASH = "0" * 64
|
|
|
|
|
|
def _fsync_directory(path: Path) -> None:
|
|
descriptor = os.open(Path(path), os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def _durable_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
_atomic_json(path, dict(payload))
|
|
_fsync_directory(path.parent)
|
|
|
|
|
|
def _validate_identifier(name: str, value: str) -> str:
|
|
normalized = str(value or "")
|
|
if not IDENTIFIER_RE.fullmatch(normalized):
|
|
raise ValueError(f"invalid {name}")
|
|
return normalized
|
|
|
|
|
|
def _validate_oid(name: str, value: str) -> str:
|
|
normalized = str(value or "").lower()
|
|
if not OID_RE.fullmatch(normalized):
|
|
raise ValueError(f"{name} must be a full 40-character OID")
|
|
return normalized
|
|
|
|
|
|
def _validate_sha(name: str, value: str) -> str:
|
|
normalized = str(value or "").lower()
|
|
if not SHA256_RE.fullmatch(normalized):
|
|
raise ValueError(f"{name} must be a SHA-256 digest")
|
|
return normalized
|
|
|
|
|
|
def _canonical_json(payload: Mapping[str, Any]) -> bytes:
|
|
return json.dumps(
|
|
payload,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
).encode("utf-8")
|
|
|
|
|
|
def _safe_child(root: Path, name: str, suffix: str = ".json") -> Path:
|
|
_validate_identifier("storage key", name)
|
|
resolved_root = Path(root).resolve()
|
|
candidate = (resolved_root / f"{name}{suffix}").resolve()
|
|
if candidate.parent != resolved_root:
|
|
raise ValueError("storage path escapes root")
|
|
return candidate
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeploymentPlan:
|
|
service: str
|
|
release_path: str
|
|
release_root: str
|
|
dropin_path: str
|
|
dropin_sha256: str
|
|
rollback_path: str
|
|
rollback_sha256: str
|
|
source_commit: str
|
|
artifact_sha256: str
|
|
manifest_sha256: str
|
|
policy_sha256: str
|
|
health_contract: dict[str, Any]
|
|
|
|
def __post_init__(self) -> None:
|
|
_validate_identifier("service", self.service)
|
|
_validate_oid("source_commit", self.source_commit)
|
|
for name in (
|
|
"dropin_sha256",
|
|
"rollback_sha256",
|
|
"artifact_sha256",
|
|
"manifest_sha256",
|
|
"policy_sha256",
|
|
):
|
|
_validate_sha(name, getattr(self, name))
|
|
if not isinstance(self.health_contract, dict):
|
|
raise ValueError("health_contract must be an object")
|
|
|
|
@property
|
|
def canonical_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"artifact_sha256": self.artifact_sha256,
|
|
"dropin_path": self.dropin_path,
|
|
"dropin_sha256": self.dropin_sha256,
|
|
"health_contract": self.health_contract,
|
|
"manifest_sha256": self.manifest_sha256,
|
|
"policy_sha256": self.policy_sha256,
|
|
"release_path": self.release_path,
|
|
"release_root": self.release_root,
|
|
"rollback_path": self.rollback_path,
|
|
"rollback_sha256": self.rollback_sha256,
|
|
"service": self.service,
|
|
"source_commit": self.source_commit,
|
|
}
|
|
|
|
@property
|
|
def sha256(self) -> str:
|
|
return hashlib.sha256(
|
|
_canonical_json(self.canonical_payload)
|
|
).hexdigest()
|
|
|
|
def validate(
|
|
self,
|
|
*,
|
|
service_allowlist: Iterable[str],
|
|
dropin_allowlist: Iterable[str],
|
|
) -> "DeploymentPlan":
|
|
if self.service not in frozenset(service_allowlist):
|
|
raise ValueError("service is not allowlisted")
|
|
release_root = Path(self.release_root).resolve()
|
|
release_path = Path(self.release_path).resolve()
|
|
try:
|
|
release_path.relative_to(release_root)
|
|
except ValueError as exc:
|
|
raise ValueError("release path escapes release root") from exc
|
|
if release_path == release_root:
|
|
raise ValueError("release path must be a release child")
|
|
dropin = Path(self.dropin_path).resolve()
|
|
try:
|
|
dropin.relative_to(release_path)
|
|
except ValueError as exc:
|
|
raise ValueError("drop-in escapes release path") from exc
|
|
allowed_dropins = {
|
|
str(Path(value).resolve()) for value in dropin_allowlist
|
|
}
|
|
if str(dropin) not in allowed_dropins:
|
|
raise ValueError("drop-in is not allowlisted")
|
|
return self
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return _sanitize({**self.canonical_payload, "sha256": self.sha256})
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: Mapping[str, Any]) -> "DeploymentPlan":
|
|
values = dict(payload)
|
|
values.pop("sha256", None)
|
|
return cls(**values)
|
|
|
|
|
|
class DeploymentPlanStore:
|
|
def __init__(self, root: Path):
|
|
self.root = Path(root)
|
|
|
|
def save(self, candidate_id: str, plan: DeploymentPlan) -> Path:
|
|
path = _safe_child(self.root, candidate_id)
|
|
_atomic_json(path, plan.to_dict())
|
|
return path
|
|
|
|
def load(self, candidate_id: str) -> DeploymentPlan:
|
|
return DeploymentPlan.from_dict(
|
|
json.loads(
|
|
_safe_child(self.root, candidate_id).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SignedReleasePlanApproval:
|
|
release_plan_id: str
|
|
candidate_id: str
|
|
source_commit: str
|
|
tree_oid: str
|
|
artifact_sha256: str
|
|
manifest_sha256: str
|
|
deployment_plan_sha256: str
|
|
policy_sha256: str
|
|
approval_plan_id: str
|
|
evidence_closure_oid: str
|
|
owner_approval_record_oid: str
|
|
capabilities: tuple[str, ...]
|
|
expires_at: str
|
|
nonce: str
|
|
key_id: str
|
|
signature: str
|
|
|
|
def __post_init__(self) -> None:
|
|
for name in (
|
|
"release_plan_id",
|
|
"candidate_id",
|
|
"approval_plan_id",
|
|
"key_id",
|
|
):
|
|
_validate_identifier(name, getattr(self, name))
|
|
if not NONCE_RE.fullmatch(str(self.nonce or "")):
|
|
raise ValueError("invalid nonce")
|
|
for name in (
|
|
"source_commit",
|
|
"tree_oid",
|
|
"evidence_closure_oid",
|
|
"owner_approval_record_oid",
|
|
):
|
|
_validate_oid(name, getattr(self, name))
|
|
for name in (
|
|
"artifact_sha256",
|
|
"manifest_sha256",
|
|
"deployment_plan_sha256",
|
|
"policy_sha256",
|
|
):
|
|
_validate_sha(name, getattr(self, name))
|
|
capabilities = tuple(str(item) for item in self.capabilities)
|
|
object.__setattr__(self, "capabilities", capabilities)
|
|
if (
|
|
not capabilities
|
|
or len(capabilities) != len(set(capabilities))
|
|
or not set(capabilities).issubset(ALLOWED_CAPABILITIES)
|
|
):
|
|
raise ValueError("approval contains invalid capabilities")
|
|
_parse_utc(self.expires_at)
|
|
|
|
@property
|
|
def canonical_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"approval_plan_id": self.approval_plan_id,
|
|
"artifact_sha256": self.artifact_sha256,
|
|
"candidate_id": self.candidate_id,
|
|
"capabilities": list(self.capabilities),
|
|
"deployment_plan_sha256": self.deployment_plan_sha256,
|
|
"evidence_closure_oid": self.evidence_closure_oid,
|
|
"expires_at": self.expires_at,
|
|
"key_id": self.key_id,
|
|
"manifest_sha256": self.manifest_sha256,
|
|
"nonce": self.nonce,
|
|
"owner_approval_record_oid": self.owner_approval_record_oid,
|
|
"policy_sha256": self.policy_sha256,
|
|
"release_plan_id": self.release_plan_id,
|
|
"source_commit": self.source_commit,
|
|
"tree_oid": self.tree_oid,
|
|
}
|
|
|
|
def canonical_bytes(self) -> bytes:
|
|
return _canonical_json(self.canonical_payload)
|
|
|
|
@property
|
|
def fingerprint(self) -> str:
|
|
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
**self.canonical_payload,
|
|
"fingerprint": self.fingerprint,
|
|
"signature": self.signature,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(
|
|
cls, payload: Mapping[str, Any]
|
|
) -> "SignedReleasePlanApproval":
|
|
values = dict(payload)
|
|
values.pop("fingerprint", None)
|
|
values["capabilities"] = tuple(values.get("capabilities") or ())
|
|
return cls(**values)
|
|
|
|
|
|
class ApprovalKeyRing:
|
|
"""Public-only Ed25519 key ring with in-process rotation/revocation."""
|
|
|
|
def __init__(self, public_keys: Mapping[str, bytes]):
|
|
self._keys: dict[str, bytes] = {}
|
|
self._revoked: set[str] = set()
|
|
for key_id, public_key in public_keys.items():
|
|
self.add(key_id, public_key)
|
|
|
|
def add(self, key_id: str, public_key: bytes) -> None:
|
|
key_id = _validate_identifier("key_id", key_id)
|
|
raw = bytes(public_key)
|
|
if len(raw) != 32:
|
|
raise ValueError("Ed25519 public key must contain 32 bytes")
|
|
Ed25519PublicKey.from_public_bytes(raw)
|
|
self._keys[key_id] = raw
|
|
self._revoked.discard(key_id)
|
|
|
|
def revoke(self, key_id: str) -> None:
|
|
if key_id not in self._keys:
|
|
raise KeyError("unknown approval signing key")
|
|
self._revoked.add(key_id)
|
|
|
|
def verify(
|
|
self, key_id: str, signature: bytes, message: bytes
|
|
) -> None:
|
|
if key_id not in self._keys or key_id in self._revoked:
|
|
raise CapabilityAuthorizationError(
|
|
"approval signing key is unavailable or revoked"
|
|
)
|
|
try:
|
|
Ed25519PublicKey.from_public_bytes(
|
|
self._keys[key_id]
|
|
).verify(signature, message)
|
|
except InvalidSignature as exc:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval signature is invalid"
|
|
) from exc
|
|
|
|
def save_public(self, path: Path) -> None:
|
|
payload = {
|
|
"algorithm": "Ed25519",
|
|
"keys": {
|
|
key_id: base64.b64encode(value).decode("ascii")
|
|
for key_id, value in sorted(self._keys.items())
|
|
},
|
|
"revoked": sorted(self._revoked),
|
|
}
|
|
_atomic_json(Path(path), payload)
|
|
|
|
@classmethod
|
|
def from_file(cls, path: Path) -> "ApprovalKeyRing":
|
|
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
if payload.get("algorithm") != "Ed25519":
|
|
raise ValueError("unsupported approval signature algorithm")
|
|
ring = cls(
|
|
{
|
|
key_id: base64.b64decode(encoded, validate=True)
|
|
for key_id, encoded in payload.get("keys", {}).items()
|
|
}
|
|
)
|
|
for key_id in payload.get("revoked", []):
|
|
ring.revoke(key_id)
|
|
return ring
|
|
|
|
|
|
class ApprovalRevocationStore:
|
|
def __init__(self, root: Path):
|
|
self.root = Path(root)
|
|
|
|
def revoke(self, approval_plan_id: str, reason: str) -> dict[str, Any]:
|
|
path = _safe_child(self.root, approval_plan_id)
|
|
payload = {
|
|
"approval_plan_id": approval_plan_id,
|
|
"reason": str(reason or "revoked"),
|
|
"revoked_at": utc_now(),
|
|
}
|
|
_atomic_json(path, payload)
|
|
return payload
|
|
|
|
def is_revoked(self, approval_plan_id: str) -> bool:
|
|
return _safe_child(self.root, approval_plan_id).is_file()
|
|
|
|
|
|
class SignedApprovalVerifier:
|
|
def __init__(
|
|
self,
|
|
keyring: ApprovalKeyRing,
|
|
revocations: ApprovalRevocationStore,
|
|
):
|
|
self.keyring = keyring
|
|
self.revocations = revocations
|
|
|
|
def verify(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
candidate: ReleaseCandidate,
|
|
deployment_plan: DeploymentPlan,
|
|
policy_sha256: str,
|
|
) -> SignedReleasePlanApproval:
|
|
try:
|
|
if _parse_utc(approval.expires_at) <= datetime.now(timezone.utc):
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval has expired"
|
|
)
|
|
if self.revocations.is_revoked(approval.approval_plan_id):
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval has been revoked"
|
|
)
|
|
expected = (
|
|
candidate.candidate_id,
|
|
candidate.source_commit,
|
|
candidate.tree_oid,
|
|
candidate.artifact_sha256,
|
|
candidate.manifest_sha256,
|
|
deployment_plan.sha256,
|
|
_validate_sha("policy_sha256", policy_sha256),
|
|
)
|
|
actual = (
|
|
approval.candidate_id,
|
|
approval.source_commit,
|
|
approval.tree_oid,
|
|
approval.artifact_sha256,
|
|
approval.manifest_sha256,
|
|
approval.deployment_plan_sha256,
|
|
approval.policy_sha256,
|
|
)
|
|
if actual != expected:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval binding does not match release evidence"
|
|
)
|
|
try:
|
|
signature = base64.b64decode(
|
|
approval.signature, validate=True
|
|
)
|
|
except (ValueError, TypeError) as exc:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval signature encoding is invalid"
|
|
) from exc
|
|
self.keyring.verify(
|
|
approval.key_id,
|
|
signature,
|
|
approval.canonical_bytes(),
|
|
)
|
|
return approval
|
|
except CapabilityAuthorizationError:
|
|
raise
|
|
except Exception as exc:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval verification failed"
|
|
) from exc
|
|
|
|
|
|
class CapabilityLedger:
|
|
"""Durable exclusive claims with leases and fail-closed recovery probes."""
|
|
|
|
STATES = frozenset(
|
|
{
|
|
"PENDING",
|
|
"CLAIMED",
|
|
"COMPLETED",
|
|
"FAILED_RECOVERABLE",
|
|
"MANUAL_OWNER_REVIEW_REQUIRED",
|
|
}
|
|
)
|
|
|
|
def __init__(self, root: Path):
|
|
self.root = Path(root)
|
|
|
|
def _operation_path(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
operation: str,
|
|
) -> Path:
|
|
key = hashlib.sha256(
|
|
"\0".join(
|
|
(approval.release_plan_id, capability, operation)
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
return _safe_child(self.root, key)
|
|
|
|
def _nonce_path(self, nonce: str) -> Path:
|
|
key = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
|
|
return _safe_child(self.root / "nonces", key)
|
|
|
|
def _lock(self, path: Path):
|
|
lock_path = self.root / ".locks" / f"{path.stem}.lock"
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(lock_path.parent, 0o700)
|
|
handle = lock_path.open("a+", encoding="utf-8")
|
|
os.chmod(lock_path, 0o600)
|
|
try:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
except ImportError:
|
|
pass
|
|
return handle
|
|
|
|
@staticmethod
|
|
def _unlock(handle) -> None:
|
|
try:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
except ImportError:
|
|
pass
|
|
handle.close()
|
|
|
|
@staticmethod
|
|
def _assert_authorized(
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
operation: str,
|
|
) -> None:
|
|
if (
|
|
capability in FORBIDDEN_CAPABILITIES
|
|
or capability not in ALLOWED_CAPABILITIES
|
|
or capability not in approval.capabilities
|
|
):
|
|
raise CapabilityAuthorizationError(
|
|
"capability is not authorized by owner approval"
|
|
)
|
|
_validate_identifier("operation", operation)
|
|
|
|
@staticmethod
|
|
def _immutable_payload(
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
operation: str,
|
|
) -> dict[str, Any]:
|
|
operation_digest = hashlib.sha256(
|
|
_canonical_json(
|
|
{
|
|
"approval_fingerprint": approval.fingerprint,
|
|
"capability": capability,
|
|
"operation": operation,
|
|
"release_plan_id": approval.release_plan_id,
|
|
}
|
|
)
|
|
).hexdigest()
|
|
return {
|
|
"approval_fingerprint": approval.fingerprint,
|
|
"approval_plan_id": approval.approval_plan_id,
|
|
"capability": capability,
|
|
"operation": operation,
|
|
"operation_digest": operation_digest,
|
|
"release_plan_id": approval.release_plan_id,
|
|
}
|
|
|
|
@staticmethod
|
|
def _assert_record_matches(
|
|
existing: Mapping[str, Any],
|
|
expected: Mapping[str, Any],
|
|
) -> None:
|
|
if {key: existing.get(key) for key in expected} != dict(expected):
|
|
raise CapabilityAuthorizationError(
|
|
"capability replay conflicts with durable record"
|
|
)
|
|
|
|
def _write(self, path: Path, payload: Mapping[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(path.parent, 0o700)
|
|
_durable_json(path, payload)
|
|
os.chmod(path, 0o600)
|
|
|
|
def _bind_nonce(self, approval: SignedReleasePlanApproval) -> None:
|
|
path = self._nonce_path(approval.nonce)
|
|
expected = {
|
|
"fingerprint": approval.fingerprint,
|
|
"release_plan_id": approval.release_plan_id,
|
|
}
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(path.parent, 0o700)
|
|
try:
|
|
descriptor = os.open(
|
|
path,
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
0o600,
|
|
)
|
|
except FileExistsError:
|
|
actual = json.loads(path.read_text(encoding="utf-8"))
|
|
if actual != expected:
|
|
raise CapabilityAuthorizationError(
|
|
"approval nonce replay does not match original plan"
|
|
)
|
|
return
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
json.dump(expected, output, sort_keys=True)
|
|
output.write("\n")
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
_fsync_directory(path.parent)
|
|
|
|
def claim(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
*,
|
|
operation: str,
|
|
executor_id: str,
|
|
lease_seconds: float,
|
|
now: float | None = None,
|
|
side_effect_probe: Callable[[], ClaimProbeResult] | None = None,
|
|
) -> dict[str, Any]:
|
|
self._assert_authorized(approval, capability, operation)
|
|
_validate_identifier("executor_id", executor_id)
|
|
if lease_seconds <= 0:
|
|
raise ValueError("lease_seconds must be positive")
|
|
timestamp = time.time() if now is None else float(now)
|
|
path = self._operation_path(approval, capability, operation)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._bind_nonce(approval)
|
|
immutable = self._immutable_payload(
|
|
approval, capability, operation
|
|
)
|
|
lock = self._lock(path)
|
|
try:
|
|
if path.exists():
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
self._assert_record_matches(record, immutable)
|
|
else:
|
|
record = {
|
|
**immutable,
|
|
"attempt": 0,
|
|
"state": "PENDING",
|
|
}
|
|
state = str(record.get("state") or "PENDING")
|
|
if state == "COMPLETED":
|
|
return {**record, "status": "IDEMPOTENT"}
|
|
if state == "MANUAL_OWNER_REVIEW_REQUIRED":
|
|
return {**record, "status": state}
|
|
if state == "CLAIMED":
|
|
lease_until = float(record.get("lease_until") or 0)
|
|
if lease_until > timestamp:
|
|
if record.get("executor_id") == executor_id:
|
|
return {**record, "status": "CLAIMED"}
|
|
return {**record, "status": "BUSY"}
|
|
if side_effect_probe is None:
|
|
return {**record, "status": "PROBE_REQUIRED"}
|
|
probe = ClaimProbeResult(side_effect_probe())
|
|
if probe == ClaimProbeResult.APPLIED_VERIFIED:
|
|
record.update(
|
|
{
|
|
"completed_at": utc_now(),
|
|
"probe_result": probe.value,
|
|
"state": "COMPLETED",
|
|
}
|
|
)
|
|
self._write(path, record)
|
|
return {**record, "status": "IDEMPOTENT"}
|
|
if probe in {
|
|
ClaimProbeResult.INDETERMINATE,
|
|
ClaimProbeResult.CONFLICT,
|
|
}:
|
|
record.update(
|
|
{
|
|
"probe_result": probe.value,
|
|
"state": "MANUAL_OWNER_REVIEW_REQUIRED",
|
|
}
|
|
)
|
|
self._write(path, record)
|
|
return {**record, "status": record["state"]}
|
|
claim_id = uuid.uuid4().hex
|
|
claimed = {
|
|
**record,
|
|
**immutable,
|
|
"attempt": int(record.get("attempt") or 0) + 1,
|
|
"claim_id": claim_id,
|
|
"claimed_at": timestamp,
|
|
"executor_id": executor_id,
|
|
"lease_until": timestamp + float(lease_seconds),
|
|
"state": "CLAIMED",
|
|
}
|
|
self._write(path, claimed)
|
|
return {**claimed, "status": "CLAIMED"}
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def complete(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
*,
|
|
operation: str,
|
|
claim_id: str | None = None,
|
|
evidence: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
self._assert_authorized(approval, capability, operation)
|
|
path = self._operation_path(approval, capability, operation)
|
|
immutable = self._immutable_payload(
|
|
approval, capability, operation
|
|
)
|
|
lock = self._lock(path)
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
self._assert_record_matches(record, immutable)
|
|
if record.get("state") == "COMPLETED":
|
|
return {**record, "status": "IDEMPOTENT"}
|
|
if (
|
|
record.get("state") != "CLAIMED"
|
|
or not claim_id
|
|
or record.get("claim_id") != claim_id
|
|
):
|
|
raise CapabilityAuthorizationError(
|
|
"capability completion claim_id mismatch"
|
|
)
|
|
record.update(
|
|
{
|
|
"completed_at": utc_now(),
|
|
"evidence": _sanitize(dict(evidence or {})),
|
|
"state": "COMPLETED",
|
|
}
|
|
)
|
|
self._write(path, record)
|
|
return {**record, "status": "COMPLETED"}
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def fail_recoverable(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
*,
|
|
operation: str,
|
|
claim_id: str,
|
|
reason: str,
|
|
) -> dict[str, Any]:
|
|
path = self._operation_path(approval, capability, operation)
|
|
lock = self._lock(path)
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
if record.get("claim_id") != claim_id:
|
|
raise CapabilityAuthorizationError(
|
|
"capability failure claim_id mismatch"
|
|
)
|
|
record.update(
|
|
{
|
|
"failure_reason": str(reason),
|
|
"state": "FAILED_RECOVERABLE",
|
|
}
|
|
)
|
|
self._write(path, record)
|
|
return record
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def reserve(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
*,
|
|
operation: str,
|
|
) -> dict[str, Any]:
|
|
"""Compatibility wrapper; production controllers use claim()."""
|
|
path = self._operation_path(approval, capability, operation)
|
|
if path.exists():
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
if record.get("state") == "COMPLETED":
|
|
return {**record, "status": "IDEMPOTENT"}
|
|
if record.get("state") in {
|
|
"CLAIMED",
|
|
"FAILED_RECOVERABLE",
|
|
}:
|
|
return {**record, "status": "RESUME"}
|
|
result = self.claim(
|
|
approval,
|
|
capability,
|
|
operation=operation,
|
|
executor_id="compatibility-reservation",
|
|
lease_seconds=300,
|
|
)
|
|
aliases = {
|
|
"CLAIMED": "RESERVED",
|
|
"BUSY": "RESUME",
|
|
"IDEMPOTENT": "IDEMPOTENT",
|
|
}
|
|
return {**result, "status": aliases.get(result["status"], result["status"])}
|
|
|
|
def consume(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
capability: str,
|
|
*,
|
|
operation: str,
|
|
) -> dict[str, Any]:
|
|
reservation = self.reserve(
|
|
approval, capability, operation=operation
|
|
)
|
|
if reservation["status"] == "IDEMPOTENT":
|
|
return reservation
|
|
completed = self.complete(
|
|
approval,
|
|
capability,
|
|
operation=operation,
|
|
claim_id=reservation["claim_id"],
|
|
)
|
|
return {**completed, "status": "CONSUMED"}
|
|
|
|
|
|
class SagaState(str, Enum):
|
|
PREPARING = "PREPARING"
|
|
VERIFYING = "VERIFYING"
|
|
READY_FOR_APPROVAL = "READY_FOR_APPROVAL"
|
|
APPROVED = "APPROVED"
|
|
PRE_SWITCH_REVALIDATING = "PRE_SWITCH_REVALIDATING"
|
|
SWITCH_PENDING = "SWITCH_PENDING"
|
|
SWITCHED = "SWITCHED"
|
|
RUNTIME_VERIFYING = "RUNTIME_VERIFYING"
|
|
RUNTIME_VERIFIED = "RUNTIME_VERIFIED"
|
|
TAG_PENDING = "TAG_PENDING"
|
|
TAG_VERIFIED = "TAG_VERIFIED"
|
|
BASELINE_REGISTRY_PENDING = "BASELINE_REGISTRY_PENDING"
|
|
COMPLETED = "COMPLETED"
|
|
ROLLBACK_PENDING = "ROLLBACK_PENDING"
|
|
ROLLED_BACK = "ROLLED_BACK"
|
|
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
|
MANUAL_OWNER_REVIEW_REQUIRED = "MANUAL_OWNER_REVIEW_REQUIRED"
|
|
|
|
|
|
@dataclass
|
|
class ReleaseSaga:
|
|
candidate_id: str
|
|
release_plan_id: str
|
|
state: SagaState = SagaState.PREPARING
|
|
revision: int = 0
|
|
events: list[dict[str, Any]] = field(default_factory=list)
|
|
journal_sequence: int = 0
|
|
journal_head_hash: str = JOURNAL_GENESIS_HASH
|
|
journal_integrity: bool = True
|
|
created_at: str = field(default_factory=utc_now)
|
|
updated_at: str = field(default_factory=utc_now)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
payload = asdict(self)
|
|
payload["state"] = self.state.value
|
|
return _sanitize(payload)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: Mapping[str, Any]) -> "ReleaseSaga":
|
|
values = dict(payload)
|
|
values["state"] = SagaState(values["state"])
|
|
return cls(**values)
|
|
|
|
|
|
class ReleaseSagaStore:
|
|
"""Hash-chained journal is truth; atomic snapshot is a rebuildable cache."""
|
|
|
|
RECOVERY_ACTIONS = {
|
|
SagaState.SWITCH_PENDING: "resume_runtime_switch",
|
|
SagaState.SWITCHED: "verify_runtime",
|
|
SagaState.RUNTIME_VERIFYING: "verify_runtime",
|
|
SagaState.TAG_PENDING: "reconcile_tag",
|
|
SagaState.TAG_VERIFIED: "reconcile_baseline_registry",
|
|
SagaState.BASELINE_REGISTRY_PENDING: "reconcile_baseline_registry",
|
|
SagaState.ROLLBACK_PENDING: "resume_rollback",
|
|
SagaState.RECONCILIATION_REQUIRED: "reconcile_release",
|
|
SagaState.MANUAL_OWNER_REVIEW_REQUIRED: "manual_owner_review",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
root: Path,
|
|
*,
|
|
after_journal_hook: Callable[[Mapping[str, Any]], None] | None = None,
|
|
):
|
|
self.root = Path(root)
|
|
self.after_journal_hook = after_journal_hook
|
|
|
|
def _path(self, candidate_id: str) -> Path:
|
|
if not CANDIDATE_ID_RE.fullmatch(str(candidate_id or "")):
|
|
raise ValueError("invalid candidate_id")
|
|
return _safe_child(self.root, candidate_id)
|
|
|
|
def _journal(self, candidate_id: str) -> Path:
|
|
if not CANDIDATE_ID_RE.fullmatch(str(candidate_id or "")):
|
|
raise ValueError("invalid candidate_id")
|
|
return _safe_child(self.root, candidate_id, ".events.jsonl")
|
|
|
|
def _lock(self, candidate_id: str):
|
|
path = _safe_child(self.root / ".locks", candidate_id, ".lock")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = path.open("a+", encoding="utf-8")
|
|
try:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
except ImportError:
|
|
pass
|
|
return handle
|
|
|
|
@staticmethod
|
|
def _unlock(handle) -> None:
|
|
try:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
except ImportError:
|
|
pass
|
|
handle.close()
|
|
|
|
def create(
|
|
self, candidate_id: str, release_plan_id: str
|
|
) -> ReleaseSaga:
|
|
_validate_identifier("release_plan_id", release_plan_id)
|
|
path = self._path(candidate_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
lock = self._lock(candidate_id)
|
|
try:
|
|
if path.exists() or self._journal(candidate_id).exists():
|
|
existing = self._load_locked(candidate_id)
|
|
if existing.release_plan_id != release_plan_id:
|
|
raise SagaConflict(
|
|
"candidate is bound to a different release plan"
|
|
)
|
|
return existing
|
|
saga = ReleaseSaga(
|
|
candidate_id=candidate_id,
|
|
release_plan_id=release_plan_id,
|
|
)
|
|
payload = {
|
|
"candidate_id": candidate_id,
|
|
"details": {},
|
|
"event": "saga_created",
|
|
"release_plan_id": release_plan_id,
|
|
"revision": 0,
|
|
"state": saga.state.value,
|
|
"timestamp": saga.created_at,
|
|
}
|
|
record = self._append_event(
|
|
candidate_id,
|
|
payload,
|
|
sequence=1,
|
|
previous_hash=JOURNAL_GENESIS_HASH,
|
|
)
|
|
saga.events.append(payload)
|
|
saga.journal_sequence = 1
|
|
saga.journal_head_hash = record["event_hash"]
|
|
self._after_journal(record)
|
|
_durable_json(path, saga.to_dict())
|
|
return saga
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def load(self, candidate_id: str) -> ReleaseSaga:
|
|
lock = self._lock(candidate_id)
|
|
try:
|
|
try:
|
|
return self._load_locked(candidate_id)
|
|
except JournalIntegrityError as exc:
|
|
return self._manual_review_snapshot(candidate_id, str(exc))
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def _load_locked(self, candidate_id: str) -> ReleaseSaga:
|
|
records = self.verify_journal(candidate_id)
|
|
saga = self._rebuild(records)
|
|
path = self._path(candidate_id)
|
|
cached = None
|
|
if path.exists():
|
|
try:
|
|
cached = ReleaseSaga.from_dict(
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
)
|
|
except Exception:
|
|
cached = None
|
|
if cached is not None and (
|
|
cached.journal_sequence > saga.journal_sequence
|
|
or (
|
|
cached.journal_sequence == saga.journal_sequence
|
|
and cached.journal_head_hash != saga.journal_head_hash
|
|
)
|
|
):
|
|
raise JournalIntegrityError(
|
|
"Saga snapshot references a missing or different journal head"
|
|
)
|
|
if cached is None or cached.to_dict() != saga.to_dict():
|
|
_durable_json(path, saga.to_dict())
|
|
return saga
|
|
|
|
def compare_and_set(
|
|
self,
|
|
candidate_id: str,
|
|
*,
|
|
expected_revision: int,
|
|
expected_states: Iterable[SagaState],
|
|
target: SagaState,
|
|
event: str,
|
|
details: Mapping[str, Any] | None = None,
|
|
) -> ReleaseSaga:
|
|
lock = self._lock(candidate_id)
|
|
try:
|
|
try:
|
|
saga = self._load_locked(candidate_id)
|
|
except JournalIntegrityError as exc:
|
|
return self._manual_review_snapshot(candidate_id, str(exc))
|
|
states = {SagaState(value) for value in expected_states}
|
|
if (
|
|
saga.revision != expected_revision
|
|
or saga.state not in states
|
|
):
|
|
raise SagaConflict("Saga compare-and-set conflict")
|
|
saga.revision += 1
|
|
saga.state = SagaState(target)
|
|
saga.updated_at = utc_now()
|
|
payload = {
|
|
"candidate_id": saga.candidate_id,
|
|
"details": _sanitize(dict(details or {})),
|
|
"event": str(event),
|
|
"release_plan_id": saga.release_plan_id,
|
|
"revision": saga.revision,
|
|
"state": saga.state.value,
|
|
"timestamp": saga.updated_at,
|
|
}
|
|
record = self._append_event(
|
|
candidate_id,
|
|
payload,
|
|
sequence=saga.journal_sequence + 1,
|
|
previous_hash=saga.journal_head_hash,
|
|
)
|
|
saga.events.append(payload)
|
|
saga.journal_sequence = record["sequence"]
|
|
saga.journal_head_hash = record["event_hash"]
|
|
self._after_journal(record)
|
|
_durable_json(self._path(candidate_id), saga.to_dict())
|
|
return saga
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def force_state_for_recovery_test(
|
|
self, candidate_id: str, state: SagaState
|
|
) -> ReleaseSaga:
|
|
saga = self.load(candidate_id)
|
|
return self.compare_and_set(
|
|
candidate_id,
|
|
expected_revision=saga.revision,
|
|
expected_states={saga.state},
|
|
target=state,
|
|
event="test_recovery_checkpoint",
|
|
)
|
|
|
|
def bind_release_plan(
|
|
self, candidate_id: str, release_plan_id: str
|
|
) -> ReleaseSaga:
|
|
_validate_identifier("release_plan_id", release_plan_id)
|
|
lock = self._lock(candidate_id)
|
|
try:
|
|
saga = self._load_locked(candidate_id)
|
|
pending = f"pending-{candidate_id}"
|
|
if saga.release_plan_id not in {pending, release_plan_id}:
|
|
raise SagaConflict(
|
|
"Saga is already bound to another release plan"
|
|
)
|
|
if saga.release_plan_id == release_plan_id:
|
|
return saga
|
|
saga.release_plan_id = release_plan_id
|
|
saga.revision += 1
|
|
saga.updated_at = utc_now()
|
|
payload = {
|
|
"candidate_id": saga.candidate_id,
|
|
"details": {"release_plan_id": release_plan_id},
|
|
"event": "owner_approval_bound",
|
|
"release_plan_id": release_plan_id,
|
|
"revision": saga.revision,
|
|
"state": saga.state.value,
|
|
"timestamp": saga.updated_at,
|
|
}
|
|
record = self._append_event(
|
|
candidate_id,
|
|
payload,
|
|
sequence=saga.journal_sequence + 1,
|
|
previous_hash=saga.journal_head_hash,
|
|
)
|
|
saga.events.append(payload)
|
|
saga.journal_sequence = record["sequence"]
|
|
saga.journal_head_hash = record["event_hash"]
|
|
self._after_journal(record)
|
|
_durable_json(self._path(candidate_id), saga.to_dict())
|
|
return saga
|
|
finally:
|
|
self._unlock(lock)
|
|
|
|
def _append_event(
|
|
self,
|
|
candidate_id: str,
|
|
payload: Mapping[str, Any],
|
|
*,
|
|
sequence: int,
|
|
previous_hash: str,
|
|
) -> dict[str, Any]:
|
|
path = self._journal(candidate_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
core = {
|
|
"sequence": int(sequence),
|
|
"previous_hash": str(previous_hash),
|
|
"payload": _sanitize(dict(payload)),
|
|
}
|
|
record = {
|
|
"schema_version": JOURNAL_SCHEMA_VERSION,
|
|
**core,
|
|
"event_hash": hashlib.sha256(
|
|
_canonical_json(core)
|
|
).hexdigest(),
|
|
}
|
|
with path.open("a", encoding="utf-8") as output:
|
|
output.write(
|
|
json.dumps(
|
|
record,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n"
|
|
)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
_fsync_directory(path.parent)
|
|
return record
|
|
|
|
def _after_journal(self, record: Mapping[str, Any]) -> None:
|
|
if self.after_journal_hook is not None:
|
|
self.after_journal_hook(record)
|
|
|
|
def verify_journal(
|
|
self, candidate_id: str
|
|
) -> list[dict[str, Any]]:
|
|
path = self._journal(candidate_id)
|
|
if not path.is_file():
|
|
raise JournalIntegrityError("Saga journal is missing")
|
|
records: list[dict[str, Any]] = []
|
|
previous = JOURNAL_GENESIS_HASH
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except OSError as exc:
|
|
raise JournalIntegrityError("Saga journal is unreadable") from exc
|
|
if not lines:
|
|
raise JournalIntegrityError("Saga journal is empty")
|
|
for expected_sequence, line in enumerate(lines, 1):
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise JournalIntegrityError(
|
|
"Saga journal contains invalid JSON"
|
|
) from exc
|
|
if set(record) != {
|
|
"schema_version",
|
|
"sequence",
|
|
"previous_hash",
|
|
"payload",
|
|
"event_hash",
|
|
}:
|
|
raise JournalIntegrityError(
|
|
"Saga journal record schema is invalid"
|
|
)
|
|
core = {
|
|
"sequence": record["sequence"],
|
|
"previous_hash": record["previous_hash"],
|
|
"payload": record["payload"],
|
|
}
|
|
expected_hash = hashlib.sha256(
|
|
_canonical_json(core)
|
|
).hexdigest()
|
|
if (
|
|
record["schema_version"] != JOURNAL_SCHEMA_VERSION
|
|
or record["sequence"] != expected_sequence
|
|
or record["previous_hash"] != previous
|
|
or record["event_hash"] != expected_hash
|
|
):
|
|
raise JournalIntegrityError(
|
|
"Saga journal hash chain verification failed"
|
|
)
|
|
previous = record["event_hash"]
|
|
records.append(record)
|
|
return records
|
|
|
|
@staticmethod
|
|
def _rebuild(records: list[Mapping[str, Any]]) -> ReleaseSaga:
|
|
first = dict(records[0]["payload"])
|
|
saga = ReleaseSaga(
|
|
candidate_id=str(first["candidate_id"]),
|
|
release_plan_id=str(first["release_plan_id"]),
|
|
state=SagaState(first["state"]),
|
|
revision=int(first["revision"]),
|
|
events=[],
|
|
created_at=str(first["timestamp"]),
|
|
updated_at=str(first["timestamp"]),
|
|
)
|
|
for record in records:
|
|
payload = dict(record["payload"])
|
|
if payload.get("candidate_id") != saga.candidate_id:
|
|
raise JournalIntegrityError(
|
|
"Saga journal candidate identity changed"
|
|
)
|
|
saga.release_plan_id = str(payload["release_plan_id"])
|
|
saga.state = SagaState(payload["state"])
|
|
saga.revision = int(payload["revision"])
|
|
saga.updated_at = str(payload["timestamp"])
|
|
saga.events.append(_sanitize(payload))
|
|
saga.journal_sequence = int(record["sequence"])
|
|
saga.journal_head_hash = str(record["event_hash"])
|
|
return saga
|
|
|
|
def _manual_review_snapshot(
|
|
self, candidate_id: str, reason: str
|
|
) -> ReleaseSaga:
|
|
path = self._path(candidate_id)
|
|
try:
|
|
cached = ReleaseSaga.from_dict(
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
)
|
|
except Exception:
|
|
cached = ReleaseSaga(
|
|
candidate_id=candidate_id,
|
|
release_plan_id=f"corrupt-{candidate_id}",
|
|
)
|
|
cached.state = SagaState.MANUAL_OWNER_REVIEW_REQUIRED
|
|
cached.journal_integrity = False
|
|
cached.updated_at = utc_now()
|
|
cached.events.append(
|
|
{
|
|
"event": "journal_integrity_failure",
|
|
"reason": str(reason),
|
|
"timestamp": cached.updated_at,
|
|
}
|
|
)
|
|
_durable_json(path, cached.to_dict())
|
|
return cached
|
|
|
|
@classmethod
|
|
def recovery_action(cls, state: SagaState) -> str:
|
|
return cls.RECOVERY_ACTIONS.get(
|
|
SagaState(state), "no_automatic_action"
|
|
)
|
|
|
|
|
|
class PreSwitchProbe(Protocol):
|
|
def collect(
|
|
self,
|
|
release_candidate: ReleaseCandidate,
|
|
deployment_plan: DeploymentPlan,
|
|
) -> Mapping[str, Any]:
|
|
...
|
|
|
|
|
|
class PreSwitchValidator:
|
|
def __init__(self, probe: PreSwitchProbe):
|
|
self.probe = probe
|
|
|
|
def validate(
|
|
self,
|
|
candidate: ReleaseCandidate,
|
|
approval: SignedReleasePlanApproval,
|
|
plan: DeploymentPlan,
|
|
*,
|
|
expected_repository: str,
|
|
) -> dict[str, Any]:
|
|
expected = {
|
|
"source_commit": candidate.source_commit,
|
|
"tree_oid": candidate.tree_oid,
|
|
"artifact_sha256": candidate.artifact_sha256,
|
|
"manifest_sha256": candidate.manifest_sha256,
|
|
"deployment_plan_sha256": approval.deployment_plan_sha256,
|
|
"policy_sha256": approval.policy_sha256,
|
|
"rollback_ready": True,
|
|
"git_refs_consistent": True,
|
|
"repository_identity": expected_repository,
|
|
"writer_disabled": True,
|
|
}
|
|
plan_binding = (
|
|
approval.candidate_id == candidate.candidate_id
|
|
and approval.source_commit == candidate.source_commit
|
|
and approval.tree_oid == candidate.tree_oid
|
|
and approval.artifact_sha256 == candidate.artifact_sha256
|
|
and approval.manifest_sha256 == candidate.manifest_sha256
|
|
and approval.deployment_plan_sha256 == plan.sha256
|
|
and plan.source_commit == candidate.source_commit
|
|
and plan.artifact_sha256 == candidate.artifact_sha256
|
|
and plan.manifest_sha256 == candidate.manifest_sha256
|
|
and plan.policy_sha256 == approval.policy_sha256
|
|
)
|
|
if not plan_binding:
|
|
raise StaleApprovalError(
|
|
"APPROVAL_STALE_REAPPROVAL_REQUIRED: "
|
|
"deployment plan binding changed"
|
|
)
|
|
actual = dict(self.probe.collect(candidate, plan))
|
|
failed = [
|
|
name
|
|
for name, expected_value in expected.items()
|
|
if actual.get(name) != expected_value
|
|
]
|
|
if failed:
|
|
raise StaleApprovalError(
|
|
"APPROVAL_STALE_REAPPROVAL_REQUIRED: "
|
|
+ ", ".join(sorted(failed))
|
|
)
|
|
return {
|
|
"valid": True,
|
|
"checks": {name: True for name in expected},
|
|
"validated_at": utc_now(),
|
|
}
|
|
|
|
|
|
class CapabilityController:
|
|
"""One narrow controller consumes one capability and runs one adapter."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
capability: str,
|
|
ledger: CapabilityLedger,
|
|
adapter: Callable[..., Mapping[str, Any]],
|
|
executor_id: str = "silent-release-controller",
|
|
lease_seconds: float = 300,
|
|
recovery_probe: Callable[[], ClaimProbeResult] | None = None,
|
|
):
|
|
if capability not in ALLOWED_CAPABILITIES:
|
|
raise ValueError("invalid release capability")
|
|
self.capability = capability
|
|
self.ledger = ledger
|
|
self.adapter = adapter
|
|
self.executor_id = executor_id
|
|
self.lease_seconds = lease_seconds
|
|
self.recovery_probe = recovery_probe
|
|
|
|
def execute(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
*,
|
|
operation: str,
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
claim = self.ledger.claim(
|
|
approval,
|
|
self.capability,
|
|
operation=operation,
|
|
executor_id=self.executor_id,
|
|
lease_seconds=self.lease_seconds,
|
|
side_effect_probe=self.recovery_probe,
|
|
)
|
|
if claim["status"] == "IDEMPOTENT":
|
|
return {
|
|
"capability": self.capability,
|
|
"status": "IDEMPOTENT",
|
|
}
|
|
if claim["status"] == "BUSY":
|
|
return {
|
|
"capability": self.capability,
|
|
"claim_id": claim.get("claim_id"),
|
|
"status": "BUSY",
|
|
}
|
|
if claim["status"] != "CLAIMED":
|
|
raise CapabilityAuthorizationError(
|
|
f"capability claim is not executable: {claim['status']}"
|
|
)
|
|
try:
|
|
result = dict(self.adapter(**kwargs))
|
|
self.validate_result(approval, result)
|
|
self.ledger.complete(
|
|
approval,
|
|
self.capability,
|
|
operation=operation,
|
|
claim_id=claim["claim_id"],
|
|
evidence={
|
|
"result_sha256": hashlib.sha256(
|
|
_canonical_json(_sanitize(result))
|
|
).hexdigest()
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
self.ledger.fail_recoverable(
|
|
approval,
|
|
self.capability,
|
|
operation=operation,
|
|
claim_id=claim["claim_id"],
|
|
reason=type(exc).__name__,
|
|
)
|
|
raise
|
|
return {
|
|
"capability": self.capability,
|
|
"result": _sanitize(result),
|
|
"status": "EXECUTED",
|
|
}
|
|
|
|
def validate_result(
|
|
self,
|
|
approval: SignedReleasePlanApproval,
|
|
result: Mapping[str, Any],
|
|
) -> None:
|
|
if result.get("verified") is not True:
|
|
raise CapabilityAuthorizationError(
|
|
"capability result is not verified"
|
|
)
|
|
|
|
|
|
class RuntimeSwitchController(CapabilityController):
|
|
def __init__(self, ledger: CapabilityLedger, adapter: Callable[..., Any]):
|
|
super().__init__(
|
|
capability="runtime_switch",
|
|
ledger=ledger,
|
|
adapter=adapter,
|
|
)
|
|
|
|
|
|
class HardenedTagController(CapabilityController):
|
|
"""Tag adapter must independently verify Git, Gitea API, and listing."""
|
|
|
|
REQUIRED_PROOF = frozenset(
|
|
{
|
|
"evidence_closure_oid",
|
|
"approval_plan_id",
|
|
"repository_identity",
|
|
"remote_tag_oid",
|
|
"remote_peeled_target_oid",
|
|
"gitea_api_tag_oid",
|
|
"gitea_api_peeled_target_oid",
|
|
"gitea_listing_visible",
|
|
}
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
ledger: CapabilityLedger,
|
|
adapter: Callable[..., Any],
|
|
*,
|
|
expected_repository: str = "",
|
|
):
|
|
self.expected_repository = expected_repository
|
|
super().__init__(
|
|
capability="tag_publish",
|
|
ledger=ledger,
|
|
adapter=adapter,
|
|
)
|
|
|
|
def validate_result(self, approval, proof):
|
|
if (
|
|
not self.REQUIRED_PROOF.issubset(proof)
|
|
or proof.get("evidence_closure_oid")
|
|
!= approval.evidence_closure_oid
|
|
or proof.get("approval_plan_id")
|
|
!= approval.approval_plan_id
|
|
or proof.get("gitea_listing_visible") is not True
|
|
or proof.get("remote_peeled_target_oid")
|
|
!= approval.source_commit
|
|
or proof.get("gitea_api_peeled_target_oid")
|
|
!= approval.source_commit
|
|
or proof.get("gitea_api_tag_oid")
|
|
!= proof.get("remote_tag_oid")
|
|
or (
|
|
self.expected_repository
|
|
and proof.get("repository_identity")
|
|
!= self.expected_repository
|
|
)
|
|
):
|
|
raise CapabilityAuthorizationError(
|
|
"tag publication proof is incomplete"
|
|
)
|
|
|
|
|
|
class BaselineRegistryFinalizeController(CapabilityController):
|
|
def __init__(self, ledger: CapabilityLedger, adapter: Callable[..., Any]):
|
|
super().__init__(
|
|
capability="baseline_registry_finalize",
|
|
ledger=ledger,
|
|
adapter=adapter,
|
|
)
|
|
|
|
|
|
class BaselineController(BaselineRegistryFinalizeController):
|
|
"""Narrow Baseline half of the governed finalization capability."""
|
|
|
|
def validate_result(self, approval, result):
|
|
required = {
|
|
"verified": True,
|
|
"baseline_target_oid": approval.source_commit,
|
|
"evidence_closure_oid": approval.evidence_closure_oid,
|
|
"approval_plan_id": approval.approval_plan_id,
|
|
}
|
|
if any(result.get(key) != value for key, value in required.items()):
|
|
raise CapabilityAuthorizationError(
|
|
"Baseline finalization proof is inconsistent"
|
|
)
|
|
|
|
|
|
class RegistryController(BaselineRegistryFinalizeController):
|
|
"""Narrow Registry half of the governed finalization capability."""
|
|
|
|
def validate_result(self, approval, result):
|
|
required = {
|
|
"verified": True,
|
|
"registry_target_oid": approval.source_commit,
|
|
"evidence_closure_oid": approval.evidence_closure_oid,
|
|
"approval_plan_id": approval.approval_plan_id,
|
|
}
|
|
if any(result.get(key) != value for key, value in required.items()):
|
|
raise CapabilityAuthorizationError(
|
|
"Registry finalization proof is inconsistent"
|
|
)
|
|
|
|
|
|
class SilentReleaseActionService:
|
|
"""Durable control-plane facade; execution is shadow-disabled by default."""
|
|
|
|
def __init__(
|
|
self,
|
|
root: Path,
|
|
*,
|
|
execution_enabled: bool = False,
|
|
runtime_executor: Any = None,
|
|
approval_verifier: SignedApprovalVerifier | None = None,
|
|
pre_switch_validator: PreSwitchValidator | None = None,
|
|
expected_repository: str = "",
|
|
policy_sha256: str = "",
|
|
):
|
|
self.root = Path(root)
|
|
self.execution_enabled = bool(execution_enabled)
|
|
self.runtime_executor = runtime_executor
|
|
self.approval_verifier = approval_verifier
|
|
self.pre_switch_validator = pre_switch_validator
|
|
self.expected_repository = expected_repository
|
|
self.policy_sha256 = policy_sha256
|
|
self.candidates = ReleaseCandidateStore(
|
|
self.root / "candidates"
|
|
)
|
|
self.plans = DeploymentPlanStore(self.root / "plans")
|
|
self.sagas = ReleaseSagaStore(self.root / "sagas")
|
|
self.approvals_root = self.root / "approvals"
|
|
|
|
@classmethod
|
|
def shadow(
|
|
cls, root: Path, *, runtime_executor: Any = None
|
|
) -> "SilentReleaseActionService":
|
|
return cls(
|
|
root,
|
|
execution_enabled=False,
|
|
runtime_executor=runtime_executor,
|
|
)
|
|
|
|
def create_release_candidate(
|
|
self, payload: Mapping[str, Any]
|
|
) -> dict[str, Any]:
|
|
values = dict(payload)
|
|
raw_plan = values.pop("deployment_plan", None)
|
|
candidate = ReleaseCandidate.from_dict(values)
|
|
try:
|
|
existing = self.candidates.load(candidate.candidate_id)
|
|
except FileNotFoundError:
|
|
self.candidates.save(candidate)
|
|
existing = candidate
|
|
else:
|
|
if existing.fingerprint != candidate.fingerprint:
|
|
raise SagaConflict(
|
|
"candidate id is already bound to other evidence"
|
|
)
|
|
self.sagas.create(
|
|
existing.candidate_id,
|
|
f"pending-{existing.candidate_id}",
|
|
)
|
|
result = existing.to_dict()
|
|
if raw_plan is not None:
|
|
plan = DeploymentPlan.from_dict(raw_plan)
|
|
if (
|
|
plan.source_commit != existing.source_commit
|
|
or plan.artifact_sha256 != existing.artifact_sha256
|
|
or plan.manifest_sha256 != existing.manifest_sha256
|
|
):
|
|
raise StaleApprovalError(
|
|
"deployment plan does not match release candidate"
|
|
)
|
|
try:
|
|
stored = self.plans.load(existing.candidate_id)
|
|
except FileNotFoundError:
|
|
self.plans.save(existing.candidate_id, plan)
|
|
else:
|
|
if stored.sha256 != plan.sha256:
|
|
raise SagaConflict(
|
|
"candidate has a different deployment plan"
|
|
)
|
|
result["deployment_plan_sha256"] = plan.sha256
|
|
return result
|
|
|
|
def get_release_candidate(self, candidate_id: str) -> dict[str, Any]:
|
|
return self.candidates.load(candidate_id).to_dict()
|
|
|
|
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
|
return self.sagas.load(candidate_id).to_dict()
|
|
|
|
def get_approval_envelope(self, candidate_id: str) -> dict[str, Any]:
|
|
candidate = self.candidates.load(candidate_id)
|
|
try:
|
|
deployment_plan_sha256 = self.plans.load(
|
|
candidate_id
|
|
).sha256
|
|
except FileNotFoundError:
|
|
deployment_plan_sha256 = None
|
|
return {
|
|
"candidate_id": candidate.candidate_id,
|
|
"candidate_fingerprint": candidate.fingerprint,
|
|
"execution_enabled": self.execution_enabled,
|
|
"required_capabilities": sorted(ALLOWED_CAPABILITIES),
|
|
"signature_algorithm": "Ed25519",
|
|
"deployment_plan_sha256": deployment_plan_sha256,
|
|
"policy_sha256": self.policy_sha256 or None,
|
|
"status": "READY_FOR_OWNER_SIGNATURE",
|
|
}
|
|
|
|
def submit_owner_approval(
|
|
self, payload: Mapping[str, Any]
|
|
) -> dict[str, Any]:
|
|
approval = SignedReleasePlanApproval.from_dict(payload)
|
|
if self.approval_verifier is None:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval verifier is not configured"
|
|
)
|
|
candidate = self.candidates.load(approval.candidate_id)
|
|
plan = self.plans.load(approval.candidate_id)
|
|
self.approval_verifier.verify(
|
|
approval,
|
|
candidate,
|
|
plan,
|
|
self.policy_sha256,
|
|
)
|
|
path = _safe_child(
|
|
self.approvals_root, approval.release_plan_id
|
|
)
|
|
if path.exists():
|
|
existing = SignedReleasePlanApproval.from_dict(
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
)
|
|
if existing.fingerprint != approval.fingerprint:
|
|
raise CapabilityAuthorizationError(
|
|
"release plan approval conflicts with stored approval"
|
|
)
|
|
result = {
|
|
"fingerprint": existing.fingerprint,
|
|
"status": "IDEMPOTENT",
|
|
}
|
|
self.sagas.bind_release_plan(
|
|
approval.candidate_id, approval.release_plan_id
|
|
)
|
|
return result
|
|
_atomic_json(path, approval.to_dict())
|
|
self.sagas.bind_release_plan(
|
|
approval.candidate_id, approval.release_plan_id
|
|
)
|
|
return {
|
|
"fingerprint": approval.fingerprint,
|
|
"status": "VERIFIED_AND_STORED",
|
|
}
|
|
|
|
def start_deployment(
|
|
self,
|
|
candidate_id: str,
|
|
*,
|
|
approval: SignedReleasePlanApproval | None = None,
|
|
) -> dict[str, Any]:
|
|
self.candidates.load(candidate_id)
|
|
if not self.execution_enabled:
|
|
return {
|
|
"candidate_id": candidate_id,
|
|
"execution_enabled": False,
|
|
"side_effects": [],
|
|
"status": "SHADOW_MODE",
|
|
}
|
|
if approval is None:
|
|
raise CapabilityAuthorizationError(
|
|
"signed owner approval is required"
|
|
)
|
|
if self.approval_verifier is None:
|
|
raise CapabilityAuthorizationError(
|
|
"owner approval verifier is not configured"
|
|
)
|
|
candidate = self.candidates.load(candidate_id)
|
|
plan = self.plans.load(candidate_id)
|
|
self.approval_verifier.verify(
|
|
approval,
|
|
candidate,
|
|
plan,
|
|
self.policy_sha256,
|
|
)
|
|
if self.pre_switch_validator is None:
|
|
raise CapabilityAuthorizationError(
|
|
"pre-switch validator is not configured"
|
|
)
|
|
self.pre_switch_validator.validate(
|
|
candidate,
|
|
approval,
|
|
plan,
|
|
expected_repository=self.expected_repository,
|
|
)
|
|
if self.runtime_executor is None:
|
|
raise CapabilityAuthorizationError(
|
|
"runtime execution adapter is not configured"
|
|
)
|
|
return _sanitize(
|
|
dict(
|
|
self.runtime_executor.execute(
|
|
candidate_id=candidate_id,
|
|
approval=approval,
|
|
)
|
|
)
|
|
)
|