From f94f96a7042ba863768938e0c1afa79406397b77 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 23 Aug 2026 16:29:17 +0000 Subject: [PATCH] jenkins: give hermes-webui-image its own build-trigger token hermes-webui-image had no authenticationToken, so there was no supported non-interactive route to trigger it -- only an interactively-authenticated Jenkins UI build worked. Give it an independently-scoped, create-only Vault token (kv/atlas/hermes/developer-jenkins-webui, seeded by a dedicated hermes-jenkins-webui-token-seed identity) mirroring the proven hermes-agent-image lane exactly, never sharing state or capability with the agent token. Adds jenkins_webui_image_build_trigger.py as the coordinator's supported trigger route for future approved releases. --- .../kustomization.yaml | 4 + services/hermes/NOTES.md | 17 ++ services/hermes/agent-deployment.yaml | 5 + .../jenkins_webui_image_build_trigger.py | 112 ++++++++++ .../hermes/scripts/stage_runtime_access.py | 1 + services/jenkins/configmap-jcasc.yaml | 1 + services/jenkins/deployment.yaml | 3 + .../job-webui.yaml | 69 ++++++ .../kustomization.yaml | 5 + ...hermes_jenkins_webui_build_token_ensure.sh | 167 +++++++++++++++ .../serviceaccount-webui.yaml | 7 + .../vault/scripts/vault_k8s_auth_configure.sh | 22 +- .../tests/test_hermes_image_builder_vault.py | 200 +++++++++++++++++- testing/tests/test_hermes_webui_release.py | 42 +++- 14 files changed, 642 insertions(+), 13 deletions(-) create mode 100644 services/hermes/scripts/jenkins_webui_image_build_trigger.py create mode 100644 services/vault-hermes-jenkins-token-seed/job-webui.yaml create mode 100644 services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_webui_build_token_ensure.sh create mode 100644 services/vault-hermes-jenkins-token-seed/serviceaccount-webui.yaml diff --git a/clusters/atlas/flux-system/applications/vault-hermes-jenkins-token-seed/kustomization.yaml b/clusters/atlas/flux-system/applications/vault-hermes-jenkins-token-seed/kustomization.yaml index 561179c8..2dd5fc21 100644 --- a/clusters/atlas/flux-system/applications/vault-hermes-jenkins-token-seed/kustomization.yaml +++ b/clusters/atlas/flux-system/applications/vault-hermes-jenkins-token-seed/kustomization.yaml @@ -24,3 +24,7 @@ spec: kind: Job name: vault-hermes-jenkins-build-token-seed-2 namespace: vault + - apiVersion: batch/v1 + kind: Job + name: vault-hermes-jenkins-webui-build-token-seed + namespace: vault diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md index 6bc5b605..b692d813 100644 --- a/services/hermes/NOTES.md +++ b/services/hermes/NOTES.md @@ -240,6 +240,23 @@ Rotate this fixed-job token only as a coordinated operator action: with the previous value as another coordinated rotation; never delete the secret to make the seeder recreate it. +The Hermes WebUI publish lane (`hermes-webui-image`) has its own independent +build token, seeded and wired identically but never sharing state with the +agent lane above: + +```sh +jenkins_webui_image_build_trigger.py '' +``` + +Its token lives at `kv/atlas/hermes/developer-jenkins-webui`, seeded by the +dedicated `hermes-jenkins-webui-token-seed` Vault role/identity (create-only, +same CAS semantics, same fail-closed behavior as the agent seeder) and read +only by the `jenkins` and `hermes-agent` Vault roles — the same two-consumer +shape as the agent token, but a disjoint path, so neither token can ever +stand in for the other. Rotate it with the same five-step runbook above, +substituting `kv/atlas/hermes/developer-jenkins-webui` and the WebUI job/token +file names throughout. + ## The actual supervised triage algorithm 1. Classify the request as test/build triage, service health, or alert tuning. diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 868ed00f..1de57ff4 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -57,6 +57,11 @@ spec: {{- with secret "kv/data/atlas/hermes/developer-jenkins" -}} {{ .Data.data.build_token }} {{- end }} + vault.hashicorp.com/agent-inject-secret-jenkins-webui-image-build-token: kv/data/atlas/hermes/developer-jenkins-webui + vault.hashicorp.com/agent-inject-template-jenkins-webui-image-build-token: | + {{- with secret "kv/data/atlas/hermes/developer-jenkins-webui" -}} + {{ .Data.data.build_token }} + {{- end }} vault.hashicorp.com/agent-inject-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh vault.hashicorp.com/agent-inject-template-node-ssh-private-key: | {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} diff --git a/services/hermes/scripts/jenkins_webui_image_build_trigger.py b/services/hermes/scripts/jenkins_webui_image_build_trigger.py new file mode 100644 index 00000000..894e5744 --- /dev/null +++ b/services/hermes/scripts/jenkins_webui_image_build_trigger.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Trigger only the reviewed-main Hermes WebUI image release job.""" + +from __future__ import annotations + +import argparse +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +JENKINS_ORIGIN = "https://ci.bstein.dev" +JENKINS_BUILD_URL = f"{JENKINS_ORIGIN}/buildByToken/buildWithParameters" +JOB_NAME = "hermes-webui-image" +TOKEN_FILE = Path("/runtime-access/jenkins-webui-image-build-token") +REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$") +QUEUE_PATH_PATTERN = re.compile(r"^/queue/item/[0-9]+/?$") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Keep a queued-build redirect from becoming an unauthorized job read.""" + + def redirect_request(self, _request, _file, _code, _message, _headers, _url): + return None + + +def _open_without_redirect(request: urllib.request.Request, timeout: int): + """Return the Build Token Root response, including its expected HTTP 303.""" + opener = urllib.request.build_opener(_NoRedirect()) + try: + return opener.open(request, timeout=timeout) + except urllib.error.HTTPError as exc: + if exc.code == 303: + return exc + raise + + +def trigger_build( + revision: str, + *, + token_file: Path = TOKEN_FILE, + opener=_open_without_redirect, +) -> dict[str, str | int]: + """Post the fixed job parameters using its job-scoped build token.""" + revision = revision.strip() + if not REVISION_PATTERN.fullmatch(revision): + raise ValueError("revision must be a lowercase full 40-character commit") + token = token_file.read_text(encoding="utf-8").strip() + if not token: + raise RuntimeError("Jenkins WebUI image-build token is empty") + payload = urllib.parse.urlencode( + { + "job": JOB_NAME, + "token": token, + "PUBLISH_IMAGE": "true", + "EXPECTED_SOURCE_REVISION": revision, + "CONFIRM_PUBLISH": "PUBLISH HERMES WEBUI", + } + ).encode("utf-8") + request = urllib.request.Request( + JENKINS_BUILD_URL, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with opener(request, timeout=20) as response: + status = int(response.status) + location = response.headers.get("Location", "") + if status not in {201, 303}: + raise RuntimeError(f"Jenkins trigger returned HTTP {status}") + if not location: + raise RuntimeError("Jenkins trigger omitted the queue Location") + queue_url = urllib.parse.urljoin(f"{JENKINS_ORIGIN}/", location) + parsed_queue = urllib.parse.urlsplit(queue_url) + expected_origin = urllib.parse.urlsplit(JENKINS_ORIGIN) + if ( + parsed_queue.scheme != expected_origin.scheme + or parsed_queue.netloc != expected_origin.netloc + or parsed_queue.query + or parsed_queue.fragment + or not QUEUE_PATH_PATTERN.fullmatch(parsed_queue.path) + ): + raise RuntimeError("Jenkins returned an invalid queue Location") + # Never return the submitted URL: its form body contains the job token. + queue_path = parsed_queue.path + return { + "job": JOB_NAME, + "queue_path": queue_path, + "source_revision": revision, + "status": status, + } + + +def main() -> int: + """Validate one revision, trigger the bounded job, and print safe metadata.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("revision", help="reviewed full commit currently on main") + args = parser.parse_args() + try: + result = trigger_build(args.revision) + except (OSError, ValueError, RuntimeError, urllib.error.URLError) as exc: + print(json.dumps({"error": str(exc)}, sort_keys=True)) + return 1 + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/services/hermes/scripts/stage_runtime_access.py b/services/hermes/scripts/stage_runtime_access.py index 3d5ad1b0..adf5c08a 100644 --- a/services/hermes/scripts/stage_runtime_access.py +++ b/services/hermes/scripts/stage_runtime_access.py @@ -227,6 +227,7 @@ def stage_agent() -> None: "execution-pool-key", "chat-relay-key", "jenkins-image-build-token", + "jenkins-webui-image-build-token", "node-ssh-private-key", "node-ssh-known-hosts", ): diff --git a/services/jenkins/configmap-jcasc.yaml b/services/jenkins/configmap-jcasc.yaml index fcb87234..6ae7a5f7 100644 --- a/services/jenkins/configmap-jcasc.yaml +++ b/services/jenkins/configmap-jcasc.yaml @@ -674,6 +674,7 @@ data: pipelineJob('hermes-webui-image') { disabled(false) description('Human-gated, daemonless Kaniko build for the reviewed atlas/titan-iac main revision. Publishes an immutable Hermes WebUI image and archives a narrow two-workload Flux digest patch; it never mutates Git or deploys.') + authenticationToken(System.getenv('HERMES_WEBUI_IMAGE_BUILD_TOKEN')) definition { cpsScm { scm { diff --git a/services/jenkins/deployment.yaml b/services/jenkins/deployment.yaml index 258725c0..a3304592 100644 --- a/services/jenkins/deployment.yaml +++ b/services/jenkins/deployment.yaml @@ -68,6 +68,9 @@ spec: {{ with secret "kv/data/atlas/hermes/developer-jenkins" }} HERMES_AGENT_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }} {{ end }} + {{ with secret "kv/data/atlas/hermes/developer-jenkins-webui" }} + HERMES_WEBUI_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }} + {{ end }} bstein.dev/restarted-at: "2026-05-20T09:40:31Z" spec: serviceAccountName: jenkins diff --git a/services/vault-hermes-jenkins-token-seed/job-webui.yaml b/services/vault-hermes-jenkins-token-seed/job-webui.yaml new file mode 100644 index 00000000..28265156 --- /dev/null +++ b/services/vault-hermes-jenkins-token-seed/job-webui.yaml @@ -0,0 +1,69 @@ +--- +# services/vault-hermes-jenkins-token-seed/job-webui.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: vault-hermes-jenkins-webui-build-token-seed + namespace: vault +spec: + backoffLimit: 2 + template: + spec: + serviceAccountName: hermes-jenkins-webui-token-seed + enableServiceLinks: false + restartPolicy: Never + nodeSelector: + hardware: rpi5 + 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-24] + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: seed + image: docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569 + imagePullPolicy: IfNotPresent + command: [sh, /scripts/vault_hermes_jenkins_webui_build_token_ensure.sh] + env: + - name: HOME + value: /tmp + - name: VAULT_ADDR + value: http://vault.vault.svc.cluster.local:8200 + - name: VAULT_K8S_ROLE + value: hermes-jenkins-webui-token-seed + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 100 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: scripts + mountPath: /scripts + readOnly: true + - name: tmp + mountPath: /tmp + resources: + requests: {cpu: 25m, memory: 32Mi} + limits: {cpu: 250m, memory: 128Mi} + volumes: + - name: scripts + configMap: + name: vault-hermes-jenkins-webui-token-seed-script + defaultMode: 0555 + - name: tmp + emptyDir: {} diff --git a/services/vault-hermes-jenkins-token-seed/kustomization.yaml b/services/vault-hermes-jenkins-token-seed/kustomization.yaml index 920caa50..af35944c 100644 --- a/services/vault-hermes-jenkins-token-seed/kustomization.yaml +++ b/services/vault-hermes-jenkins-token-seed/kustomization.yaml @@ -5,9 +5,14 @@ namespace: vault resources: - serviceaccount.yaml - job.yaml + - serviceaccount-webui.yaml + - job-webui.yaml generatorOptions: disableNameSuffixHash: true configMapGenerator: - name: vault-hermes-jenkins-token-seed-script files: - vault_hermes_jenkins_build_token_ensure.sh=scripts/vault_hermes_jenkins_build_token_ensure.sh + - name: vault-hermes-jenkins-webui-token-seed-script + files: + - vault_hermes_jenkins_webui_build_token_ensure.sh=scripts/vault_hermes_jenkins_webui_build_token_ensure.sh diff --git a/services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_webui_build_token_ensure.sh b/services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_webui_build_token_ensure.sh new file mode 100644 index 00000000..4cf795b1 --- /dev/null +++ b/services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_webui_build_token_ensure.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env sh +set -eu + +secret_path="kv/atlas/hermes/developer-jenkins-webui" +secret_api_path="kv/data/atlas/hermes/developer-jenkins-webui" +jwt_file="${VAULT_K8S_JWT_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token}" +vault_role="${VAULT_K8S_ROLE:-hermes-jenkins-webui-token-seed}" +payload_file="${TMPDIR:-/tmp}/hermes-jenkins-webui-token.json" + +log() { printf '[hermes-jenkins-webui-token] %s\n' "$*" >&2; } +cleanup() { rm -f "${payload_file}"; } +trap cleanup EXIT HUP INT TERM + +retry_command() { + attempt=1 + while [ "${attempt}" -le 5 ]; do + set +e + command_output="$("$@" 2>&1)" + command_status=$? + set -e + if [ "${command_status}" -eq 0 ]; then + printf '%s' "${command_output}" + return 0 + fi + if [ "${attempt}" -lt 5 ]; then + sleep "${attempt}" + fi + attempt=$((attempt + 1)) + done + return "${command_status}" +} + +ensure_token() { + if [ -n "${VAULT_TOKEN:-}" ]; then + return + fi + jwt="$(cat "${jwt_file}")" + VAULT_TOKEN="$(retry_command vault write -field=token \ + auth/kubernetes/login role="${vault_role}" jwt="${jwt}")" || { + log "Vault Kubernetes login failed after retries" + exit 1 + } + unset jwt + if [ -z "${VAULT_TOKEN}" ]; then + log "Vault Kubernetes login returned an empty token" + exit 1 + fi + export VAULT_TOKEN +} + +# Return 0 for the complete field, 10 when absent, 11 when present but +# incomplete, and 12 for a persistent authorization/transport failure. +read_build_token() { + attempt=1 + while [ "${attempt}" -le 5 ]; do + set +e + secret_json="$(vault read -format=json "${secret_api_path}" 2>&1)" + secret_status=$? + set -e + if [ "${secret_status}" -eq 0 ]; then + unset secret_json + set +e + build_token="$(vault kv get -field=build_token "${secret_path}" 2>&1)" + field_status=$? + set -e + if [ "${field_status}" -eq 0 ]; then + case "${build_token}" in + ''|*[!0-9a-f]*) unset build_token; return 11 ;; + esac + if [ "${#build_token}" -eq 64 ]; then + unset build_token + return 0 + fi + unset build_token + return 11 + fi + if printf '%s' "${build_token}" | grep -q 'No value found'; then + unset build_token + return 11 + fi + unset build_token + elif printf '%s' "${secret_json}" | grep -Eq 'Code: 404|No value found at'; then + unset secret_json + return 10 + fi + if [ "${attempt}" -lt 5 ]; then + sleep "${attempt}" + fi + attempt=$((attempt + 1)) + done + unset secret_json + return 12 +} + +ensure_token + +if read_build_token; then + read_status=0 +else + read_status=$? +fi +case "${read_status}" in + 0) + log "job-scoped token already present; no write performed" + exit 0 + ;; + 11) + log "secret exists without a valid build_token; refusing to overwrite existing fields" + exit 1 + ;; + 12) + log "secret read failed after retries; refusing to seed" + exit 1 + ;; + 10) ;; + *) + log "unexpected secret read state ${read_status}" + exit 1 + ;; +esac + +new_token="$(retry_command vault write -field=random_bytes \ + sys/tools/random/32 format=hex)" || { + log "Vault random token generation failed after retries" + exit 1 + } +case "${new_token}" in + ''|*[!0-9a-f]*) + unset new_token + log "Vault returned an invalid random token" + exit 1 + ;; +esac +if [ "${#new_token}" -ne 64 ]; then + unset new_token + log "Vault returned an invalid random token" + exit 1 +fi + +umask 077 +printf '{"options":{"cas":0},"data":{"build_token":"%s"}}\n' \ + "${new_token}" > "${payload_file}" +unset new_token + +set +e +create_error="$(vault write "${secret_api_path}" @"${payload_file}" 2>&1 >/dev/null)" +create_status=$? +set -e +cleanup +if [ "${create_status}" -eq 0 ]; then + log "job-scoped token created with KV-v2 CAS create-only semantics" + exit 0 +fi + +if printf '%s' "${create_error}" | grep -qi 'check-and-set'; then + unset create_error + if read_build_token; then + log "another seeder won CAS; existing token preserved" + exit 0 + fi + log "CAS race did not produce a readable build_token; refusing to continue" + exit 1 +fi + +unset create_error +log "KV-v2 CAS create failed; refusing to retry a write" +exit 1 diff --git a/services/vault-hermes-jenkins-token-seed/serviceaccount-webui.yaml b/services/vault-hermes-jenkins-token-seed/serviceaccount-webui.yaml new file mode 100644 index 00000000..a3a28622 --- /dev/null +++ b/services/vault-hermes-jenkins-token-seed/serviceaccount-webui.yaml @@ -0,0 +1,7 @@ +# services/vault-hermes-jenkins-token-seed/serviceaccount-webui.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: hermes-jenkins-webui-token-seed + namespace: vault +automountServiceAccountToken: true diff --git a/services/vault/scripts/vault_k8s_auth_configure.sh b/services/vault/scripts/vault_k8s_auth_configure.sh index 37551b3d..be5e5ffe 100644 --- a/services/vault/scripts/vault_k8s_auth_configure.sh +++ b/services/vault/scripts/vault_k8s_auth_configure.sh @@ -254,7 +254,7 @@ write_policy_and_role "nextcloud" "nextcloud" "nextcloud-vault" \ write_policy_and_role "comms" "comms" "comms-vault,atlasbot" \ "comms/* shared/chat-ai-keys-runtime shared/harbor-pull" "" write_policy_and_role "jenkins" "jenkins" "jenkins" \ - "jenkins/* shared/harbor-pull quality/sonarqube-oidc hermes/developer-jenkins" "" + "jenkins/* shared/harbor-pull quality/sonarqube-oidc hermes/developer-jenkins hermes/developer-jenkins-webui" "" write_policy_and_role "jenkins-vault-sync" "jenkins" "jenkins-vault-sync" \ "shared/harbor-pull" "" write_policy_and_role "monitoring" "monitoring" "monitoring-vault-sync" \ @@ -275,7 +275,7 @@ write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \ # the least-authority hermes-scm-broker role (below) carries the Gitea token # so a compromised agent cannot reach the SCM write path directly. write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \ - "hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" "" + "hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-harbor hermes/developer-jenkins hermes/developer-jenkins-webui hermes/developer-ssh" "" write_policy_and_role "hermes-switchyard" "hermes" "hermes-switchyard" \ "hermes/chat-telegram" "" hermes_jenkins_token_seed_policy=' @@ -293,6 +293,24 @@ vault_cmd write "auth/kubernetes/role/hermes-jenkins-token-seed" \ bound_service_account_namespaces="vault" \ policies="hermes-jenkins-token-seed" \ ttl="${role_ttl}" +# A dedicated path and identity for the WebUI publish lane: create-only, like +# the agent lane above, and never able to read or write hermes/developer-jenkins +# so this token can never be confused with or substituted for the agent's. +hermes_jenkins_webui_token_seed_policy=' +path "kv/data/atlas/hermes/developer-jenkins-webui" { + capabilities = ["create", "read"] +} +path "sys/tools/random/32" { + capabilities = ["update"] +} +' +write_raw_policy "hermes-jenkins-webui-token-seed" "${hermes_jenkins_webui_token_seed_policy}" +log "writing role hermes-jenkins-webui-token-seed" +vault_cmd write "auth/kubernetes/role/hermes-jenkins-webui-token-seed" \ + bound_service_account_names="hermes-jenkins-webui-token-seed" \ + bound_service_account_namespaces="vault" \ + policies="hermes-jenkins-webui-token-seed" \ + ttl="${role_ttl}" write_policy_and_role "hermes-scm-broker" "hermes-scm" "hermes-scm-broker" \ "hermes/developer-gitea" "" write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \ diff --git a/testing/tests/test_hermes_image_builder_vault.py b/testing/tests/test_hermes_image_builder_vault.py index 6dadf10c..46b3adb1 100644 --- a/testing/tests/test_hermes_image_builder_vault.py +++ b/testing/tests/test_hermes_image_builder_vault.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.util import os import stat import subprocess @@ -17,21 +18,30 @@ SEEDER = ( REPO_ROOT / "services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_build_token_ensure.sh" ) +WEBUI_SEEDER = ( + REPO_ROOT + / "services/vault-hermes-jenkins-token-seed/scripts" + / "vault_hermes_jenkins_webui_build_token_ensure.sh" +) def test_developer_jenkins_access_is_read_only_and_bound_to_two_consumers() -> None: - """Only the Jenkins controller and Hermes agent can read the fixed-job token.""" + """Only the Jenkins controller and Hermes agent can read either fixed-job token.""" source = VAULT_CONFIG.read_text(encoding="utf-8") assert 'write_policy_and_role "jenkins" "jenkins" "jenkins"' in source assert ( '"jenkins/* shared/harbor-pull quality/sonarqube-oidc ' - 'hermes/developer-jenkins" ""' + 'hermes/developer-jenkins hermes/developer-jenkins-webui" ""' ) in source assert 'write_policy_and_role "hermes-agent" "hermes" "hermes-agent"' in source - assert 'hermes/developer-jenkins hermes/developer-ssh" ""' in source + assert ( + 'hermes/developer-jenkins hermes/developer-jenkins-webui ' + 'hermes/developer-ssh" ""' + ) in source assert 'write_policy_and_role "hermes-switchyard" "hermes"' in source assert '"hermes/chat-telegram" ""' in source assert '"hermes/developer-jenkins"' not in source + assert '"hermes/developer-jenkins-webui"' not in source jenkins_spc = yaml.safe_load( (REPO_ROOT / "services/jenkins/secretproviderclass.yaml").read_text() @@ -131,15 +141,17 @@ def test_seed_vault_policy_is_exact_create_read_and_rng_only() -> None: assert 'bound_service_account_namespaces="vault"' in source -def _fake_vault_tools(tmp_path: Path) -> tuple[Path, Path, Path]: +def _fake_vault_tools( + tmp_path: Path, + secret_path: str = "kv/data/atlas/hermes/developer-jenkins", +) -> tuple[Path, Path, Path]: """Create deterministic Vault/sleep fakes and return their evidence paths.""" binary_dir = tmp_path / "bin" binary_dir.mkdir(parents=True) calls = tmp_path / "calls" stdin_capture = tmp_path / "stdin" vault = binary_dir / "vault" - vault.write_text( - """#!/bin/sh + script = """#!/bin/sh set -eu printf '%s\n' "$*" >> "$FAKE_CALL_LOG" scenario="$FAKE_SCENARIO" @@ -236,7 +248,9 @@ if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then fi echo "unexpected fake Vault call: $*" >&2 exit 99 -""", +""" + vault.write_text( + script.replace("kv/data/atlas/hermes/developer-jenkins", secret_path), encoding="utf-8", ) sleep = binary_dir / "sleep" @@ -247,9 +261,13 @@ exit 99 def _run_seeder( - tmp_path: Path, scenario: str + tmp_path: Path, + scenario: str, + *, + seeder: Path = SEEDER, + secret_path: str = "kv/data/atlas/hermes/developer-jenkins", ) -> tuple[subprocess.CompletedProcess, str]: - binary_dir, calls, stdin_capture = _fake_vault_tools(tmp_path) + binary_dir, calls, stdin_capture = _fake_vault_tools(tmp_path, secret_path) env = { **os.environ, "PATH": f"{binary_dir}:{os.environ['PATH']}", @@ -262,7 +280,7 @@ def _run_seeder( "TMPDIR": str(tmp_path), } result = subprocess.run( - ["sh", str(SEEDER)], + ["sh", str(seeder)], env=env, text=True, capture_output=True, @@ -327,3 +345,165 @@ def test_seeder_retries_reads_and_accepts_only_a_verified_cas_winner( assert race_calls.count("write kv/data/atlas/hermes/developer-jenkins") == 1 assert race_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins") == 2 assert "another seeder won CAS" in race.stderr + + +def test_webui_seed_job_targets_its_own_service_account_and_dedicated_script() -> None: + """The WebUI seed job never reuses the agent seed's identity or Vault role.""" + job = yaml.safe_load( + (REPO_ROOT / "services/vault-hermes-jenkins-token-seed/job-webui.yaml").read_text() + ) + assert job["metadata"]["name"] == "vault-hermes-jenkins-webui-build-token-seed" + pod = job["spec"]["template"]["spec"] + assert pod["serviceAccountName"] == "hermes-jenkins-webui-token-seed" + assert pod["restartPolicy"] == "Never" + container = pod["containers"][0] + assert container["command"] == [ + "sh", + "/scripts/vault_hermes_jenkins_webui_build_token_ensure.sh", + ] + role_env = next(item for item in container["env"] if item["name"] == "VAULT_K8S_ROLE") + assert role_env["value"] == "hermes-jenkins-webui-token-seed" + security = container["securityContext"] + assert security["runAsNonRoot"] is True + assert security["readOnlyRootFilesystem"] is True + assert security["allowPrivilegeEscalation"] is False + assert security["capabilities"]["drop"] == ["ALL"] + + kustomization = yaml.safe_load( + (REPO_ROOT / "services/vault-hermes-jenkins-token-seed/kustomization.yaml").read_text() + ) + assert "job-webui.yaml" in kustomization["resources"] + assert "serviceaccount-webui.yaml" in kustomization["resources"] + script_generator = next( + item + for item in kustomization["configMapGenerator"] + if item["name"] == "vault-hermes-jenkins-webui-token-seed-script" + ) + assert script_generator["files"] == [ + "vault_hermes_jenkins_webui_build_token_ensure.sh=" + "scripts/vault_hermes_jenkins_webui_build_token_ensure.sh" + ] + + seed_kustomization = yaml.safe_load( + ( + REPO_ROOT + / "clusters/atlas/flux-system/applications" + / "vault-hermes-jenkins-token-seed/kustomization.yaml" + ).read_text() + ) + assert { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "vault-hermes-jenkins-webui-build-token-seed", + "namespace": "vault", + } in seed_kustomization["spec"]["healthChecks"] + + +def test_webui_seed_vault_policy_is_exact_create_read_and_rng_only() -> None: + """The WebUI seed identity cannot rotate, delete, list, or touch the agent path.""" + source = VAULT_CONFIG.read_text(encoding="utf-8") + policy = source.split("hermes_jenkins_webui_token_seed_policy='", 1)[1].split( + "'", 1 + )[0] + assert 'path "kv/data/atlas/hermes/developer-jenkins-webui"' in policy + assert 'capabilities = ["create", "read"]' in policy + assert 'path "sys/tools/random/32"' in policy + assert 'capabilities = ["update"]' in policy + for forbidden in ("delete", "patch", "list", 'kv/data/atlas/hermes/*'): + assert forbidden not in policy + assert '"kv/data/atlas/hermes/developer-jenkins"' not in policy + assert 'bound_service_account_names="hermes-jenkins-webui-token-seed"' in source + assert 'bound_service_account_namespaces="vault"' in source + + +def test_webui_seeder_never_writes_over_the_agent_seeders_secret() -> None: + """The two seeders address disjoint Vault paths, never the other's token.""" + source = WEBUI_SEEDER.read_text(encoding="utf-8") + assert 'secret_path="kv/atlas/hermes/developer-jenkins-webui"' in source + assert 'secret_api_path="kv/data/atlas/hermes/developer-jenkins-webui"' in source + assert "kv/atlas/hermes/developer-jenkins\"" not in source + assert '"options":{"cas":0}' in source + assert "kv patch" not in source + assert "kv delete" not in source + assert WEBUI_SEEDER.name not in VAULT_CONFIG.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("scenario", ["existing", "read-error"]) +def test_webui_seeder_never_overwrites_existing_or_ambiguous_state( + tmp_path: Path, scenario: str +) -> None: + """The WebUI seeder mirrors the agent seeder's fail-closed create-only behavior.""" + result, calls = _run_seeder( + tmp_path, + scenario, + seeder=WEBUI_SEEDER, + secret_path="kv/data/atlas/hermes/developer-jenkins-webui", + ) + assert (result.returncode == 0) is (scenario == "existing") + assert "write kv/data/atlas/hermes/developer-jenkins-webui" not in calls + assert "sys/tools/random" not in calls + + +def test_webui_seeder_uses_single_cas_create_for_absent_secret(tmp_path: Path) -> None: + """A confirmed 404 on the WebUI path produces one create-only write.""" + result, calls = _run_seeder( + tmp_path, + "absent", + seeder=WEBUI_SEEDER, + secret_path="kv/data/atlas/hermes/developer-jenkins-webui", + ) + assert result.returncode == 0, result.stderr + assert calls.count("write kv/data/atlas/hermes/developer-jenkins-webui") == 1 + payload = (tmp_path / "stdin").read_text(encoding="utf-8") + assert '"options":{"cas":0}' in payload + assert '"build_token":"' in payload + + +def test_webui_seeder_accepts_only_a_verified_cas_winner(tmp_path: Path) -> None: + """A competing create on the WebUI path is accepted only after a reread.""" + race, race_calls = _run_seeder( + tmp_path, + "cas-race", + seeder=WEBUI_SEEDER, + secret_path="kv/data/atlas/hermes/developer-jenkins-webui", + ) + assert race.returncode == 0, race.stderr + assert race_calls.count("write kv/data/atlas/hermes/developer-jenkins-webui") == 1 + assert race_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins-webui") == 2 + assert "another seeder won CAS" in race.stderr + + +def test_webui_build_token_reaches_runtime_access_with_private_mode( + tmp_path: Path, monkeypatch +) -> None: + """The staged WebUI token is byte-exact and mode-0600, like the agent token.""" + stage_path = REPO_ROOT / "services/hermes/scripts/stage_runtime_access.py" + assert '"jenkins-webui-image-build-token",' in stage_path.read_text( + encoding="utf-8" + ) + + spec = importlib.util.spec_from_file_location("stage_runtime_access", stage_path) + assert spec is not None and spec.loader is not None + stage = importlib.util.module_from_spec(spec) + spec.loader.exec_module(stage) + + vault = tmp_path / "vault" + vault.mkdir() + (vault / "jenkins-webui-image-build-token").write_text( + "webui-job-scoped-token\n", encoding="utf-8" + ) + runtime = tmp_path / "runtime" + runtime.mkdir() + monkeypatch.setattr(stage, "VAULT_ROOT", vault) + monkeypatch.setattr(stage.os, "chown", lambda *_args: None) + monkeypatch.setattr(stage.os, "fchown", lambda *_args: None) + + value = stage._copy_secret( + "jenkins-webui-image-build-token", + runtime / "jenkins-webui-image-build-token", + ) + + assert value == "webui-job-scoped-token" + staged = runtime / "jenkins-webui-image-build-token" + assert staged.read_text(encoding="utf-8").strip() == "webui-job-scoped-token" + assert staged.stat().st_mode & 0o777 == 0o600 diff --git a/testing/tests/test_hermes_webui_release.py b/testing/tests/test_hermes_webui_release.py index 0ab35dfa..fe37d0e0 100644 --- a/testing/tests/test_hermes_webui_release.py +++ b/testing/tests/test_hermes_webui_release.py @@ -70,7 +70,7 @@ def _release_fixture(tmp_path: Path): def test_webui_job_is_independent_manual_and_main_only() -> None: - """WebUI has its own job and never widens the existing agent-only lane.""" + """WebUI has its own job, its own build token, and never widens the agent lane.""" config = yaml.safe_load( (ROOT / "services/jenkins/configmap-jcasc.yaml").read_text(encoding="utf-8") ) @@ -84,6 +84,46 @@ def test_webui_job_is_independent_manual_and_main_only() -> None: assert "scriptPath('ci/Jenkinsfile.hermes-webui-image')" in block assert "pipelineTriggers" not in block assert "HERMES_AGENT_IMAGE_BUILD_TOKEN" not in block + assert "authenticationToken(System.getenv('HERMES_WEBUI_IMAGE_BUILD_TOKEN'))" in block + + +def test_webui_build_token_is_seeded_and_wired_independently_of_the_agent_token() -> None: + """The WebUI publish token has its own Vault path, seed identity, and file.""" + vault_script = ( + ROOT / "services/vault/scripts/vault_k8s_auth_configure.sh" + ).read_text(encoding="utf-8") + assert "hermes/developer-jenkins-webui" in vault_script + assert "hermes-jenkins-webui-token-seed" in vault_script + seed_script = ( + ROOT + / "services/vault-hermes-jenkins-token-seed/scripts" + / "vault_hermes_jenkins_webui_build_token_ensure.sh" + ).read_text(encoding="utf-8") + assert "kv/atlas/hermes/developer-jenkins-webui" in seed_script + assert "kv/atlas/hermes/developer-jenkins" not in seed_script.replace( + "kv/atlas/hermes/developer-jenkins-webui", "" + ) + + jenkins_deployment = (ROOT / "services/jenkins/deployment.yaml").read_text( + encoding="utf-8" + ) + assert 'with secret "kv/data/atlas/hermes/developer-jenkins-webui"' in jenkins_deployment + assert "HERMES_WEBUI_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }}" in jenkins_deployment + + agent_deployment = (ROOT / "services/hermes/agent-deployment.yaml").read_text( + encoding="utf-8" + ) + assert ( + "vault.hashicorp.com/agent-inject-secret-jenkins-webui-image-build-token: " + "kv/data/atlas/hermes/developer-jenkins-webui" + ) in agent_deployment + + trigger_script = ( + ROOT / "services/hermes/scripts/jenkins_webui_image_build_trigger.py" + ).read_text(encoding="utf-8") + assert 'JOB_NAME = "hermes-webui-image"' in trigger_script + assert 'TOKEN_FILE = Path("/runtime-access/jenkins-webui-image-build-token")' in trigger_script + assert 'CONFIRM_PUBLISH": "PUBLISH HERMES WEBUI"' in trigger_script def test_pipeline_builds_exact_reviewed_main_and_never_deploys() -> None: