338 lines
16 KiB
Python
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)
|