release(hermes): render one or two WebUI consumers with HUX binding

The Flux release renderer now accepts an expected-consumer set per
workload (chat may carry the HUX sidecar as a second consumer of the
exact same WebUI image) and binds HUX_IMAGE_TAG/HUX_IMAGE_DIGEST env
metadata to the released tag and digest when those fields are present.
Rendering fails when the binding fields are incomplete, keeping the
image identity single-sourced. Tests adapt to both topologies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 04:12:03 -03:00
parent bd63b568e1
commit 9e99470fef
3 changed files with 102 additions and 13 deletions

View File

@ -49,8 +49,9 @@ def render_workload(
kind: str,
name: str,
image: str = DEFAULT_IMAGE,
expected_images: int | tuple[int, ...] = 1,
) -> str:
"""Replace one WebUI image in one exact Flux workload without reformatting."""
"""Replace every expected WebUI consumer in one exact Flux workload."""
digest = validated(digest, DIGEST_PATTERN, "image digest")
identity = re.compile(
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
@ -75,18 +76,55 @@ def render_workload(
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
matches.append(index)
suffixes[index] = f" #{comment}" if separator else ""
if len(matches) != 1:
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:
raise ValueError(
f"expected exactly one {image!r} image in {kind}/{name}; "
f"expected {allowed} {image!r} image(s) in {kind}/{name}; "
f"found {len(matches)}"
)
index = matches[0]
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}"
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}"
return "".join(lines)
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
present = {
name: rendered.count(f"- {{name: {name}, value:") for name in replacements
}
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(
rf"^(?P<indent>\s*)- \{{name: {name}, value: '[^'\n]+'\}}(?P<suffix>[^\n]*)$",
re.MULTILINE,
)
rendered, count = pattern.subn(
lambda match: (
f"{match.group('indent')}- {{name: {name}, value: '{value}'}}"
f"{match.group('suffix')}"
),
rendered,
)
if count != 1:
raise ValueError(f"expected exactly one {name} HUX build binding; found {count}")
return rendered
def _targets(chat_manifest: Path, dashboard_manifest: Path):
return (
(
@ -94,12 +132,14 @@ def _targets(chat_manifest: Path, dashboard_manifest: Path):
"StatefulSet",
"hermes-chat-tenant",
"hermes-chat-statefulset.yaml",
(1, 2),
),
(
dashboard_manifest,
"Deployment",
"hermes",
"hermes-dashboard-deployment.yaml",
1,
),
)
@ -122,13 +162,27 @@ def _metadata(
def _rendered_and_patch(
digest: str, chat_manifest: Path, dashboard_manifest: Path
digest: str,
source_revision: str,
build_number: str,
chat_manifest: Path,
dashboard_manifest: Path,
) -> tuple[dict[str, str], str]:
rendered_targets: dict[str, str] = {}
patch_parts: list[str] = []
for path, kind, name, artifact_name in _targets(chat_manifest, dashboard_manifest):
for path, kind, name, artifact_name, expected_images in _targets(chat_manifest, dashboard_manifest):
source = path.read_text(encoding="utf-8")
rendered = render_workload(source, digest, kind=kind, name=name)
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
)
rendered_targets[artifact_name] = rendered
patch_parts.append(
"".join(
@ -159,7 +213,7 @@ def write_release_artifacts(
destination, source_revision, build_number
)
rendered_targets, patch = _rendered_and_patch(
digest, chat_manifest, dashboard_manifest
digest, source_revision, build_number, chat_manifest, dashboard_manifest
)
if not patch:
raise ValueError("published digest already matches every Flux target")
@ -201,7 +255,7 @@ def validate_release_artifacts(
):
raise ValueError("release output must contain exactly four evidence files")
rendered_targets, patch = _rendered_and_patch(
digest, chat_manifest, dashboard_manifest
digest, source_revision, build_number, chat_manifest, dashboard_manifest
)
metadata = _metadata(digest, source_revision, build_number, destination)
expected = {

View File

@ -18,6 +18,7 @@ from hermes_webui_flux_release import (
DESTINATION_PATTERN,
DIGEST_PATTERN,
REVISION_PATTERN,
render_hux_build_metadata as render_hux_build_metadata,
render_workload as render_workload,
validate_destination,
validate_release_artifacts as validate_flux_release_artifacts,

View File

@ -190,7 +190,16 @@ def test_renderer_updates_exact_chat_and_dashboard_webui_only(tmp_path: Path) ->
module, digest, kwargs = _release_fixture(tmp_path)
output = kwargs["output_dir"]
patch = (output / "hermes-webui-image-update.patch").read_text(encoding="utf-8")
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{digest}") == 2
chat_consumers = CHAT.read_text(encoding="utf-8").count(
f"image: {module.DEFAULT_IMAGE}:"
)
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{digest}") == chat_consumers + 1
if "HUX_IMAGE_TAG" in CHAT.read_text(encoding="utf-8"):
assert (
"+ - {name: HUX_IMAGE_TAG, value: "
f"'git-{kwargs['source_revision']}-build-{kwargs['build_number']}-release'}}"
) in patch
assert f"+ - {{name: HUX_IMAGE_DIGEST, value: '{digest}'}}" in patch
assert "services/hermes/chat-statefulset.yaml" in patch
assert "services/hermes/deployment.yaml" in patch
assert "hermes-agent@sha256" not in "\n".join(
@ -236,6 +245,31 @@ def test_renderer_accepts_flux_tagged_digest_reference() -> None:
assert '"$imagepolicy": "hermes:hermes-webui-release"' in rendered
def test_renderer_supports_pre_activation_single_consumer_without_hux_metadata() -> None:
"""The image can land safely before the HUX sidecar is enabled by Flux."""
module = _load(RELEASE, "hermes_webui_release_staged_activation")
old_digest = "sha256:" + "1" * 64
new_digest = "sha256:" + "2" * 64
revision = "3" * 40
source = (
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n"
" name: hermes-chat-tenant\nspec:\n template:\n spec:\n"
" containers:\n - name: webui\n"
f" image: {module.DEFAULT_IMAGE}@{old_digest}\n"
)
rendered = module.render_workload(
source,
new_digest,
kind="StatefulSet",
name="hermes-chat-tenant",
expected_images=(1, 2),
)
assert f"image: {module.DEFAULT_IMAGE}@{new_digest}" in rendered
assert module.render_hux_build_metadata(rendered, new_digest, revision, "23") == rendered
@pytest.mark.parametrize(
("source", "kind", "name", "match"),
[