743 lines
40 KiB
Python
743 lines
40 KiB
Python
"""Release-owned deployment targets and a receipt-gated persistent saga.
|
|
|
|
The module persists intent before invoking a governed operation adapter. It
|
|
does not contain a shell runner, production signer, or success simulator. A
|
|
configured verifier must translate an adapter/sink receipt into a typed,
|
|
operation-bound :class:`VerifiedReceipt` before a stage can advance.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sqlite3
|
|
from typing import Any, Callable, Mapping, Sequence
|
|
|
|
from .lineage import (
|
|
LineageLedger,
|
|
VerifiedAcceptanceEvidence,
|
|
canonical_artifact,
|
|
decode_artifact,
|
|
)
|
|
|
|
|
|
class ReleaseError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class ReleaseConflict(ReleaseError):
|
|
pass
|
|
|
|
|
|
class ReleaseVerificationError(ReleaseError):
|
|
pass
|
|
|
|
|
|
class ReleaseAuthorizationError(ReleaseError):
|
|
pass
|
|
|
|
|
|
class ReleaseReconciliationRequired(ReleaseError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerifiedReceipt:
|
|
"""Trusted verifier output, fully bound to one persisted stage effect."""
|
|
|
|
receipt_id: str
|
|
tenant_id: str
|
|
subject_id: str
|
|
component_id: str
|
|
task_id: str
|
|
source_task_id: str
|
|
source_effect_id: str
|
|
grant_id: str
|
|
job_id: str
|
|
effect_id: str
|
|
operation_id: str
|
|
deployment_id: str
|
|
release_id: str
|
|
artifact: Any
|
|
observed: Mapping[str, Any]
|
|
outcome: str = "SUCCEEDED"
|
|
observed_at: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnvironmentDeploymentTarget:
|
|
tenant_id: str
|
|
component_id: str
|
|
environment: str
|
|
release_id: str
|
|
artifact: Any
|
|
revision: int
|
|
stage: str
|
|
source_revision: str | None
|
|
observed_main_revision: str | None
|
|
previous_release_id: str | None
|
|
previous_artifact: Any | None
|
|
rollback_release_id: str | None
|
|
rollback_artifact: Any | None
|
|
rollback_compatible: bool
|
|
updated_by_subject_id: str
|
|
updated_at: str
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _required(value: Any, name: str) -> str:
|
|
text = str(value or "")
|
|
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
|
raise ValueError(f"invalid {name}")
|
|
return text
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
|
|
|
|
def _recipe_digest(stages: Sequence[Mapping[str, Any]]) -> str:
|
|
return hashlib.sha256(_json(list(stages)).encode()).hexdigest()
|
|
|
|
|
|
class ReleaseLedger:
|
|
"""The release domain authority for dynamic targets and deployment sagas."""
|
|
|
|
TARGET_STAGES = {"STAGED", "ACTIVE", "ROLLBACK_STAGED"}
|
|
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
lineage: LineageLedger,
|
|
receipt_verifier: Callable[[Any, Mapping[str, Any]], VerifiedReceipt],
|
|
target_authorizer: Callable[[str,str,str,str,str], bool],
|
|
):
|
|
if not isinstance(lineage, LineageLedger):
|
|
raise ValueError("lineage ledger is required")
|
|
if not callable(receipt_verifier):
|
|
raise ValueError("receipt_verifier is required")
|
|
if not callable(target_authorizer):
|
|
raise ValueError("target_authorizer is required")
|
|
self.path = str(path)
|
|
self.lineage = lineage
|
|
self.receipt_verifier = receipt_verifier
|
|
self.target_authorizer = target_authorizer
|
|
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
self._initialize()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(self.path, timeout=30)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
connection.execute("PRAGMA busy_timeout=30000")
|
|
return connection
|
|
|
|
def _initialize(self) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
if "release_targets" in tables and "release_schema_meta" not in tables:
|
|
raise ReleaseConflict("unversioned candidate release schema requires explicit migration")
|
|
if "release_schema_meta" in tables:
|
|
version=connection.execute("SELECT version FROM release_schema_meta WHERE domain='release'").fetchone()
|
|
if version is None or version[0] != 2:
|
|
raise ReleaseConflict("unsupported release schema version")
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS release_schema_meta (
|
|
domain TEXT PRIMARY KEY,
|
|
version INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS release_recipes (
|
|
recipe_id TEXT PRIMARY KEY,
|
|
recipe_digest TEXT NOT NULL,
|
|
stages_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS release_targets (
|
|
tenant_id TEXT NOT NULL,
|
|
component_id TEXT NOT NULL,
|
|
environment TEXT NOT NULL,
|
|
release_id TEXT NOT NULL,
|
|
artifact_json TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
stage TEXT NOT NULL,
|
|
source_revision TEXT,
|
|
observed_main_revision TEXT,
|
|
previous_release_id TEXT,
|
|
previous_artifact_json TEXT,
|
|
rollback_release_id TEXT,
|
|
rollback_artifact_json TEXT,
|
|
rollback_compatible INTEGER NOT NULL CHECK (rollback_compatible IN (0,1)),
|
|
updated_by_subject_id TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (tenant_id, component_id, environment)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS release_sagas (
|
|
tenant_id TEXT NOT NULL,
|
|
saga_id TEXT NOT NULL,
|
|
subject_id TEXT NOT NULL,
|
|
mission_id TEXT NOT NULL,
|
|
component_id TEXT NOT NULL,
|
|
source_task_id TEXT NOT NULL,
|
|
source_effect_id TEXT NOT NULL,
|
|
grant_id TEXT NOT NULL,
|
|
release_id TEXT NOT NULL,
|
|
deployment_id TEXT NOT NULL,
|
|
environment TEXT NOT NULL,
|
|
artifact_json TEXT NOT NULL,
|
|
recipe_id TEXT NOT NULL,
|
|
recipe_digest TEXT NOT NULL,
|
|
target_revision INTEGER NOT NULL,
|
|
stage_index INTEGER NOT NULL,
|
|
state TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (tenant_id, saga_id),
|
|
UNIQUE (tenant_id, deployment_id),
|
|
FOREIGN KEY (recipe_id) REFERENCES release_recipes(recipe_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS release_effect_intents (
|
|
tenant_id TEXT NOT NULL,
|
|
saga_id TEXT NOT NULL,
|
|
stage_index INTEGER NOT NULL,
|
|
stage_id TEXT NOT NULL,
|
|
operation_id TEXT NOT NULL,
|
|
effect_id TEXT NOT NULL,
|
|
job_id TEXT,
|
|
state TEXT NOT NULL,
|
|
receipt_id TEXT,
|
|
receipt_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (tenant_id, saga_id, stage_index),
|
|
UNIQUE (tenant_id, effect_id),
|
|
FOREIGN KEY (tenant_id, saga_id)
|
|
REFERENCES release_sagas(tenant_id, saga_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS release_deployment_facts (
|
|
tenant_id TEXT NOT NULL,
|
|
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
subject_id TEXT NOT NULL,
|
|
mission_id TEXT NOT NULL,
|
|
component_id TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
grant_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
deployment_id TEXT NOT NULL,
|
|
release_id TEXT NOT NULL,
|
|
artifact_json TEXT NOT NULL,
|
|
saga_id TEXT NOT NULL,
|
|
stage_id TEXT NOT NULL,
|
|
operation_id TEXT NOT NULL,
|
|
effect_id TEXT NOT NULL,
|
|
receipt_id TEXT NOT NULL,
|
|
observed_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE (tenant_id, effect_id, receipt_id),
|
|
FOREIGN KEY (tenant_id, saga_id)
|
|
REFERENCES release_sagas(tenant_id, saga_id)
|
|
);
|
|
"""
|
|
)
|
|
connection.execute("INSERT OR IGNORE INTO release_schema_meta(domain,version) VALUES('release',2)")
|
|
|
|
def close(self) -> None:
|
|
"""Compatibility hook; connections are transaction-scoped."""
|
|
|
|
def _authorize(self, tenant_id: str, subject_id: str, component_id: str, environment: str, action: str) -> None:
|
|
try:
|
|
authorized=self.target_authorizer(tenant_id,subject_id,component_id,environment,action)
|
|
except Exception as exc:
|
|
raise ReleaseAuthorizationError("deployment target authorization unavailable") from exc
|
|
if authorized is not True:
|
|
raise ReleaseAuthorizationError("deployment target authorization denied")
|
|
|
|
def register_recipe(self, recipe_id: str, stages: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|
recipe_id = _required(recipe_id, "recipe_id")
|
|
if not isinstance(stages, Sequence) or isinstance(stages, (str, bytes)) or not stages:
|
|
raise ValueError("recipe stages must be a non-empty sequence")
|
|
normalized = []
|
|
stage_ids = set()
|
|
for item in stages:
|
|
if not isinstance(item, Mapping) or set(item) - {"stage_id", "operation_id", "parameters"}:
|
|
raise ValueError("invalid recipe stage")
|
|
stage_id = _required(item.get("stage_id"), "stage_id")
|
|
operation_id = _required(item.get("operation_id"), "operation_id")
|
|
if stage_id in stage_ids:
|
|
raise ValueError("duplicate recipe stage_id")
|
|
stage_ids.add(stage_id)
|
|
parameters = item.get("parameters", {})
|
|
if not isinstance(parameters, Mapping):
|
|
raise ValueError("stage parameters must be an object")
|
|
normalized.append({"stage_id": stage_id, "operation_id": operation_id, "parameters": dict(parameters)})
|
|
stages_json = _json(normalized)
|
|
digest = _recipe_digest(normalized)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
existing = connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?", (recipe_id,)).fetchone()
|
|
if existing:
|
|
if existing["recipe_digest"] != digest:
|
|
raise ReleaseConflict("recipe_id already has different immutable stages")
|
|
else:
|
|
connection.execute("INSERT INTO release_recipes VALUES(?,?,?,?)", (recipe_id, digest, stages_json, _now()))
|
|
return {"recipe_id": recipe_id, "recipe_digest": digest, "stages": normalized}
|
|
|
|
def set_target(
|
|
self,
|
|
tenant_id: str,
|
|
subject_id: str,
|
|
component_id: str,
|
|
environment: str,
|
|
release_id: str,
|
|
artifact: Any,
|
|
*,
|
|
expected_revision: int | None,
|
|
stage: str = "STAGED",
|
|
source_revision: str | None = None,
|
|
observed_main_revision: str | None = None,
|
|
rollback_release_id: str | None = None,
|
|
rollback_artifact: Any | None = None,
|
|
rollback_compatible: bool = False,
|
|
) -> EnvironmentDeploymentTarget:
|
|
tenant_id, subject_id, component_id, environment, release_id = map(
|
|
lambda pair: _required(pair[0], pair[1]),
|
|
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(component_id,"component_id"),(environment,"environment"),(release_id,"release_id")),
|
|
)
|
|
self._authorize(tenant_id,subject_id,component_id,environment,"write")
|
|
if stage not in self.TARGET_STAGES:
|
|
raise ValueError("invalid target stage")
|
|
if expected_revision is not None and (not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0):
|
|
raise ValueError("invalid expected_revision")
|
|
if type(rollback_compatible) is not bool:
|
|
raise ValueError("rollback_compatible must be boolean")
|
|
artifact_json = canonical_artifact(artifact)
|
|
try:
|
|
linked_release = self.lineage.get_release_for_component(tenant_id, component_id, release_id)
|
|
except KeyError as exc:
|
|
raise ReleaseConflict("deployment target release has no lineage") from exc
|
|
if canonical_artifact(linked_release["artifact"]) != artifact_json:
|
|
raise ReleaseConflict("deployment target artifact does not match release")
|
|
rollback_artifact_json = canonical_artifact(rollback_artifact) if rollback_artifact is not None else None
|
|
source_revision = _required(source_revision, "source_revision") if source_revision else None
|
|
observed_main_revision = _required(observed_main_revision, "observed_main_revision") if observed_main_revision else None
|
|
rollback_release_id = _required(rollback_release_id, "rollback_release_id") if rollback_release_id else None
|
|
if (rollback_release_id is None) != (rollback_artifact_json is None):
|
|
raise ValueError("rollback release and artifact must be supplied together")
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current = connection.execute(
|
|
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
|
(tenant_id, component_id, environment),
|
|
).fetchone()
|
|
if current is None:
|
|
if expected_revision is not None:
|
|
raise ReleaseConflict("expected revision does not match absent target")
|
|
if stage == "ROLLBACK_STAGED":
|
|
raise ReleaseConflict("rollback requires a current target")
|
|
revision = 1
|
|
previous_release_id = previous_artifact_json = None
|
|
else:
|
|
if expected_revision != current["revision"]:
|
|
raise ReleaseConflict("expected revision does not match target")
|
|
if stage == "ROLLBACK_STAGED":
|
|
if not current["rollback_compatible"]:
|
|
raise ReleaseConflict("current target has no compatible rollback")
|
|
if release_id != current["rollback_release_id"] or artifact_json != current["rollback_artifact_json"]:
|
|
raise ReleaseConflict("rollback target does not match compatible rollback binding")
|
|
revision = current["revision"] + 1
|
|
previous_release_id = current["release_id"]
|
|
previous_artifact_json = current["artifact_json"]
|
|
if rollback_compatible and rollback_release_id is None:
|
|
rollback_release_id = previous_release_id
|
|
rollback_artifact_json = previous_artifact_json
|
|
if rollback_compatible and current is not None and rollback_release_id is None:
|
|
raise ReleaseConflict("compatible rollback requires a previous target")
|
|
now = _now()
|
|
connection.execute(
|
|
"""INSERT INTO release_targets VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(tenant_id,component_id,environment) DO UPDATE SET
|
|
release_id=excluded.release_id, artifact_json=excluded.artifact_json,
|
|
revision=excluded.revision, stage=excluded.stage,
|
|
source_revision=excluded.source_revision,
|
|
observed_main_revision=excluded.observed_main_revision,
|
|
previous_release_id=excluded.previous_release_id,
|
|
previous_artifact_json=excluded.previous_artifact_json,
|
|
rollback_release_id=excluded.rollback_release_id,
|
|
rollback_artifact_json=excluded.rollback_artifact_json,
|
|
rollback_compatible=excluded.rollback_compatible,
|
|
updated_by_subject_id=excluded.updated_by_subject_id,
|
|
updated_at=excluded.updated_at""",
|
|
(tenant_id,component_id,environment,release_id,artifact_json,revision,stage,
|
|
source_revision,observed_main_revision,previous_release_id,previous_artifact_json,
|
|
rollback_release_id,rollback_artifact_json,int(rollback_compatible),subject_id,now),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
|
(tenant_id,component_id,environment),
|
|
).fetchone()
|
|
return self._target(row)
|
|
|
|
def get_target(self, tenant_id: str, subject_id: str, component_id: str, environment: str) -> EnvironmentDeploymentTarget:
|
|
tenant_id,subject_id,component_id,environment=(
|
|
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
|
_required(component_id,"component_id"),_required(environment,"environment"),
|
|
)
|
|
self._authorize(tenant_id,subject_id,component_id,environment,"read")
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
|
(tenant_id,component_id,environment),
|
|
).fetchone()
|
|
if row is None:
|
|
raise KeyError("deployment target not found")
|
|
return self._target(row)
|
|
|
|
@staticmethod
|
|
def _target(row: sqlite3.Row) -> EnvironmentDeploymentTarget:
|
|
return EnvironmentDeploymentTarget(
|
|
tenant_id=row["tenant_id"], component_id=row["component_id"], environment=row["environment"],
|
|
release_id=row["release_id"], artifact=decode_artifact(row["artifact_json"]), revision=row["revision"],
|
|
stage=row["stage"], source_revision=row["source_revision"], observed_main_revision=row["observed_main_revision"],
|
|
previous_release_id=row["previous_release_id"],
|
|
previous_artifact=decode_artifact(row["previous_artifact_json"]) if row["previous_artifact_json"] else None,
|
|
rollback_release_id=row["rollback_release_id"],
|
|
rollback_artifact=decode_artifact(row["rollback_artifact_json"]) if row["rollback_artifact_json"] else None,
|
|
rollback_compatible=bool(row["rollback_compatible"]),
|
|
updated_by_subject_id=row["updated_by_subject_id"], updated_at=row["updated_at"],
|
|
)
|
|
|
|
def start_saga(
|
|
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, saga_id: str,
|
|
release_id: str, deployment_id: str, environment: str, artifact: Any,
|
|
recipe_id: str, *, expected_target_revision: int,
|
|
) -> dict[str, Any]:
|
|
tenant_id,subject_id,mission_id,component_id,saga_id,release_id,deployment_id,environment,recipe_id=map(
|
|
lambda pair:_required(pair[0],pair[1]),
|
|
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),(saga_id,"saga_id"),
|
|
(release_id,"release_id"),(deployment_id,"deployment_id"),(environment,"environment"),(recipe_id,"recipe_id")),
|
|
)
|
|
if not isinstance(expected_target_revision,int) or isinstance(expected_target_revision,bool) or expected_target_revision < 1:
|
|
raise ValueError("invalid expected_target_revision")
|
|
self._authorize(tenant_id,subject_id,component_id,environment,"deploy")
|
|
artifact_json=canonical_artifact(artifact)
|
|
target=self.get_target(tenant_id,subject_id,component_id,environment)
|
|
if target.revision != expected_target_revision:
|
|
raise ReleaseConflict("expected target revision does not match")
|
|
if target.stage not in {"STAGED","ROLLBACK_STAGED"}:
|
|
raise ReleaseConflict("deployment target is not staged")
|
|
if target.release_id != release_id or canonical_artifact(target.artifact) != artifact_json:
|
|
raise ReleaseConflict("saga does not match deployment target")
|
|
graph=self.lineage.explain(tenant_id,subject_id,mission_id)
|
|
release=next((item for item in graph["releases"] if item["release_id"] == release_id),None)
|
|
if release is None or release["component_id"] != component_id or canonical_artifact(release["artifact"]) != artifact_json:
|
|
raise ReleaseConflict("release has no exact source lineage")
|
|
source_task=next((item for item in graph["tasks"] if item["task_id"] == release["source_task_id"]),None)
|
|
if source_task is None or source_task["component_id"] != component_id:
|
|
raise ReleaseConflict("release source Task component lineage missing")
|
|
with self._connect() as connection:
|
|
recipe=connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
|
if recipe is None:
|
|
raise ReleaseConflict("recipe not found")
|
|
# The relationship is durable before saga admission and is idempotent on retry.
|
|
self.lineage.link_deployment(
|
|
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment,
|
|
expected_target_revision,decode_artifact(artifact_json),
|
|
metadata={"saga_id":saga_id,"recipe_id":recipe_id,"recipe_digest":recipe["recipe_digest"]},
|
|
)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
existing=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
|
expected=(subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,recipe_id,recipe["recipe_digest"],expected_target_revision)
|
|
if existing:
|
|
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","source_task_id","source_effect_id","grant_id","release_id","deployment_id","environment","artifact_json","recipe_id","recipe_digest","target_revision"))
|
|
if actual != expected:
|
|
raise ReleaseConflict("saga_id already has different immutable binding")
|
|
return self._saga(connection,existing)
|
|
collision=connection.execute("SELECT saga_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
|
if collision:
|
|
raise ReleaseConflict("deployment_id already has a different saga")
|
|
now=_now()
|
|
connection.execute(
|
|
"INSERT INTO release_sagas VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(tenant_id,saga_id,subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,
|
|
recipe_id,recipe["recipe_digest"],expected_target_revision,0,"RUNNING",0,now,now),
|
|
)
|
|
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
|
return self._saga(connection,row)
|
|
|
|
def get_saga(self, tenant_id: str, subject_id: str, saga_id: str) -> dict[str, Any]:
|
|
with self._connect() as connection:
|
|
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(_required(tenant_id,"tenant_id"),_required(saga_id,"saga_id"))).fetchone()
|
|
if row is None:
|
|
raise KeyError("release saga not found")
|
|
if row["subject_id"] != _required(subject_id,"subject_id"):
|
|
raise ReleaseAuthorizationError("release saga subject denied")
|
|
return self._saga(connection,row)
|
|
|
|
@staticmethod
|
|
def _stages(connection: sqlite3.Connection, recipe_id: str) -> list[dict[str, Any]]:
|
|
row=connection.execute("SELECT stages_json FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
|
if row is None:
|
|
raise ReleaseConflict("persisted recipe missing")
|
|
return json.loads(row["stages_json"])
|
|
|
|
def _saga(self, connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
|
|
stages=self._stages(connection,row["recipe_id"])
|
|
index=row["stage_index"]
|
|
return {
|
|
"tenant_id":row["tenant_id"],"subject_id":row["subject_id"],"mission_id":row["mission_id"],
|
|
"component_id":row["component_id"],"source_task_id":row["source_task_id"],
|
|
"source_effect_id":row["source_effect_id"],"grant_id":row["grant_id"],
|
|
"saga_id":row["saga_id"],"release_id":row["release_id"],"deployment_id":row["deployment_id"],
|
|
"environment":row["environment"],"artifact":decode_artifact(row["artifact_json"]),
|
|
"recipe_id":row["recipe_id"],"recipe_digest":row["recipe_digest"],
|
|
"target_revision":row["target_revision"],"stage_index":index,
|
|
"stage_id":stages[index]["stage_id"] if index < len(stages) else None,
|
|
"state":row["state"],"revision":row["revision"],"created_at":row["created_at"],"updated_at":row["updated_at"],
|
|
}
|
|
|
|
def advance_saga(
|
|
self, tenant_id: str, subject_id: str, saga_id: str, expected_revision: int,
|
|
operation_adapter: Any,
|
|
) -> dict[str, Any]:
|
|
tenant_id,subject_id,saga_id=(
|
|
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(saga_id,"saga_id")
|
|
)
|
|
if not isinstance(expected_revision,int) or isinstance(expected_revision,bool) or expected_revision < 0:
|
|
raise ValueError("invalid expected_revision")
|
|
prepare=getattr(operation_adapter,"prepare",None)
|
|
execute=getattr(operation_adapter,"execute",None)
|
|
if not callable(prepare) or not callable(execute):
|
|
raise ValueError("governed operation adapter requires prepare and execute")
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
saga=connection.execute(
|
|
"SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
|
).fetchone()
|
|
if saga is None:
|
|
raise KeyError("release saga not found")
|
|
if saga["subject_id"] != subject_id:
|
|
raise ReleaseAuthorizationError("saga belongs to different subject")
|
|
self._authorize(tenant_id,subject_id,saga["component_id"],saga["environment"],"deploy")
|
|
if saga["revision"] != expected_revision:
|
|
raise ReleaseConflict("expected revision does not match saga")
|
|
if saga["state"] == "COMPLETED":
|
|
return self._saga(connection,saga)
|
|
stages=self._stages(connection,saga["recipe_id"])
|
|
stage=stages[saga["stage_index"]]
|
|
effect_id=f"release:{saga_id}:{saga['stage_index']}:{stage['stage_id']}"
|
|
intent=connection.execute(
|
|
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(tenant_id,saga_id,saga["stage_index"]),
|
|
).fetchone()
|
|
if intent is None:
|
|
now=_now()
|
|
connection.execute(
|
|
"INSERT INTO release_effect_intents VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(tenant_id,saga_id,saga["stage_index"],stage["stage_id"],stage["operation_id"],effect_id,
|
|
None,"PENDING_JOB",None,None,now,now),
|
|
)
|
|
elif (intent["effect_id"],intent["stage_id"],intent["operation_id"]) != (
|
|
effect_id,stage["stage_id"],stage["operation_id"]
|
|
):
|
|
raise ReleaseConflict("persisted stage effect identity mismatch")
|
|
operation={
|
|
"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":saga["mission_id"],
|
|
"component_id":saga["component_id"],"task_id":saga["source_task_id"],
|
|
"source_task_id":saga["source_task_id"],"source_effect_id":saga["source_effect_id"],
|
|
"grant_id":saga["grant_id"],"saga_id":saga_id,
|
|
"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
|
"environment":saga["environment"],"artifact":decode_artifact(saga["artifact_json"]),
|
|
"target_revision":saga["target_revision"],"stage_index":saga["stage_index"],
|
|
"stage_id":stage["stage_id"],"operation_id":stage["operation_id"],"effect_id":effect_id,
|
|
"parameters":stage.get("parameters",{}),
|
|
}
|
|
with self._connect() as connection:
|
|
intent=connection.execute(
|
|
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(tenant_id,saga_id,saga["stage_index"]),
|
|
).fetchone()
|
|
job_id=intent["job_id"]
|
|
if job_id is None:
|
|
prepared=prepare(dict(operation))
|
|
job_id=prepared.get("job_id") if isinstance(prepared,Mapping) else prepared
|
|
job_id=_required(job_id,"prepared job_id")
|
|
# This is the first point where the relationship graph may name a Job:
|
|
# link_effect independently resolves the configured real Job authority.
|
|
self.lineage.link_effect(
|
|
tenant_id,subject_id,saga["mission_id"],saga["source_task_id"],effect_id,job_id,
|
|
source_effect_id=saga["source_effect_id"],
|
|
metadata={"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
|
"component_id":saga["component_id"],"stage_id":stage["stage_id"]},
|
|
)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current=connection.execute(
|
|
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",
|
|
(tenant_id,saga_id),
|
|
).fetchone()
|
|
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
|
raise ReleaseConflict("expected revision changed before Job binding")
|
|
bound=connection.execute(
|
|
"SELECT job_id FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(tenant_id,saga_id,saga["stage_index"]),
|
|
).fetchone()
|
|
if bound["job_id"] not in (None,job_id):
|
|
raise ReleaseConflict("stage effect already bound to different Job")
|
|
connection.execute(
|
|
"UPDATE release_effect_intents SET job_id=?,state='JOB_LINKED',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(job_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
|
)
|
|
operation["job_id"]=job_id
|
|
with self._connect() as connection:
|
|
intent=connection.execute(
|
|
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(tenant_id,saga_id,saga["stage_index"]),
|
|
).fetchone()
|
|
if intent["receipt_json"]:
|
|
raw_receipt=json.loads(intent["receipt_json"])
|
|
elif intent["state"] == "EFFECT_UNKNOWN":
|
|
reconcile=getattr(operation_adapter,"reconcile",None)
|
|
if not callable(reconcile):
|
|
raise ReleaseReconciliationRequired("RECONCILIATION_REQUIRED: adapter readback unavailable")
|
|
raw_receipt=reconcile(dict(operation))
|
|
else:
|
|
try:
|
|
raw_receipt=execute(dict(operation))
|
|
except Exception:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
|
)
|
|
raise
|
|
if not isinstance(raw_receipt,Mapping):
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
|
)
|
|
raise ReleaseVerificationError("operation adapter receipt must be an object")
|
|
try:
|
|
unverified_receipt_json=_json(dict(raw_receipt))
|
|
except (TypeError,ValueError) as exc:
|
|
raise ReleaseVerificationError("operation adapter receipt must be canonical JSON") from exc
|
|
if len(unverified_receipt_json.encode("utf-8")) > 262_144:
|
|
raise ReleaseVerificationError("operation adapter receipt is too large")
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current=connection.execute(
|
|
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
|
).fetchone()
|
|
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
|
raise ReleaseConflict("expected revision changed before receipt persistence")
|
|
connection.execute(
|
|
"UPDATE release_effect_intents SET state='RECEIPT_UNVERIFIED',receipt_json=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(unverified_receipt_json,_now(),tenant_id,saga_id,saga["stage_index"]),
|
|
)
|
|
expected={key:operation[key] for key in (
|
|
"tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
|
"job_id","effect_id","operation_id","deployment_id","release_id","artifact"
|
|
)}
|
|
try:
|
|
verified=self.receipt_verifier(raw_receipt,expected)
|
|
except Exception as exc:
|
|
raise ReleaseVerificationError("configured verifier rejected operation receipt") from exc
|
|
if not isinstance(verified,VerifiedReceipt):
|
|
raise ReleaseVerificationError("receipt_verifier must return VerifiedReceipt")
|
|
if verified.outcome != "SUCCEEDED":
|
|
raise ReleaseVerificationError("verified receipt outcome is not SUCCEEDED")
|
|
fields=("tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
|
"job_id","effect_id","operation_id","deployment_id","release_id","artifact")
|
|
bindings=tuple(canonical_artifact(getattr(verified,name)) if name == "artifact" else getattr(verified,name) for name in fields)
|
|
expected_bindings=tuple(canonical_artifact(operation[name]) if name == "artifact" else operation[name] for name in fields)
|
|
if bindings != expected_bindings:
|
|
wrong=next(name for name,actual,wanted in zip(fields,bindings,expected_bindings) if actual != wanted)
|
|
raise ReleaseVerificationError(f"verified receipt {wrong} binding mismatch")
|
|
receipt_id=_required(verified.receipt_id,"receipt_id")
|
|
if not isinstance(verified.observed,Mapping):
|
|
raise ReleaseVerificationError("verified receipt observation must be an object")
|
|
observed_json=_json(dict(verified.observed))
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
|
if current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
|
raise ReleaseConflict("expected revision changed before receipt commit")
|
|
connection.execute(
|
|
"UPDATE release_effect_intents SET state='VERIFIED',receipt_id=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
|
(receipt_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
|
)
|
|
connection.execute(
|
|
"""INSERT OR IGNORE INTO release_deployment_facts
|
|
(tenant_id,subject_id,mission_id,component_id,task_id,grant_id,job_id,deployment_id,release_id,artifact_json,saga_id,stage_id,operation_id,effect_id,receipt_id,observed_json,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(tenant_id,subject_id,saga["mission_id"],saga["component_id"],saga["source_task_id"],saga["grant_id"],job_id,
|
|
saga["deployment_id"],saga["release_id"],saga["artifact_json"],saga_id,stage["stage_id"],stage["operation_id"],
|
|
effect_id,receipt_id,observed_json,_now()),
|
|
)
|
|
next_index=saga["stage_index"]+1
|
|
state="COMPLETED" if next_index == len(stages) else "RUNNING"
|
|
updated=connection.execute(
|
|
"UPDATE release_sagas SET stage_index=?,state=?,revision=revision+1,updated_at=? WHERE tenant_id=? AND saga_id=? AND revision=?",
|
|
(next_index,state,_now(),tenant_id,saga_id,expected_revision),
|
|
)
|
|
if updated.rowcount != 1:
|
|
raise ReleaseConflict("expected revision changed")
|
|
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
|
return self._saga(connection,row)
|
|
|
|
|
|
def deployment_facts(self, tenant_id: str, subject_id: str, component_id: str, deployment_id: str) -> list[dict[str, Any]]:
|
|
tenant_id,subject_id,component_id,deployment_id=(
|
|
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
|
_required(component_id,"component_id"),_required(deployment_id,"deployment_id"),
|
|
)
|
|
with self._connect() as connection:
|
|
saga=connection.execute(
|
|
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
|
(tenant_id,deployment_id),
|
|
).fetchone()
|
|
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
|
raise ReleaseAuthorizationError("deployment facts scope denied")
|
|
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"read")
|
|
rows=connection.execute(
|
|
"SELECT * FROM release_deployment_facts WHERE tenant_id=? AND subject_id=? AND component_id=? AND deployment_id=? ORDER BY fact_id",
|
|
(tenant_id,subject_id,component_id,deployment_id),
|
|
).fetchall()
|
|
return [{**{key:row[key] for key in row.keys() if not key.endswith("_json")},
|
|
"artifact":decode_artifact(row["artifact_json"]),"observed":json.loads(row["observed_json"])} for row in rows]
|
|
|
|
def record_acceptance(
|
|
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
|
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
|
evidence: Any,
|
|
verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
|
) -> dict[str, Any]:
|
|
with self._connect() as connection:
|
|
saga=connection.execute(
|
|
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
|
(tenant_id,deployment_id),
|
|
).fetchone()
|
|
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
|
raise ReleaseAuthorizationError("acceptance deployment scope denied")
|
|
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"accept")
|
|
return self.lineage.record_acceptance(
|
|
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id,
|
|
artifact,accepted,evidence=evidence,verifier=verifier,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"EnvironmentDeploymentTarget", "ReleaseAuthorizationError", "ReleaseConflict", "ReleaseError", "ReleaseLedger",
|
|
"ReleaseReconciliationRequired", "ReleaseVerificationError", "VerifiedReceipt",
|
|
]
|