diff --git a/ci/Jenkinsfile.hermes-webui-image b/ci/Jenkinsfile.hermes-webui-image new file mode 100644 index 00000000..859f9809 --- /dev/null +++ b/ci/Jenkinsfile.hermes-webui-image @@ -0,0 +1,314 @@ +pipeline { + agent { + kubernetes { + defaultContainer 'python' + yaml """ +apiVersion: v1 +kind: Pod +metadata: + labels: + atlas.bstein.dev/workload: hermes-webui-image-builder +spec: + serviceAccountName: hermes-image-builder + automountServiceAccountToken: false + enableServiceLinks: false + restartPolicy: Never + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: NotIn + values: + - titan-04 + - titan-14 + - titan-18 + - titan-19 + - titan-22 + - titan-24 + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: hardware + operator: In + values: + - rpi5 + imagePullSecrets: + - name: harbor-bstein-robot + containers: + - name: jnlp + image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 25m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + - name: python + image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808 + command: ["sleep"] + args: ["99d"] + tty: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + - name: kaniko + image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e + command: ["/busybox/sh", "-c"] + args: ["/busybox/sleep 99d"] + tty: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"] + privileged: false + runAsUser: 0 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: "2" + memory: 4Gi +""" + } + } + parameters { + booleanParam( + name: 'PUBLISH_IMAGE', + defaultValue: false, + description: 'Publish the reviewed main revision to Harbor.' + ) + string( + name: 'EXPECTED_SOURCE_REVISION', + defaultValue: '', + description: 'Exact 40-character commit on atlas/titan-iac main.' + ) + string( + name: 'CONFIRM_PUBLISH', + defaultValue: '', + description: 'Enter PUBLISH HERMES WEBUI to confirm the release.' + ) + } + environment { + HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-webui' + } + options { + disableConcurrentBuilds() + buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100')) + skipDefaultCheckout(true) + timeout(time: 150, unit: 'MINUTES') + } + stages { + stage('Checkout reviewed source') { + steps { + checkout scm + } + } + stage('Enforce release boundary') { + steps { + container('jnlp') { + sh ''' + set -eu + mkdir -p build + test "${PUBLISH_IMAGE}" = "true" + test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI" + case "${EXPECTED_SOURCE_REVISION}" in + *[!0-9a-f]*|'') + echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2 + exit 2 + ;; + esac + test "${#EXPECTED_SOURCE_REVISION}" -eq 40 + actual_revision="$(git rev-parse HEAD)" + test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}" + test "${actual_revision}" = "$(git rev-parse origin/main)" + test -z "$(git status --porcelain)" + test -f dockerfiles/Dockerfile.hermes-webui + case "${BUILD_NUMBER}" in + ''|0*|*[!0-9]*) + echo "BUILD_NUMBER must be a positive decimal integer" >&2 + exit 2 + ;; + esac + printf '%s\n' \ + "${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \ + > build/hermes-webui.destination + ''' + } + } + } + stage('Validate reviewed WebUI source') { + steps { + container('python') { + sh ''' + set -eu + python3 -m pip install --disable-pip-version-check --no-cache-dir \ + --target=/tmp/hermes-webui-release-test-deps \ + pytest==8.3.4 PyYAML==6.0.2 + PYTHONPATH=/tmp/hermes-webui-release-test-deps \ + python3 -m pytest -q \ + testing/tests/test_hermes_webui_brand.py \ + testing/tests/test_hermes_webui_release.py + ''' + } + } + } + stage('Reject replay before publish') { + steps { + withCredentials([usernamePassword( + credentialsId: 'harbor-robot', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASSWORD' + )]) { + sh ''' + set -eu + set +x + destination="$(cat build/hermes-webui.destination)" + python3 ci/scripts/hermes_webui_release.py assert-absent \ + --source-revision "${EXPECTED_SOURCE_REVISION}" \ + --build-number "${BUILD_NUMBER}" \ + --destination "${destination}" + ''' + } + } + } + stage('Build and publish without a daemon') { + steps { + container('kaniko') { + withCredentials([usernamePassword( + credentialsId: 'harbor-robot', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASSWORD' + )]) { + sh '''#!/busybox/sh + set -eu + set +x + config_path=/kaniko/.docker/config.json + destination="$(cat build/hermes-webui.destination)" + umask 077 + auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')" + /busybox/mkdir -p /kaniko/.docker + /busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}" + unset HARBOR_USER HARBOR_PASSWORD auth + trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM + umask 022 + /kaniko/executor \ + --registry-mirror=harbor-core.harbor.svc.cluster.local \ + --insecure-registry=harbor-core.harbor.svc.cluster.local \ + --context="dir://${WORKSPACE}" \ + --dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \ + --destination="${destination}" \ + --digest-file="${WORKSPACE}/build/hermes-webui.digest" \ + --image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui.image" \ + --label="org.opencontainers.image.revision=${EXPECTED_SOURCE_REVISION}" \ + --label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \ + --label="org.opencontainers.image.title=hermes-webui" \ + --cleanup \ + --push-retry=3 + /busybox/chmod 644 build/hermes-webui.digest build/hermes-webui.image + ''' + } + } + } + } + stage('Render reviewed Flux handoff') { + steps { + withCredentials([usernamePassword( + credentialsId: 'harbor-robot', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASSWORD' + )]) { + sh ''' + set -eu + set +x + destination="$(cat build/hermes-webui.destination)" + python3 ci/scripts/hermes_webui_release.py render \ + --digest-file build/hermes-webui.digest \ + --image-file build/hermes-webui.image \ + --source-revision "${EXPECTED_SOURCE_REVISION}" \ + --build-number "${BUILD_NUMBER}" \ + --destination "${destination}" \ + --chat-manifest services/hermes/chat-statefulset.yaml \ + --dashboard-manifest services/hermes/deployment.yaml \ + --output-dir build/hermes-webui-release + test -s build/hermes-webui-release/hermes-webui-image-update.patch + test -s build/hermes-webui-release/hermes-webui-image.json + ''' + } + } + } + } + post { + success { + sh ''' + set -eu + expected_files="$(printf '%s\n' \ + build/hermes-webui.destination \ + build/hermes-webui.digest \ + build/hermes-webui.image \ + build/hermes-webui-release/hermes-chat-statefulset.yaml \ + build/hermes-webui-release/hermes-dashboard-deployment.yaml \ + build/hermes-webui-release/hermes-webui-image.json \ + build/hermes-webui-release/hermes-webui-image-update.patch \ + | LC_ALL=C sort)" + actual_files="$(find build -type f -print | LC_ALL=C sort)" + test "${actual_files}" = "${expected_files}" + destination="$(cat build/hermes-webui.destination)" + python3 ci/scripts/hermes_webui_release.py verify-evidence \ + --digest-file build/hermes-webui.digest \ + --image-file build/hermes-webui.image \ + --source-revision "${EXPECTED_SOURCE_REVISION}" \ + --build-number "${BUILD_NUMBER}" \ + --destination "${destination}" \ + --chat-manifest services/hermes/chat-statefulset.yaml \ + --dashboard-manifest services/hermes/deployment.yaml \ + --output-dir build/hermes-webui-release + ''' + archiveArtifacts( + artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch', + allowEmptyArchive: false, + fingerprint: true + ) + } + cleanup { + container('kaniko') { + sh '''#!/busybox/sh + /busybox/rm -f /kaniko/.docker/config.json + ''' + } + } + } +} diff --git a/ci/scripts/hermes_webui_flux_release.py b/ci/scripts/hermes_webui_flux_release.py new file mode 100644 index 00000000..5483b333 --- /dev/null +++ b/ci/scripts/hermes_webui_flux_release.py @@ -0,0 +1,207 @@ +#!/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, +) -> str: + """Replace one WebUI image in one exact Flux workload without reformatting.""" + 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] = [] + for index, line in enumerate(lines): + stripped = line.strip() + if not stripped.startswith(f"image: {image}@"): + continue + current_digest = stripped.removeprefix(f"image: {image}@") + validated(current_digest, DIGEST_PATTERN, "current Flux image digest") + matches.append(index) + if len(matches) != 1: + raise ValueError( + f"expected exactly one {image!r} image 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}{newline}" + return "".join(lines) + + +def _targets(chat_manifest: Path, dashboard_manifest: Path): + return ( + ( + chat_manifest, + "StatefulSet", + "hermes-chat-tenant", + "hermes-chat-statefulset.yaml", + ), + ( + dashboard_manifest, + "Deployment", + "hermes", + "hermes-dashboard-deployment.yaml", + ), + ) + + +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( + digest: 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): + source = path.read_text(encoding="utf-8") + rendered = render_workload(source, digest, kind=kind, name=name) + 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( + digest, chat_manifest, dashboard_manifest + ) + 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( + digest, chat_manifest, dashboard_manifest + ) + 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}") diff --git a/ci/scripts/hermes_webui_release.py b/ci/scripts/hermes_webui_release.py new file mode 100755 index 00000000..1a744508 --- /dev/null +++ b/ci/scripts/hermes_webui_release.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Verify and render a reviewable Hermes WebUI image release.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Callable + +from hermes_webui_flux_release import ( + DEFAULT_IMAGE as DEFAULT_IMAGE, + DESTINATION_PATTERN, + DIGEST_PATTERN, + render_workload as render_workload, + validate_destination, + validate_release_artifacts as validate_flux_release_artifacts, + validated as _validated, + write_release_artifacts, +) + +HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0" +HARBOR_PROJECT = "bstein" +HARBOR_REPOSITORY = "hermes-webui" +IMMUTABLE_REPOSITORY_PATTERN = "hermes-webui" +IMMUTABLE_TAG_PATTERN = "git-*-build-*" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Never send registry credentials to a redirect target.""" + + def redirect_request(self, _request, _file, _code, _message, _headers, _url): + return None + + +def validate_kaniko_evidence( + *, digest_text: str, image_text: str, destination: str +) -> str: + """Cross-check both independent Kaniko output files against the destination.""" + digest_lines = digest_text.splitlines() + image_lines = image_text.splitlines() + if len(digest_lines) != 1: + raise ValueError("Kaniko digest evidence must contain exactly one line") + if len(image_lines) != 1: + raise ValueError("Kaniko image evidence must contain exactly one line") + digest = _validated(digest_lines[0], DIGEST_PATTERN, "image digest") + if image_lines[0].strip() != f"{destination}@{digest}": + raise ValueError("Kaniko image evidence does not match destination and digest") + return digest + + +def _registry_request(request: urllib.request.Request, timeout: int) -> Any: + """Make a registry request without following redirects.""" + opener = urllib.request.build_opener(_NoRedirect()) + try: + return opener.open(request, timeout=timeout) + except urllib.error.HTTPError as exc: + return exc + + +def _artifact_response( + destination: str, + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> tuple[int, bytes]: + """Read one exact Harbor artifact by tag with bounded response size.""" + match = DESTINATION_PATTERN.fullmatch(destination) + if not match: + raise ValueError("invalid destination") + if not username or not password: + raise RuntimeError("Harbor credentials are empty") + tag = destination.rsplit(":", 1)[1] + encoded_tag = urllib.parse.quote(tag, safe="") + auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + request = urllib.request.Request( + f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/" + f"{HARBOR_REPOSITORY}/artifacts/{encoded_tag}" + "?with_immutable_status=true", + headers={"Accept": "application/json", "Authorization": f"Basic {auth}"}, + method="GET", + ) + with opener(request, 20) as response: + body = response.read(1_048_577) + if len(body) > 1_048_576: + raise RuntimeError("Harbor artifact response exceeded the size limit") + return int(response.status), body + + +def _immutable_rules_response( + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> tuple[int, bytes, dict[str, str]]: + """Read the project policy with the same least-privilege publish identity.""" + if not username or not password: + raise RuntimeError("Harbor credentials are empty") + auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + request = urllib.request.Request( + f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules" + "?page=1&page_size=100", + headers={"Accept": "application/json", "Authorization": f"Basic {auth}"}, + method="GET", + ) + with opener(request, 20) as response: + body = response.read(1_048_577) + if len(body) > 1_048_576: + raise RuntimeError("Harbor immutable rule response exceeded the size limit") + return int(response.status), body, dict(response.headers) + + +def _require_complete_rule_page( + rules: list[dict[str, Any]], headers: dict[str, str] +) -> None: + """Require proof that the bounded first page contains every rule.""" + raw_total = next( + (value for key, value in headers.items() if key.lower() == "x-total-count"), + None, + ) + if raw_total is None or not str(raw_total).isdecimal(): + raise RuntimeError("Harbor immutable rule list omitted a valid total count") + if int(raw_total) != len(rules): + raise RuntimeError("Harbor immutable rule list was truncated") + + +def _normalized_immutable_rule(rule: dict[str, Any]) -> dict[str, Any]: + """Select only fields that bind the server-side build-tag policy.""" + return { + "disabled": bool(rule.get("disabled", False)), + "action": rule.get("action"), + "template": rule.get("template"), + "tag_selectors": [ + { + "kind": item.get("kind"), + "decoration": item.get("decoration"), + "pattern": item.get("pattern"), + } + for item in rule.get("tag_selectors") or [] + if isinstance(item, dict) + ], + "scope_selectors": { + "repository": [ + { + "kind": item.get("kind"), + "decoration": item.get("decoration"), + "pattern": item.get("pattern"), + } + for item in (rule.get("scope_selectors") or {}).get("repository", []) + if isinstance(item, dict) + ] + }, + } + + +def verify_immutable_policy( + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> None: + """Fail closed before build unless the exact Harbor rule is active.""" + status, body, headers = _immutable_rules_response( + username=username, password=password, opener=opener + ) + if status != 200: + raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}") + try: + rules = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise RuntimeError("Harbor immutable rule list has an invalid shape") + _require_complete_rule_page(rules, headers) + expected = { + "disabled": False, + "action": "immutable", + "template": "immutable_template", + "tag_selectors": [ + { + "kind": "doublestar", + "decoration": "matches", + "pattern": IMMUTABLE_TAG_PATTERN, + } + ], + "scope_selectors": { + "repository": [ + { + "kind": "doublestar", + "decoration": "repoMatches", + "pattern": IMMUTABLE_REPOSITORY_PATTERN, + } + ] + }, + } + matches = [ + _normalized_immutable_rule(item) + for item in rules + if _normalized_immutable_rule(item)["tag_selectors"] + == expected["tag_selectors"] + and _normalized_immutable_rule(item)["scope_selectors"] + == expected["scope_selectors"] + ] + if matches != [expected]: + raise RuntimeError("Harbor immutable build-tag policy is absent or not exact") + + +def assert_tag_absent( + destination: str, + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> None: + """Reject replay before Kaniko can push an already-used immutable identity.""" + status, _body = _artifact_response( + destination, username=username, password=password, opener=opener + ) + if status == 404: + return + if status == 200: + raise RuntimeError("Harbor destination tag already exists; refusing overwrite") + raise RuntimeError(f"Harbor destination preflight returned HTTP {status}") + + +def verify_registry_digest( + destination: str, + digest: str, + *, + username: str, + password: str, + opener: Callable[[urllib.request.Request, int], Any] = _registry_request, +) -> None: + """Verify Harbor independently resolves the pushed tag to Kaniko's digest.""" + digest = _validated(digest, DIGEST_PATTERN, "image digest") + status, body = _artifact_response( + destination, username=username, password=password, opener=opener + ) + if status != 200: + raise RuntimeError(f"Harbor manifest verification returned HTTP {status}") + try: + artifact = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Harbor returned invalid artifact JSON") from exc + harbor_digest = str(artifact.get("digest") or "").strip() + if not DIGEST_PATTERN.fullmatch(harbor_digest): + raise RuntimeError("Harbor response omitted a valid artifact digest") + if harbor_digest != digest: + raise RuntimeError("Harbor digest does not match Kaniko evidence") + expected_tag = destination.rsplit(":", 1)[1] + matching_tags = [ + item + for item in artifact.get("tags") or [] + if isinstance(item, dict) and item.get("name") == expected_tag + ] + if len(matching_tags) != 1: + raise RuntimeError("Harbor artifact does not contain the expected tag") + if matching_tags[0].get("immutable") is not True: + raise RuntimeError("Harbor did not enforce the expected tag as immutable") + + +def validate_release_artifacts( + *, + digest_file: Path, + image_file: Path, + 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 = validate_kaniko_evidence( + digest_text=digest_file.read_text(encoding="utf-8"), + image_text=image_file.read_text(encoding="utf-8"), + destination=destination, + ) + validate_flux_release_artifacts( + digest=digest, + source_revision=source_revision, + build_number=build_number, + destination=destination, + chat_manifest=chat_manifest, + dashboard_manifest=dashboard_manifest, + output_dir=output_dir, + ) + + +def _credentials() -> tuple[str, str]: + """Read the masked, runtime-only Jenkins credential environment.""" + username = os.environ.get("HARBOR_USER", "") + password = os.environ.get("HARBOR_PASSWORD", "") + if not username or not password: + raise RuntimeError("Harbor credentials are unavailable") + return username, password + + +def _common_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--source-revision", required=True) + parser.add_argument("--build-number", required=True) + parser.add_argument("--destination", required=True) + + +def main() -> int: + """Fail closed around the unique tag, then verify and render after push.""" + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + absent = commands.add_parser("assert-absent") + _common_arguments(absent) + render = commands.add_parser("render") + _common_arguments(render) + render.add_argument("--digest-file", required=True, type=Path) + render.add_argument("--image-file", required=True, type=Path) + render.add_argument("--chat-manifest", required=True, type=Path) + render.add_argument("--dashboard-manifest", required=True, type=Path) + render.add_argument("--output-dir", required=True, type=Path) + verify = commands.add_parser("verify-evidence") + _common_arguments(verify) + verify.add_argument("--digest-file", required=True, type=Path) + verify.add_argument("--image-file", required=True, type=Path) + verify.add_argument("--chat-manifest", required=True, type=Path) + verify.add_argument("--dashboard-manifest", required=True, type=Path) + verify.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args() + + validate_destination(args.destination, args.source_revision, args.build_number) + if args.command == "verify-evidence": + validate_release_artifacts( + digest_file=args.digest_file, + image_file=args.image_file, + source_revision=args.source_revision, + build_number=args.build_number, + destination=args.destination, + chat_manifest=args.chat_manifest, + dashboard_manifest=args.dashboard_manifest, + output_dir=args.output_dir, + ) + return 0 + + username, password = _credentials() + if args.command == "assert-absent": + verify_immutable_policy(username=username, password=password) + assert_tag_absent(args.destination, username=username, password=password) + return 0 + + digest = validate_kaniko_evidence( + digest_text=args.digest_file.read_text(encoding="utf-8"), + image_text=args.image_file.read_text(encoding="utf-8"), + destination=args.destination, + ) + verify_registry_digest( + args.destination, digest, username=username, password=password + ) + write_release_artifacts( + digest=digest, + source_revision=args.source_revision, + build_number=args.build_number, + destination=args.destination, + chat_manifest=args.chat_manifest, + dashboard_manifest=args.dashboard_manifest, + output_dir=args.output_dir, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/clusters/atlas/flux-system/applications/harbor/kustomization.yaml b/clusters/atlas/flux-system/applications/harbor/kustomization.yaml index 225214fe..a1782f58 100644 --- a/clusters/atlas/flux-system/applications/harbor/kustomization.yaml +++ b/clusters/atlas/flux-system/applications/harbor/kustomization.yaml @@ -22,6 +22,10 @@ spec: kind: Job name: harbor-hermes-agent-immutability-ensure-1 namespace: harbor + - apiVersion: batch/v1 + kind: Job + name: harbor-hermes-webui-immutability-ensure-1 + namespace: harbor dependsOn: - name: core - name: longhorn diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index d6b83e98..2676844c 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -10,85 +10,9 @@ USER root # while the gateway remains the only process that owns an agent conversation. COPY --from=webui /apptoo /opt/hermes-webui -# The account policy caps user-selected reasoning at xhigh even when a provider -# advertises a newer, more expensive level. -RUN /opt/hermes/.venv/bin/python - <<'PY' -from pathlib import Path - -config = Path("/opt/hermes-webui/api/config.py") -source = config.read_text(encoding="utf-8") -before = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")' -after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' -if before not in source: - raise SystemExit("Hermes WebUI reasoning-effort patch context changed") -config.write_text(source.replace(before, after, 1), encoding="utf-8") - -index = Path("/opt/hermes-webui/static/index.html") -source = index.read_text(encoding="utf-8") -before = '
Max
\n' -if before not in source: - raise SystemExit("Hermes WebUI xhigh UI patch context changed") -index.write_text(source.replace(before, "", 1), encoding="utf-8") - -# oauth2-proxy returns 401 for browser API and health probes when the secure -# session expires. Re-enter OIDC with the complete return path instead of -# presenting an endless, inaccurate "connection lost" loop. -ui = Path("/opt/hermes-webui/static/ui.js") -source = ui.read_text(encoding="utf-8") -before = ''' const res=await fetcher(_offlineHealthUrl(),opts); - return !!(res&&res.ok); -''' -after = ''' const res=await fetcher(_offlineHealthUrl(),opts); - if(res&&(res.status===401||res.status===403)){ - const rd=window.location.pathname+window.location.search+window.location.hash; - window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd)); - return false; - } - return !!(res&&res.ok); -''' -if source.count(before) != 1: - raise SystemExit("Hermes WebUI auth-recovery patch context changed") -ui.write_text(source.replace(before, after, 1), encoding="utf-8") - -# Make delegated session hierarchy obvious and collapsible in the sidebar. -sessions = Path("/opt/hermes-webui/static/sessions.js") -source = sessions.read_text(encoding="utf-8") -before = ''' const childLabel=t('session_meta_children', childCount); - childCountEl.textContent=childLabel; - childCountEl.title=_sessionChildBadgeTooltip(childLabel); -''' -after = ''' const childLabel=t('session_meta_children', childCount); - const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw; - childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel; - childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false'); - childCountEl.title=_sessionChildBadgeTooltip(childLabel); -''' -if source.count(before) != 1: - raise SystemExit("Hermes WebUI child-session toggle patch context changed") -sessions.write_text(source.replace(before, after, 1), encoding="utf-8") - -# A profile's model is only its default; a session-level selector can override -# it. Label the scope so the dropdown does not contradict the effective model. -panels = Path("/opt/hermes-webui/static/panels.js") -source = panels.read_text(encoding="utf-8") -before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n" -after = ''' if (typeof p.model === 'string' && p.model) { - const routeLabels = { - 'atlas/auto/fast': 'Automatic · Fast', - 'atlas/auto/balanced': 'Automatic · Balanced', - 'atlas/auto/deep': 'Automatic · Deep', - 'atlas/auto/maximum': 'Automatic · Maximum', - }; - meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop())); - } -''' -if source.count(before) != 2: - raise SystemExit("Hermes WebUI profile-model label patch context changed") -panels.write_text(source.replace(before, after, 2), encoding="utf-8") -PY - # Add the Atlas voice bridge as a narrow integration layer. It activates only # when a tenant's server-side STT capability reports the private Jetson route. +COPY dockerfiles/hermes-webui-base-patch.py /tmp/hermes-webui-base-patch.py COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py @@ -96,15 +20,24 @@ COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voic COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js +COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py +COPY dockerfiles/hermes-webui-brand.css /opt/hermes-webui/static/hermes-brand.css +COPY dockerfiles/hermes-webui-manifest.json /opt/hermes-webui/static/manifest.json +COPY dockerfiles/hermes-webui-assets/hermes-agent.ico /opt/hermes-webui/static/hermes-agent.ico +COPY dockerfiles/hermes-webui-assets/hermes-agent-192.png /opt/hermes-webui/static/hermes-agent-192.png +COPY dockerfiles/hermes-webui-assets/hermes-agent-512.png /opt/hermes-webui/static/hermes-agent-512.png +RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py +RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \ /opt/hermes-webui/api/config.py \ && ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \ + && grep -Fq 'res.status===401||res.status===403' /opt/hermes-webui/static/ui.js \ && grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \ && grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \ && grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \ @@ -124,6 +57,24 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \ && grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \ && grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \ + && grep -Fq 'Hermes Chat' /opt/hermes-webui/static/index.html \ + && grep -Fq 'id="hermesBrandStyles"' /opt/hermes-webui/static/index.html \ + && grep -Fq 'static/hermes-agent-512.png' /opt/hermes-webui/static/index.html \ + && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/hermes-brand.css \ + && grep -Fq '"name": "Hermes Chat"' /opt/hermes-webui/static/manifest.json \ + && grep -Fq "'./static/hermes-agent-512.png'" /opt/hermes-webui/static/sw.js \ + && printf '%s %s\n' \ + 'aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9' \ + '/opt/hermes-webui/static/hermes-agent.ico' \ + | sha256sum -c - \ + && printf '%s %s\n' \ + '0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f' \ + '/opt/hermes-webui/static/hermes-agent-192.png' \ + | sha256sum -c - \ + && printf '%s %s\n' \ + '6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d' \ + '/opt/hermes-webui/static/hermes-agent-512.png' \ + | sha256sum -c - \ && /opt/hermes/.venv/bin/python -m py_compile \ /opt/hermes-webui/api/routes.py \ /opt/hermes-webui/api/upload.py \ diff --git a/dockerfiles/hermes-webui-assets/SOURCE.md b/dockerfiles/hermes-webui-assets/SOURCE.md new file mode 100644 index 00000000..3d68694f --- /dev/null +++ b/dockerfiles/hermes-webui-assets/SOURCE.md @@ -0,0 +1,18 @@ +# Hermes WebUI persona icon provenance + +`hermes-agent.ico` is a byte-for-byte tracked copy of the canonical Hermes +Agent dashboard icon from `/opt/hermes/web/public/favicon.ico`. The same bytes +were independently present at `/opt/hermes/hermes_cli/web_dist/favicon.ico` on +the Atlas coordinator when this asset was imported on 2026-08-23. + +- Canonical ICO SHA-256: `aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9` +- ICO payloads: PNG-encoded RGBA variants at 16x16, 32x32, and 48x48 +- `hermes-agent-192.png`: 48px canonical variant resized to 192x192 with + Pillow 12.2.0 LANCZOS resampling; SHA-256 + `0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f` +- `hermes-agent-512.png`: 48px canonical variant resized to 512x512 with + Pillow 12.2.0 LANCZOS resampling; SHA-256 + `6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d` + +The larger files are faithful format/size derivatives for PWA installation; +they do not redraw or replace the supplied persona. diff --git a/dockerfiles/hermes-webui-assets/hermes-agent-192.png b/dockerfiles/hermes-webui-assets/hermes-agent-192.png new file mode 100644 index 00000000..2af94217 Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent-192.png differ diff --git a/dockerfiles/hermes-webui-assets/hermes-agent-512.png b/dockerfiles/hermes-webui-assets/hermes-agent-512.png new file mode 100644 index 00000000..dc68afba Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent-512.png differ diff --git a/dockerfiles/hermes-webui-assets/hermes-agent.ico b/dockerfiles/hermes-webui-assets/hermes-agent.ico new file mode 100644 index 00000000..7a949324 Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent.ico differ diff --git a/dockerfiles/hermes-webui-base-patch.py b/dockerfiles/hermes-webui-base-patch.py new file mode 100644 index 00000000..1629d25a --- /dev/null +++ b/dockerfiles/hermes-webui-base-patch.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Apply Atlas baseline UI policy patches to the pinned Hermes WebUI.""" + +from pathlib import Path + + +config = Path("/opt/hermes-webui/api/config.py") +source = config.read_text(encoding="utf-8") +before = ( + 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")' +) +after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' +if source.count(before) != 1: + raise SystemExit("Hermes WebUI reasoning-effort patch context changed") +config.write_text(source.replace(before, after, 1), encoding="utf-8") + +index = Path("/opt/hermes-webui/static/index.html") +source = index.read_text(encoding="utf-8") +before = '
Max
\n' +if source.count(before) != 1: + raise SystemExit("Hermes WebUI xhigh UI patch context changed") +index.write_text(source.replace(before, "", 1), encoding="utf-8") + +# oauth2-proxy returns 401 for browser API and health probes when the secure +# session expires. Re-enter OIDC with the complete return path. +ui = Path("/opt/hermes-webui/static/ui.js") +source = ui.read_text(encoding="utf-8") +before = """ const res=await fetcher(_offlineHealthUrl(),opts); + return !!(res&&res.ok); +""" +after = """ const res=await fetcher(_offlineHealthUrl(),opts); + if(res&&(res.status===401||res.status===403)){ + const rd=window.location.pathname+window.location.search+window.location.hash; + window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd)); + return false; + } + return !!(res&&res.ok); +""" +if source.count(before) != 1: + raise SystemExit("Hermes WebUI auth-recovery patch context changed") +ui.write_text(source.replace(before, after, 1), encoding="utf-8") + +# Make delegated session hierarchy obvious and collapsible in the sidebar. +sessions = Path("/opt/hermes-webui/static/sessions.js") +source = sessions.read_text(encoding="utf-8") +before = """ const childLabel=t('session_meta_children', childCount); + childCountEl.textContent=childLabel; + childCountEl.title=_sessionChildBadgeTooltip(childLabel); +""" +after = """ const childLabel=t('session_meta_children', childCount); + const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw; + childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel; + childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false'); + childCountEl.title=_sessionChildBadgeTooltip(childLabel); +""" +if source.count(before) != 1: + raise SystemExit("Hermes WebUI child-session toggle patch context changed") +sessions.write_text(source.replace(before, after, 1), encoding="utf-8") + +# A profile's model is only its default; label that scope in both render paths. +panels = Path("/opt/hermes-webui/static/panels.js") +source = panels.read_text(encoding="utf-8") +before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n" +after = """ if (typeof p.model === 'string' && p.model) { + const routeLabels = { + 'atlas/auto/fast': 'Automatic · Fast', + 'atlas/auto/balanced': 'Automatic · Balanced', + 'atlas/auto/deep': 'Automatic · Deep', + 'atlas/auto/maximum': 'Automatic · Maximum', + }; + meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop())); + } +""" +if source.count(before) != 2: + raise SystemExit("Hermes WebUI profile-model label patch context changed") +panels.write_text(source.replace(before, after, 2), encoding="utf-8") diff --git a/dockerfiles/hermes-webui-brand-patch.py b/dockerfiles/hermes-webui-brand-patch.py new file mode 100644 index 00000000..2c3e8889 --- /dev/null +++ b/dockerfiles/hermes-webui-brand-patch.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Apply fail-closed Hermes identity and PWA patches to pinned WebUI source.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")) + + +def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None: + """Replace one exact upstream fragment and reject pin drift.""" + source = path.read_text(encoding="utf-8") + if source.count(before) != count: + raise SystemExit( + f"Hermes brand patch context changed in {path}: {before[:80]!r}" + ) + path.write_text(source.replace(before, after, count), encoding="utf-8") + + +def replace_between_exact(path: Path, start: str, end: str, after: str) -> None: + """Replace one uniquely bounded upstream region and reject ambiguous input.""" + source = path.read_text(encoding="utf-8") + if source.count(start) != 1 or source.count(end) != 1: + raise SystemExit( + f"Hermes brand patch context changed in {path}: {start[:80]!r}" + ) + start_index = source.index(start) + end_index = source.index(end, start_index) + path.write_text(source[:start_index] + after + source[end_index:], encoding="utf-8") + + +index = ROOT / "static/index.html" +replace_exact(index, "Hermes", "Hermes Chat") +replace_exact( + index, + """ + +""", + """ + +""", +) +replace_exact( + index, + '', + '', +) +replace_exact( + index, + '', + '', +) +replace_exact( + index, + '', + '', +) +replace_exact( + index, + '', + '', +) +replace_exact( + index, + '', + '', +) +replace_exact( + index, + "var c=t==='dark'?'#141425':'#FAF7F0';", + "var c=t==='dark'?'#0D1420':'#E8F1F2';", +) +replace_exact( + index, + '', + '\n' + '', +) +replace_between_exact( + index, + '