338 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, release_security
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 == "evidence":
return {"type": "system", "id": "release-evidence-producer"}
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."""
release_security.require_review_creator(request.identity.trust)
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."""
release_security.require_evidence_producer(request.identity.trust)
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)