2026-08-23 01:08:35 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Render and revalidate the two-workload Hermes WebUI Flux handoff."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import difflib
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-webui"
|
|
|
|
|
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
|
|
|
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
|
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
|
|
|
|
|
DESTINATION_PATTERN = re.compile(
|
|
|
|
|
r"^registry\.bstein\.dev/bstein/hermes-webui:"
|
|
|
|
|
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validated(value: str, pattern: re.Pattern[str], label: str) -> str:
|
|
|
|
|
"""Return a normalized value when it matches the release contract."""
|
|
|
|
|
normalized = value.strip()
|
|
|
|
|
if not pattern.fullmatch(normalized):
|
|
|
|
|
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_destination(
|
|
|
|
|
destination: str, source_revision: str, build_number: str
|
|
|
|
|
) -> tuple[str, str]:
|
|
|
|
|
"""Bind one unique build tag to the reviewed revision and Jenkins build."""
|
|
|
|
|
revision = validated(source_revision, REVISION_PATTERN, "source revision")
|
|
|
|
|
build = validated(build_number, BUILD_PATTERN, "build number")
|
|
|
|
|
match = DESTINATION_PATTERN.fullmatch(destination.strip())
|
|
|
|
|
if not match or match.groups() != (revision, build):
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"destination must bind the reviewed revision and unique Jenkins build"
|
|
|
|
|
)
|
|
|
|
|
return revision, build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_workload(
|
|
|
|
|
source: str,
|
|
|
|
|
digest: str,
|
|
|
|
|
*,
|
|
|
|
|
kind: str,
|
|
|
|
|
name: str,
|
|
|
|
|
image: str = DEFAULT_IMAGE,
|
2026-08-24 04:12:03 -03:00
|
|
|
expected_images: int | tuple[int, ...] = 1,
|
2026-08-23 01:08:35 +00:00
|
|
|
) -> str:
|
2026-08-24 04:12:03 -03:00
|
|
|
"""Replace every expected WebUI consumer in one exact Flux workload."""
|
2026-08-23 01:08:35 +00:00
|
|
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
|
|
|
|
identity = re.compile(
|
|
|
|
|
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
|
|
|
|
|
rf"metadata:\n name: {re.escape(name)}\n"
|
|
|
|
|
)
|
|
|
|
|
if not identity.search(source):
|
|
|
|
|
raise ValueError(f"Flux target identity changed: expected {kind}/{name}")
|
|
|
|
|
lines = source.splitlines(keepends=True)
|
|
|
|
|
matches: list[int] = []
|
2026-08-23 13:41:58 -03:00
|
|
|
suffixes: dict[int, str] = {}
|
2026-08-23 01:08:35 +00:00
|
|
|
for index, line in enumerate(lines):
|
|
|
|
|
stripped = line.strip()
|
2026-08-23 14:27:55 -03:00
|
|
|
if not stripped.startswith("image: "):
|
|
|
|
|
continue
|
|
|
|
|
value, separator, comment = stripped.removeprefix("image: ").partition(" #")
|
|
|
|
|
current_image, at, current_digest = value.rpartition("@")
|
|
|
|
|
if not at or not re.fullmatch(
|
|
|
|
|
rf"{re.escape(image)}(?::[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})?",
|
|
|
|
|
current_image,
|
|
|
|
|
):
|
2026-08-23 01:08:35 +00:00
|
|
|
continue
|
|
|
|
|
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
|
|
|
|
matches.append(index)
|
2026-08-23 13:41:58 -03:00
|
|
|
suffixes[index] = f" #{comment}" if separator else ""
|
2026-08-24 04:12:03 -03:00
|
|
|
allowed = (expected_images,) if isinstance(expected_images, int) else expected_images
|
|
|
|
|
if not allowed or any(count < 1 for count in allowed) or len(matches) not in allowed:
|
2026-08-23 01:08:35 +00:00
|
|
|
raise ValueError(
|
2026-08-24 04:12:03 -03:00
|
|
|
f"expected {allowed} {image!r} image(s) in {kind}/{name}; "
|
2026-08-23 01:08:35 +00:00
|
|
|
f"found {len(matches)}"
|
|
|
|
|
)
|
2026-08-24 04:12:03 -03:00
|
|
|
for index in matches:
|
|
|
|
|
newline = "\n" if lines[index].endswith("\n") else ""
|
|
|
|
|
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
|
|
|
|
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
|
2026-08-23 01:08:35 +00:00
|
|
|
return "".join(lines)
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 04:12:03 -03:00
|
|
|
def render_hux_build_metadata(
|
|
|
|
|
source: str, digest: str, source_revision: str, build_number: str
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Bind HUX metadata when the activated sidecar fields are present."""
|
|
|
|
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
|
|
|
|
revision = validated(source_revision, REVISION_PATTERN, "source revision")
|
|
|
|
|
build = validated(build_number, BUILD_PATTERN, "build number")
|
|
|
|
|
replacements = {
|
|
|
|
|
"HUX_IMAGE_TAG": f"git-{revision}-build-{build}-release",
|
|
|
|
|
"HUX_IMAGE_DIGEST": digest,
|
|
|
|
|
}
|
|
|
|
|
rendered = source
|
2026-08-24 11:21:35 -03:00
|
|
|
# Block style only: Flux setters cannot attach to values inside flow
|
|
|
|
|
# mappings, so the manifest keeps these as two-line entries with the
|
|
|
|
|
# marker comment on the value scalar. The renderer stays a belt on top
|
|
|
|
|
# of the Flux :tag/:digest setters and binds the same values.
|
2026-08-24 04:12:03 -03:00
|
|
|
present = {
|
2026-08-24 11:21:35 -03:00
|
|
|
name: rendered.count(f"- name: {name}\n") for name in replacements
|
2026-08-24 04:12:03 -03:00
|
|
|
}
|
|
|
|
|
if set(present.values()) == {0}:
|
|
|
|
|
return rendered
|
|
|
|
|
if any(count != 1 for count in present.values()):
|
|
|
|
|
raise ValueError(f"incomplete HUX build binding fields: {present}")
|
|
|
|
|
for name, value in replacements.items():
|
|
|
|
|
pattern = re.compile(
|
2026-08-24 11:21:35 -03:00
|
|
|
rf"^(?P<head>(?P<indent>\s*)- name: {name}\n(?P=indent) value: )"
|
|
|
|
|
rf"[^#\n]+(?P<suffix> #[^\n]*)?$",
|
2026-08-24 04:12:03 -03:00
|
|
|
re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
rendered, count = pattern.subn(
|
|
|
|
|
lambda match: (
|
2026-08-24 11:21:35 -03:00
|
|
|
f"{match.group('head')}{value}{match.group('suffix') or ''}"
|
2026-08-24 04:12:03 -03:00
|
|
|
),
|
|
|
|
|
rendered,
|
|
|
|
|
)
|
|
|
|
|
if count != 1:
|
|
|
|
|
raise ValueError(f"expected exactly one {name} HUX build binding; found {count}")
|
|
|
|
|
return rendered
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 01:08:35 +00:00
|
|
|
def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
|
|
|
|
return (
|
|
|
|
|
(
|
|
|
|
|
chat_manifest,
|
|
|
|
|
"StatefulSet",
|
|
|
|
|
"hermes-chat-tenant",
|
|
|
|
|
"hermes-chat-statefulset.yaml",
|
hermes(chat): stage the HUX-12 evidence producer sidecar
Activation-layer staging, fail-closed until enablement: a per-tenant
hux-evidence-producer sidecar on the exact reviewed WebUI image runs
hux_producer.run_once on a 60s loop, inert until the Vault-staged
evidence key (tolerant init, tmpfs, 0400, staged only for the hux
service and producer containers - never hermes or webui), the policy
ConfigMap, and the scope ConfigMap exist. Adds least-privilege
read-only RBAC (pods+statefulset in hermes, the single named Flux
Kustomization), tenant egress to the Kubernetes API ClusterIP and the
traefik edge, the policy allowlist, hux_producer packaging in the WebUI
image, and a third expected WebUI consumer in the Flux release
renderer. Delivery and image-automation gates enforce the boundary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:39:45 -03:00
|
|
|
(1, 2, 3),
|
2026-08-23 01:08:35 +00:00
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
dashboard_manifest,
|
|
|
|
|
"Deployment",
|
|
|
|
|
"hermes",
|
|
|
|
|
"hermes-dashboard-deployment.yaml",
|
2026-08-24 04:12:03 -03:00
|
|
|
1,
|
2026-08-23 01:08:35 +00:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _metadata(
|
|
|
|
|
digest: str, source_revision: str, build_number: str, destination: str
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"build_number": build_number,
|
|
|
|
|
"digest": digest,
|
|
|
|
|
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
|
|
|
|
"flux_targets": [
|
|
|
|
|
"apps/StatefulSet/hermes/hermes-chat-tenant",
|
|
|
|
|
"apps/Deployment/hermes/hermes",
|
|
|
|
|
],
|
|
|
|
|
"image": DEFAULT_IMAGE,
|
|
|
|
|
"published_tag": destination,
|
|
|
|
|
"source_revision": source_revision,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rendered_and_patch(
|
2026-08-24 04:12:03 -03:00
|
|
|
digest: str,
|
|
|
|
|
source_revision: str,
|
|
|
|
|
build_number: str,
|
|
|
|
|
chat_manifest: Path,
|
|
|
|
|
dashboard_manifest: Path,
|
2026-08-23 01:08:35 +00:00
|
|
|
) -> tuple[dict[str, str], str]:
|
|
|
|
|
rendered_targets: dict[str, str] = {}
|
|
|
|
|
patch_parts: list[str] = []
|
2026-08-24 04:12:03 -03:00
|
|
|
for path, kind, name, artifact_name, expected_images in _targets(chat_manifest, dashboard_manifest):
|
2026-08-23 01:08:35 +00:00
|
|
|
source = path.read_text(encoding="utf-8")
|
2026-08-24 04:12:03 -03:00
|
|
|
rendered = render_workload(
|
|
|
|
|
source,
|
|
|
|
|
digest,
|
|
|
|
|
kind=kind,
|
|
|
|
|
name=name,
|
|
|
|
|
expected_images=expected_images,
|
|
|
|
|
)
|
|
|
|
|
if name == "hermes-chat-tenant":
|
|
|
|
|
rendered = render_hux_build_metadata(
|
|
|
|
|
rendered, digest, source_revision, build_number
|
|
|
|
|
)
|
2026-08-23 01:08:35 +00:00
|
|
|
rendered_targets[artifact_name] = rendered
|
|
|
|
|
patch_parts.append(
|
|
|
|
|
"".join(
|
|
|
|
|
difflib.unified_diff(
|
|
|
|
|
source.splitlines(keepends=True),
|
|
|
|
|
rendered.splitlines(keepends=True),
|
|
|
|
|
fromfile=f"a/services/hermes/{path.name}",
|
|
|
|
|
tofile=f"b/services/hermes/{path.name}",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return rendered_targets, "".join(patch_parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_release_artifacts(
|
|
|
|
|
*,
|
|
|
|
|
digest: str,
|
|
|
|
|
source_revision: str,
|
|
|
|
|
build_number: str,
|
|
|
|
|
destination: str,
|
|
|
|
|
chat_manifest: Path,
|
|
|
|
|
dashboard_manifest: Path,
|
|
|
|
|
output_dir: Path,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Write two rendered Flux targets, one patch, and credential-free evidence."""
|
|
|
|
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
|
|
|
|
source_revision, build_number = validate_destination(
|
|
|
|
|
destination, source_revision, build_number
|
|
|
|
|
)
|
|
|
|
|
rendered_targets, patch = _rendered_and_patch(
|
2026-08-24 04:12:03 -03:00
|
|
|
digest, source_revision, build_number, chat_manifest, dashboard_manifest
|
2026-08-23 01:08:35 +00:00
|
|
|
)
|
|
|
|
|
if not patch:
|
|
|
|
|
raise ValueError("published digest already matches every Flux target")
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
for artifact_name, rendered in rendered_targets.items():
|
|
|
|
|
(output_dir / artifact_name).write_text(rendered, encoding="utf-8")
|
|
|
|
|
(output_dir / "hermes-webui-image-update.patch").write_text(patch, encoding="utf-8")
|
|
|
|
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
|
|
|
|
(output_dir / "hermes-webui-image.json").write_text(
|
|
|
|
|
json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_release_artifacts(
|
|
|
|
|
*,
|
|
|
|
|
digest: str,
|
|
|
|
|
source_revision: str,
|
|
|
|
|
build_number: str,
|
|
|
|
|
destination: str,
|
|
|
|
|
chat_manifest: Path,
|
|
|
|
|
dashboard_manifest: Path,
|
|
|
|
|
output_dir: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Revalidate the exact successful-build evidence without rewriting it."""
|
|
|
|
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
|
|
|
|
source_revision, build_number = validate_destination(
|
|
|
|
|
destination, source_revision, build_number
|
|
|
|
|
)
|
|
|
|
|
expected_names = {
|
|
|
|
|
"hermes-webui-image.json",
|
|
|
|
|
"hermes-webui-image-update.patch",
|
|
|
|
|
"hermes-chat-statefulset.yaml",
|
|
|
|
|
"hermes-dashboard-deployment.yaml",
|
|
|
|
|
}
|
|
|
|
|
entries = list(output_dir.iterdir())
|
|
|
|
|
if {entry.name for entry in entries} != expected_names or not all(
|
|
|
|
|
entry.is_file() and not entry.is_symlink() for entry in entries
|
|
|
|
|
):
|
|
|
|
|
raise ValueError("release output must contain exactly four evidence files")
|
|
|
|
|
rendered_targets, patch = _rendered_and_patch(
|
2026-08-24 04:12:03 -03:00
|
|
|
digest, source_revision, build_number, chat_manifest, dashboard_manifest
|
2026-08-23 01:08:35 +00:00
|
|
|
)
|
|
|
|
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
|
|
|
|
expected = {
|
|
|
|
|
"hermes-webui-image.json": json.dumps(metadata, indent=2, sort_keys=True)
|
|
|
|
|
+ "\n",
|
|
|
|
|
"hermes-webui-image-update.patch": patch,
|
|
|
|
|
**rendered_targets,
|
|
|
|
|
}
|
|
|
|
|
for name, expected_text in expected.items():
|
|
|
|
|
if (output_dir / name).read_text(encoding="utf-8") != expected_text:
|
|
|
|
|
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|