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:
parent
bd63b568e1
commit
9e99470fef
@ -49,8 +49,9 @@ def render_workload(
|
|||||||
kind: str,
|
kind: str,
|
||||||
name: str,
|
name: str,
|
||||||
image: str = DEFAULT_IMAGE,
|
image: str = DEFAULT_IMAGE,
|
||||||
|
expected_images: int | tuple[int, ...] = 1,
|
||||||
) -> str:
|
) -> 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")
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
identity = re.compile(
|
identity = re.compile(
|
||||||
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
|
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")
|
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
||||||
matches.append(index)
|
matches.append(index)
|
||||||
suffixes[index] = f" #{comment}" if separator else ""
|
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(
|
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)}"
|
f"found {len(matches)}"
|
||||||
)
|
)
|
||||||
index = matches[0]
|
for index in matches:
|
||||||
newline = "\n" if lines[index].endswith("\n") else ""
|
newline = "\n" if lines[index].endswith("\n") else ""
|
||||||
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||||
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
|
lines[index] = f"{prefix}image: {image}@{digest}{suffixes[index]}{newline}"
|
||||||
return "".join(lines)
|
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):
|
def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
@ -94,12 +132,14 @@ def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
|||||||
"StatefulSet",
|
"StatefulSet",
|
||||||
"hermes-chat-tenant",
|
"hermes-chat-tenant",
|
||||||
"hermes-chat-statefulset.yaml",
|
"hermes-chat-statefulset.yaml",
|
||||||
|
(1, 2),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
dashboard_manifest,
|
dashboard_manifest,
|
||||||
"Deployment",
|
"Deployment",
|
||||||
"hermes",
|
"hermes",
|
||||||
"hermes-dashboard-deployment.yaml",
|
"hermes-dashboard-deployment.yaml",
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -122,13 +162,27 @@ def _metadata(
|
|||||||
|
|
||||||
|
|
||||||
def _rendered_and_patch(
|
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]:
|
) -> tuple[dict[str, str], str]:
|
||||||
rendered_targets: dict[str, str] = {}
|
rendered_targets: dict[str, str] = {}
|
||||||
patch_parts: list[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")
|
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
|
rendered_targets[artifact_name] = rendered
|
||||||
patch_parts.append(
|
patch_parts.append(
|
||||||
"".join(
|
"".join(
|
||||||
@ -159,7 +213,7 @@ def write_release_artifacts(
|
|||||||
destination, source_revision, build_number
|
destination, source_revision, build_number
|
||||||
)
|
)
|
||||||
rendered_targets, patch = _rendered_and_patch(
|
rendered_targets, patch = _rendered_and_patch(
|
||||||
digest, chat_manifest, dashboard_manifest
|
digest, source_revision, build_number, chat_manifest, dashboard_manifest
|
||||||
)
|
)
|
||||||
if not patch:
|
if not patch:
|
||||||
raise ValueError("published digest already matches every Flux target")
|
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")
|
raise ValueError("release output must contain exactly four evidence files")
|
||||||
rendered_targets, patch = _rendered_and_patch(
|
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)
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||||
expected = {
|
expected = {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from hermes_webui_flux_release import (
|
|||||||
DESTINATION_PATTERN,
|
DESTINATION_PATTERN,
|
||||||
DIGEST_PATTERN,
|
DIGEST_PATTERN,
|
||||||
REVISION_PATTERN,
|
REVISION_PATTERN,
|
||||||
|
render_hux_build_metadata as render_hux_build_metadata,
|
||||||
render_workload as render_workload,
|
render_workload as render_workload,
|
||||||
validate_destination,
|
validate_destination,
|
||||||
validate_release_artifacts as validate_flux_release_artifacts,
|
validate_release_artifacts as validate_flux_release_artifacts,
|
||||||
|
|||||||
@ -190,7 +190,16 @@ def test_renderer_updates_exact_chat_and_dashboard_webui_only(tmp_path: Path) ->
|
|||||||
module, digest, kwargs = _release_fixture(tmp_path)
|
module, digest, kwargs = _release_fixture(tmp_path)
|
||||||
output = kwargs["output_dir"]
|
output = kwargs["output_dir"]
|
||||||
patch = (output / "hermes-webui-image-update.patch").read_text(encoding="utf-8")
|
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/chat-statefulset.yaml" in patch
|
||||||
assert "services/hermes/deployment.yaml" in patch
|
assert "services/hermes/deployment.yaml" in patch
|
||||||
assert "hermes-agent@sha256" not in "\n".join(
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
("source", "kind", "name", "match"),
|
("source", "kind", "name", "match"),
|
||||||
[
|
[
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user