WIP: jenkins: give hermes-webui-image its own build-trigger token #54

Draft
hermes-automation wants to merge 1 commits from feature/t_39cf1905-webui-build-token into main
14 changed files with 642 additions and 13 deletions

View File

@ -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

View File

@ -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 '<reviewed full main commit>'
```
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.

View File

@ -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" -}}

View File

@ -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())

View File

@ -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",
):

View File

@ -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 {

View File

@ -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

View File

@ -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: {}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" \

View File

@ -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

View File

@ -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: