WIP: jenkins: give hermes-webui-image its own build-trigger token #54
@ -24,3 +24,7 @@ spec:
|
|||||||
kind: Job
|
kind: Job
|
||||||
name: vault-hermes-jenkins-build-token-seed-2
|
name: vault-hermes-jenkins-build-token-seed-2
|
||||||
namespace: vault
|
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
|
with the previous value as another coordinated rotation; never delete the
|
||||||
secret to make the seeder recreate it.
|
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
|
## The actual supervised triage algorithm
|
||||||
|
|
||||||
1. Classify the request as test/build triage, service health, or alert tuning.
|
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" -}}
|
{{- with secret "kv/data/atlas/hermes/developer-jenkins" -}}
|
||||||
{{ .Data.data.build_token }}
|
{{ .Data.data.build_token }}
|
||||||
{{- end }}
|
{{- 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-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh
|
||||||
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
vault.hashicorp.com/agent-inject-template-node-ssh-private-key: |
|
||||||
{{- with secret "kv/data/atlas/hermes/developer-ssh" -}}
|
{{- 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",
|
"execution-pool-key",
|
||||||
"chat-relay-key",
|
"chat-relay-key",
|
||||||
"jenkins-image-build-token",
|
"jenkins-image-build-token",
|
||||||
|
"jenkins-webui-image-build-token",
|
||||||
"node-ssh-private-key",
|
"node-ssh-private-key",
|
||||||
"node-ssh-known-hosts",
|
"node-ssh-known-hosts",
|
||||||
):
|
):
|
||||||
|
|||||||
@ -674,6 +674,7 @@ data:
|
|||||||
pipelineJob('hermes-webui-image') {
|
pipelineJob('hermes-webui-image') {
|
||||||
disabled(false)
|
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.')
|
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 {
|
definition {
|
||||||
cpsScm {
|
cpsScm {
|
||||||
scm {
|
scm {
|
||||||
|
|||||||
@ -68,6 +68,9 @@ spec:
|
|||||||
{{ with secret "kv/data/atlas/hermes/developer-jenkins" }}
|
{{ with secret "kv/data/atlas/hermes/developer-jenkins" }}
|
||||||
HERMES_AGENT_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }}
|
HERMES_AGENT_IMAGE_BUILD_TOKEN={{ .Data.data.build_token }}
|
||||||
{{ end }}
|
{{ 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"
|
bstein.dev/restarted-at: "2026-05-20T09:40:31Z"
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: jenkins
|
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:
|
resources:
|
||||||
- serviceaccount.yaml
|
- serviceaccount.yaml
|
||||||
- job.yaml
|
- job.yaml
|
||||||
|
- serviceaccount-webui.yaml
|
||||||
|
- job-webui.yaml
|
||||||
generatorOptions:
|
generatorOptions:
|
||||||
disableNameSuffixHash: true
|
disableNameSuffixHash: true
|
||||||
configMapGenerator:
|
configMapGenerator:
|
||||||
- name: vault-hermes-jenkins-token-seed-script
|
- name: vault-hermes-jenkins-token-seed-script
|
||||||
files:
|
files:
|
||||||
- vault_hermes_jenkins_build_token_ensure.sh=scripts/vault_hermes_jenkins_build_token_ensure.sh
|
- 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" \
|
write_policy_and_role "comms" "comms" "comms-vault,atlasbot" \
|
||||||
"comms/* shared/chat-ai-keys-runtime shared/harbor-pull" ""
|
"comms/* shared/chat-ai-keys-runtime shared/harbor-pull" ""
|
||||||
write_policy_and_role "jenkins" "jenkins" "jenkins" \
|
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" \
|
write_policy_and_role "jenkins-vault-sync" "jenkins" "jenkins-vault-sync" \
|
||||||
"shared/harbor-pull" ""
|
"shared/harbor-pull" ""
|
||||||
write_policy_and_role "monitoring" "monitoring" "monitoring-vault-sync" \
|
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
|
# the least-authority hermes-scm-broker role (below) carries the Gitea token
|
||||||
# so a compromised agent cannot reach the SCM write path directly.
|
# so a compromised agent cannot reach the SCM write path directly.
|
||||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \
|
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" \
|
write_policy_and_role "hermes-switchyard" "hermes" "hermes-switchyard" \
|
||||||
"hermes/chat-telegram" ""
|
"hermes/chat-telegram" ""
|
||||||
hermes_jenkins_token_seed_policy='
|
hermes_jenkins_token_seed_policy='
|
||||||
@ -293,6 +293,24 @@ vault_cmd write "auth/kubernetes/role/hermes-jenkins-token-seed" \
|
|||||||
bound_service_account_namespaces="vault" \
|
bound_service_account_namespaces="vault" \
|
||||||
policies="hermes-jenkins-token-seed" \
|
policies="hermes-jenkins-token-seed" \
|
||||||
ttl="${role_ttl}"
|
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" \
|
write_policy_and_role "hermes-scm-broker" "hermes-scm" "hermes-scm-broker" \
|
||||||
"hermes/developer-gitea" ""
|
"hermes/developer-gitea" ""
|
||||||
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -17,21 +18,30 @@ SEEDER = (
|
|||||||
REPO_ROOT
|
REPO_ROOT
|
||||||
/ "services/vault-hermes-jenkins-token-seed/scripts/vault_hermes_jenkins_build_token_ensure.sh"
|
/ "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:
|
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")
|
source = VAULT_CONFIG.read_text(encoding="utf-8")
|
||||||
assert 'write_policy_and_role "jenkins" "jenkins" "jenkins"' in source
|
assert 'write_policy_and_role "jenkins" "jenkins" "jenkins"' in source
|
||||||
assert (
|
assert (
|
||||||
'"jenkins/* shared/harbor-pull quality/sonarqube-oidc '
|
'"jenkins/* shared/harbor-pull quality/sonarqube-oidc '
|
||||||
'hermes/developer-jenkins" ""'
|
'hermes/developer-jenkins hermes/developer-jenkins-webui" ""'
|
||||||
) in source
|
) in source
|
||||||
assert 'write_policy_and_role "hermes-agent" "hermes" "hermes-agent"' 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 'write_policy_and_role "hermes-switchyard" "hermes"' in source
|
||||||
assert '"hermes/chat-telegram" ""' in source
|
assert '"hermes/chat-telegram" ""' in source
|
||||||
assert '"hermes/developer-jenkins"' not in source
|
assert '"hermes/developer-jenkins"' not in source
|
||||||
|
assert '"hermes/developer-jenkins-webui"' not in source
|
||||||
|
|
||||||
jenkins_spc = yaml.safe_load(
|
jenkins_spc = yaml.safe_load(
|
||||||
(REPO_ROOT / "services/jenkins/secretproviderclass.yaml").read_text()
|
(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
|
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."""
|
"""Create deterministic Vault/sleep fakes and return their evidence paths."""
|
||||||
binary_dir = tmp_path / "bin"
|
binary_dir = tmp_path / "bin"
|
||||||
binary_dir.mkdir(parents=True)
|
binary_dir.mkdir(parents=True)
|
||||||
calls = tmp_path / "calls"
|
calls = tmp_path / "calls"
|
||||||
stdin_capture = tmp_path / "stdin"
|
stdin_capture = tmp_path / "stdin"
|
||||||
vault = binary_dir / "vault"
|
vault = binary_dir / "vault"
|
||||||
vault.write_text(
|
script = """#!/bin/sh
|
||||||
"""#!/bin/sh
|
|
||||||
set -eu
|
set -eu
|
||||||
printf '%s\n' "$*" >> "$FAKE_CALL_LOG"
|
printf '%s\n' "$*" >> "$FAKE_CALL_LOG"
|
||||||
scenario="$FAKE_SCENARIO"
|
scenario="$FAKE_SCENARIO"
|
||||||
@ -236,7 +248,9 @@ if [ "$1" = "read" ] && [ "$2" = "-format=json" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "unexpected fake Vault call: $*" >&2
|
echo "unexpected fake Vault call: $*" >&2
|
||||||
exit 99
|
exit 99
|
||||||
""",
|
"""
|
||||||
|
vault.write_text(
|
||||||
|
script.replace("kv/data/atlas/hermes/developer-jenkins", secret_path),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
sleep = binary_dir / "sleep"
|
sleep = binary_dir / "sleep"
|
||||||
@ -247,9 +261,13 @@ exit 99
|
|||||||
|
|
||||||
|
|
||||||
def _run_seeder(
|
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]:
|
) -> 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 = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
"PATH": f"{binary_dir}:{os.environ['PATH']}",
|
"PATH": f"{binary_dir}:{os.environ['PATH']}",
|
||||||
@ -262,7 +280,7 @@ def _run_seeder(
|
|||||||
"TMPDIR": str(tmp_path),
|
"TMPDIR": str(tmp_path),
|
||||||
}
|
}
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sh", str(SEEDER)],
|
["sh", str(seeder)],
|
||||||
env=env,
|
env=env,
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=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("write kv/data/atlas/hermes/developer-jenkins") == 1
|
||||||
assert race_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins") == 2
|
assert race_calls.count("read -format=json kv/data/atlas/hermes/developer-jenkins") == 2
|
||||||
assert "another seeder won CAS" in race.stderr
|
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:
|
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(
|
config = yaml.safe_load(
|
||||||
(ROOT / "services/jenkins/configmap-jcasc.yaml").read_text(encoding="utf-8")
|
(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 "scriptPath('ci/Jenkinsfile.hermes-webui-image')" in block
|
||||||
assert "pipelineTriggers" not in block
|
assert "pipelineTriggers" not in block
|
||||||
assert "HERMES_AGENT_IMAGE_BUILD_TOKEN" 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:
|
def test_pipeline_builds_exact_reviewed_main_and_never_deploys() -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user