334 lines
16 KiB
Python

"""HUX-12 immutable, hash-chained deployment follow-through.
Each release is bound to one project and conversation. The current view is
reconstructed from append-only entries; every read verifies sequence, hash
chain and contract before returning anything. ``live_verified`` is reachable
only from a digest-converged pod with a passing health check.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
from urllib.parse import urlsplit
from hux import contracts, redaction
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.store import check_id, new_id, now_iso
CARD = "HUX-12"
FAMILY = "release_ledger"
MAX_RELEASES = 500
ZERO_HASH = "sha256:" + "0" * 64
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
WORKLOADS = frozenset({"hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"})
EVIDENCE_FIELDS = frozenset({"review_url", "merge_commit", "ci_build_url", "image_ref", "image_digest", "harbor_digest", "flux_revision", "pod_digest", "health_check", "rollback_target"})
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
COMMIT = re.compile(r"^[0-9a-f]{40}$")
IMAGE = re.compile(r"^[a-z0-9./-]+:[A-Za-z0-9._-]+@(sha256:[0-9a-f]{64})$")
SCHEMAS = contracts.load_all()
SCHEMAS["release-ledger.schema.json"] = contracts.load_schema("release-ledger.schema.json")
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected release fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, str]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation_id
def _actor(request: Request) -> dict[str, str]:
if request.identity.trust == "worker":
return {"type": "system", "id": "release-worker"}
return {"type": "user", "id": request.identity.subject}
def _safe_url(value: Any, *, relative: bool = False) -> str:
if not isinstance(value, str) or not value or len(value) > 500:
raise Invalid("evidence URL is missing or too long")
parsed = urlsplit(value)
if parsed.query or parsed.fragment or parsed.username or parsed.password:
raise Invalid("evidence URLs may not contain credentials, queries or fragments")
if relative:
if not ((not parsed.scheme and value.startswith("/")) or parsed.scheme == "https"):
raise Invalid("health URL must be HTTPS or a local absolute path")
elif parsed.scheme != "https" or not parsed.netloc:
raise Invalid("evidence URL must use HTTPS")
if redaction.scrub_text(value)[1]:
raise Invalid("evidence URL appears to contain a secret")
return value
def _canonical(data: Any) -> bytes:
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
def _entry_hash(entry: dict[str, Any]) -> str:
unsigned = {key: value for key, value in entry.items() if key != "entry_hash"}
return "sha256:" + hashlib.sha256(_canonical(unsigned)).hexdigest()
def _validate_release(release: dict[str, Any]) -> None:
problems = contracts.validate("release.schema.json", release, SCHEMAS)
if problems:
raise Invalid("release failed contract validation", problems)
def _validate_entry(entry: dict[str, Any]) -> None:
problems = contracts.validate("release-ledger.schema.json", entry, SCHEMAS)
if problems:
raise Invalid("release ledger entry failed contract validation", problems)
def _rows(request: Request, release_id: str) -> list[dict[str, Any]]:
try:
check_id(release_id)
except Invalid as error:
raise NotFound("release not found") from error
rows = request.store.read(FAMILY, release_id)
if not rows:
raise NotFound("release not found")
previous = ZERO_HASH
for sequence, row in enumerate(rows, 1):
if row.get("sequence") != sequence or row.get("previous_hash") != previous or row.get("entry_hash") != _entry_hash(row):
raise Invalid("release ledger integrity check failed")
_validate_entry(row)
previous = row["entry_hash"]
return rows
def _append(request: Request, release: dict[str, Any], project_id: str, conversation_id: str, previous: list[dict[str, Any]]) -> dict[str, Any]:
entry = {
"schema": "hux.release_ledger_entry.v1", "release_id": release["id"], "owner": request.identity.subject,
"project_id": project_id, "conversation_id": conversation_id, "sequence": len(previous) + 1,
"at": now_iso(), "previous_hash": previous[-1]["entry_hash"] if previous else ZERO_HASH, "snapshot": release,
}
entry["entry_hash"] = _entry_hash(entry)
_validate_entry(entry)
request.store.append(FAMILY, release["id"], entry)
return entry
def _view(entry: dict[str, Any]) -> dict[str, Any]:
return {
"release": entry["snapshot"],
"scope": {"project_id": entry["project_id"], "conversation_id": entry["conversation_id"]},
"revision": entry["sequence"],
"ledger_hash": entry["entry_hash"],
}
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(_canonical({"body": body, "path": path})).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for different release evidence")
rows = _rows(request, row["release_id"])
if row["sequence"] > len(rows):
raise Invalid("release idempotency ledger is inconsistent")
return _view(rows[row["sequence"] - 1])
return None
def _remember(request: Request, key: str, fingerprint: str, release_id: str, sequence: int) -> None:
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "release_id": release_id, "sequence": sequence, "at": now_iso()})
def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]:
evidence = body.get("evidence")
if not isinstance(evidence, dict) or set(evidence) != {"review_url"}:
raise Invalid("reviewed release requires exactly evidence.review_url")
return {"review_url": _safe_url(evidence["review_url"])}
def create_release(request: Request) -> Response:
"""Create the sole reviewed ledger for one scoped workload commit."""
body = _body(request, {"workload", "commit", "feature_flags", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
if request.if_match() != 0:
raise Invalid("If-Match: 0 is required when creating a release")
workload, commit = body.get("workload"), body.get("commit")
if workload not in WORKLOADS or not isinstance(commit, str) or not COMMIT.fullmatch(commit):
raise Invalid("workload and exact 40-character commit are required")
flags = body.get("feature_flags", [])
if not isinstance(flags, list) or len(flags) > 32:
raise Invalid("feature_flags must be a bounded list")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
releases = [name for name in request.store.ledgers(FAMILY) if name.startswith("rel_")]
if len(releases) >= MAX_RELEASES:
raise TooLarge("release ledger limit reached")
for name in releases:
entry = _rows(request, name)[0]
release = entry["snapshot"]
if entry["project_id"] == project_id and entry["conversation_id"] == conversation_id and release["workload"] == workload and release["commit"] == commit:
raise Conflict("a release already exists for this scoped workload commit")
release = {
"schema": "hux.release.v1", "id": new_id("rel"), "workload": workload, "feature_flags": flags,
"commit": commit, "state": "reviewed", "evidence": _initial_evidence(body), "transitions": [],
}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, [])
result = _view(entry)
_remember(request, key, fingerprint, release["id"], 1)
request.audit("releases.create", release["id"])
return Response(201, result, {"ETag": "1", "Cache-Control": "no-store"})
def _transition_evidence(current: dict[str, Any], target: str, supplied: Any) -> dict[str, Any]:
if not isinstance(supplied, dict) or set(supplied) - EVIDENCE_FIELDS:
raise Invalid("evidence must be a bounded object of known fields")
expected = {
"merged": {"merge_commit"},
"built": {"ci_build_url", "image_ref", "image_digest", "harbor_digest"},
"verified": set(),
"deployed": {"flux_revision"},
"converged": {"pod_digest"},
"live_verified": {"health_check"},
"rolled_back": {"rollback_target"},
}[target]
if set(supplied) != expected:
raise Invalid(f"{target} requires exactly: {', '.join(sorted(expected)) or 'no new evidence'}")
evidence = {**current.get("evidence", {}), **supplied}
if target == "merged" and not COMMIT.fullmatch(str(evidence.get("merge_commit", ""))):
raise Invalid("merged requires an exact merge commit")
if target == "built":
match = IMAGE.fullmatch(str(evidence.get("image_ref", "")))
digest = evidence.get("image_digest")
if not match or not SHA.fullmatch(str(digest)) or evidence.get("harbor_digest") != digest or match.group(1) != digest:
raise Invalid("image ref, image digest and Harbor digest must match exactly")
evidence["ci_build_url"] = _safe_url(evidence.get("ci_build_url"))
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
raise Invalid("verified image does not match Harbor")
if target == "deployed" and not re.fullmatch(r"main@sha1:[0-9a-f]{40}", str(evidence.get("flux_revision", ""))):
raise Invalid("deployed requires an exact Flux revision")
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
raise Invalid("pod digest does not equal the built image digest")
if target == "live_verified":
health = evidence.get("health_check")
if not isinstance(health, dict) or set(health) != {"url", "status", "at"} or health.get("status") != "pass":
raise Invalid("live verification requires one passing health check")
health["url"] = _safe_url(health["url"], relative=True)
if target == "rolled_back" and not SHA.fullmatch(str(evidence.get("rollback_target", ""))):
raise Invalid("rollback requires an exact target digest")
return evidence
def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str]]:
mapping = {
"merged": ("build", "merge_commit"), "built": ("build", "image_digest"),
"verified": ("build", "harbor_digest"), "deployed": ("flux", "flux_revision"),
"converged": ("pod", "pod_digest"), "live_verified": ("receipt", "health_check"),
"rolled_back": ("pod", "rollback_target"),
}
kind, key = mapping[target]
value = evidence[key]
identifier = value["at"] if key == "health_check" else value
return [{"kind": kind, "id": str(identifier)[:200]}]
def transition_release(request: Request) -> Response:
"""Append exactly one legal, evidence-backed release transition."""
body = _body(request, {"to", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
rows = _rows(request, request.params["release_id"])
head, current = rows[-1], rows[-1]["snapshot"]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
if expected != len(rows):
raise Conflict("release revision does not match If-Match")
target = body.get("to")
if target == "rolled_back":
if current["state"] not in {"built", "verified", "deployed", "converged", "live_verified"}:
raise Conflict("there is no deployed image to roll back")
elif current["state"] == "rolled_back" or target not in ORDER or ORDER.index(target) != ORDER.index(current["state"]) + 1:
raise Conflict("release transition skips, reverses, or follows a terminal state")
evidence = _transition_evidence(current, target, body.get("evidence"))
stamp = now_iso()
transition = {"from": current["state"], "to": target, "at": stamp, "by": _actor(request), "evidence": _evidence_refs(target, evidence)}
release = {**current, "state": target, "evidence": evidence, "transitions": [*current["transitions"], transition]}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, rows)
result = _view(entry)
_remember(request, key, fingerprint, release["id"], entry["sequence"])
request.audit("releases.transition", release["id"], reason=target)
return Response(200, result, {"ETag": str(entry["sequence"]), "Cache-Control": "no-store"})
def get_release(request: Request) -> Response:
"""Read one verified release view within its project/conversation scope."""
project_id, conversation_id = _scope(request)
rows = _rows(request, request.params["release_id"])
head = rows[-1]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
request.audit("releases.read", request.params["release_id"])
return Response(200, _view(head), {"ETag": str(len(rows)), "Cache-Control": "no-store"})
def list_releases(request: Request) -> Response:
"""List verified heads for one scope, newest ledger first."""
project_id, conversation_id = _scope(request)
items = []
for name in request.store.ledgers(FAMILY):
if not name.startswith("rel_"):
continue
head = _rows(request, name)[-1]
if head["project_id"] == project_id and head["conversation_id"] == conversation_id:
items.append(_view(head))
items.sort(key=lambda row: row["release"]["transitions"][-1]["at"] if row["release"]["transitions"] else "", reverse=True)
request.audit("releases.list", conversation_id)
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-12 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/releases"
router.add("POST", base, CARD, "releases.create", create_release)
router.add("GET", base, CARD, "releases.list", list_releases)
router.add("GET", base + "/{release_id}", CARD, "releases.read", get_release)
router.add("POST", base + "/{release_id}/transitions", CARD, "releases.transition", transition_release)