From 9e99470fef97b1d0d43a04ca497fe3e87d965060 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 04:12:03 -0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- ci/scripts/hermes_webui_flux_release.py | 78 ++++++++++++++++++---- ci/scripts/hermes_webui_release.py | 1 + testing/tests/test_hermes_webui_release.py | 36 +++++++++- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/ci/scripts/hermes_webui_flux_release.py b/ci/scripts/hermes_webui_flux_release.py index b027bb7b..a966cc8f 100644 --- a/ci/scripts/hermes_webui_flux_release.py +++ b/ci/scripts/hermes_webui_flux_release.py @@ -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\s*)- \{{name: {name}, value: '[^'\n]+'\}}(?P[^\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 = { diff --git a/ci/scripts/hermes_webui_release.py b/ci/scripts/hermes_webui_release.py index be1ac8d3..6f8ad274 100755 --- a/ci/scripts/hermes_webui_release.py +++ b/ci/scripts/hermes_webui_release.py @@ -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, diff --git a/testing/tests/test_hermes_webui_release.py b/testing/tests/test_hermes_webui_release.py index d2646e4c..a8902d63 100644 --- a/testing/tests/test_hermes_webui_release.py +++ b/testing/tests/test_hermes_webui_release.py @@ -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"), [