WIP: jenkins: give hermes-webui-image its own build-trigger token #54
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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" -}}
|
||||
|
||||
112
services/hermes/scripts/jenkins_webui_image_build_trigger.py
Normal file
112
services/hermes/scripts/jenkins_webui_image_build_trigger.py
Normal 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())
|
||||
@ -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",
|
||||
):
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
69
services/vault-hermes-jenkins-token-seed/job-webui.yaml
Normal file
69
services/vault-hermes-jenkins-token-seed/job-webui.yaml
Normal 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: {}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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" \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user