build(hermes-webui): multi-arch image (arm64 + amd64)

Repoints both Dockerfile.hermes-webui FROM bases to in-cluster Harbor mirrors
(webui base OCI index + the a68d1c4d multi-arch hermes-agent manifest list),
adds the suspended webui base-mirror Job, and gives the WebUI image build an
amd64 leg on titan-24 plus a manifest-list combine — so the hux sidecar can
schedule onto the amd64 accelerator node titan-22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-25 18:31:00 -03:00
commit 6f783b7778
7 changed files with 582 additions and 13 deletions

View File

@ -205,6 +205,7 @@ spec:
testing/tests/test_hermes_hux_runtime_vendor_parity.py \ testing/tests/test_hermes_hux_runtime_vendor_parity.py \
testing/tests/test_hermes_hux_delivery.py \ testing/tests/test_hermes_hux_delivery.py \
testing/tests/test_hermes_oci_promote.py \ testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_multiarch_combine.py \
testing/tests/test_hermes_image_automation.py testing/tests/test_hermes_image_automation.py
HUX_BACKEND_TESTS="$(find testing/tests -maxdepth 1 -type f \ HUX_BACKEND_TESTS="$(find testing/tests -maxdepth 1 -type f \
-name 'test_hermes_hux_*.py' \ -name 'test_hermes_hux_*.py' \
@ -239,7 +240,7 @@ spec:
} }
} }
} }
stage('Build and publish without a daemon') { stage('Build arm64 leg without a daemon') {
steps { steps {
container('kaniko') { container('kaniko') {
withCredentials([usernamePassword( withCredentials([usernamePassword(
@ -251,7 +252,7 @@ spec:
set -eu set -eu
set +x set +x
config_path=/kaniko/.docker/config.json config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-webui.destination)" destination="$(cat build/hermes-webui.destination)-arm64"
source_revision="$(cat build/hermes-webui.source-revision)" source_revision="$(cat build/hermes-webui.source-revision)"
umask 077 umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')" auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
@ -267,19 +268,206 @@ spec:
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \ --dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
--destination="${destination}" \ --destination="${destination}" \
--build-arg="HERMES_WEBUI_RELEASE_ID=git-${source_revision}-build-${BUILD_NUMBER}" \ --build-arg="HERMES_WEBUI_RELEASE_ID=git-${source_revision}-build-${BUILD_NUMBER}" \
--digest-file="${WORKSPACE}/build/hermes-webui.digest" \ --digest-file="${WORKSPACE}/build/hermes-webui-arm64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui.image" \ --image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui-arm64.image" \
--label="org.opencontainers.image.revision=${source_revision}" \ --label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \ --label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
--label="org.opencontainers.image.title=hermes-webui" \ --label="org.opencontainers.image.title=hermes-webui" \
--cleanup \ --cleanup \
--push-retry=3 --push-retry=3
/busybox/chmod 644 build/hermes-webui.digest build/hermes-webui.image /busybox/chmod 644 build/hermes-webui-arm64.digest build/hermes-webui-arm64.image
''' '''
} }
} }
} }
} }
stage('Build amd64 leg without a daemon') {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
atlas.bstein.dev/workload: hermes-webui-image-builder-amd64
spec:
serviceAccountName: hermes-image-builder
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# titan-24 is an accelerator node (not a general worker) that co-hosts the
# out-of-cluster Sui validator. Pin the disposable amd64 build to it by
# hostname + arch ONLY — do NOT require node-role worker, so titan-24 is never
# opened to general cluster scheduling. The toleration + tight caps below keep
# this off the validator's back.
nodeSelector:
kubernetes.io/arch: amd64
kubernetes.io/hostname: titan-24
tolerations:
# titan-24 co-hosts the out-of-cluster Sui validator; tolerate whatever
# PreferNoSchedule/NoSchedule guard taint the node carries so the pinned
# build lands, and rely on the tight resource caps below (not scheduling
# priority) to keep the disposable build from starving the validator.
- operator: Exists
imagePullSecrets:
- name: harbor-bstein-robot
containers:
- name: jnlp
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 250m
memory: 384Mi
- name: kaniko
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
command: ["/busybox/sh", "-c"]
args: ["/busybox/sleep 99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
privileged: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 100m
memory: 512Mi
ephemeral-storage: 10Gi
limits:
cpu: "1500m"
memory: 3Gi
ephemeral-storage: 20Gi
"""
}
}
steps {
// This amd64 leg runs on its own fresh pod (titan-24), so it must check
// out the SCM itself before the reviewed-revision git boundary check —
// otherwise `git rev-parse origin/main` fails with "not a git repository".
checkout scm
container('jnlp') {
sh '''
set -eu
mkdir -p build
test "${PUBLISH_IMAGE}" = "true"
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"
case "${EXPECTED_SOURCE_REVISION}" in
*[!0-9a-f]*|'')
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
exit 2
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-webui
case "${BUILD_NUMBER}" in
''|0*|*[!0-9]*)
echo "BUILD_NUMBER must be a positive decimal integer" >&2
exit 2
;;
esac
printf '%s\n' \
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
> build/hermes-webui.destination
printf '%s\n' "${actual_revision}" > build/hermes-webui.source-revision
'''
}
container('kaniko') {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''#!/busybox/sh
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-webui.destination)-amd64"
source_revision="$(cat build/hermes-webui.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
/busybox/mkdir -p /kaniko/.docker
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
unset HARBOR_USER HARBOR_PASSWORD auth
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
umask 022
/kaniko/executor \
--registry-mirror=harbor-core.harbor.svc.cluster.local \
--insecure-registry=harbor-core.harbor.svc.cluster.local \
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
--destination="${destination}" \
--build-arg="HERMES_WEBUI_RELEASE_ID=git-${source_revision}-build-${BUILD_NUMBER}" \
--digest-file="${WORKSPACE}/build/hermes-webui-amd64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui-amd64.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
--label="org.opencontainers.image.title=hermes-webui" \
--cleanup \
--push-retry=3
/busybox/chmod 644 build/hermes-webui-amd64.digest build/hermes-webui-amd64.image
'''
}
}
stash(
name: 'hermes-webui-amd64-evidence',
includes: 'build/hermes-webui-amd64.digest,build/hermes-webui-amd64.image'
)
}
}
stage('Combine multi-arch index') {
steps {
unstash 'hermes-webui-amd64-evidence'
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-webui.destination)"
source_revision="$(cat build/hermes-webui.source-revision)"
python3 ci/scripts/hermes_multiarch_combine.py \
--destination "${destination}" \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--arm64-digest-file build/hermes-webui-arm64.digest \
--arm64-image-file build/hermes-webui-arm64.image \
--amd64-digest-file build/hermes-webui-amd64.digest \
--amd64-image-file build/hermes-webui-amd64.image \
--digest-file build/hermes-webui.digest \
--image-file build/hermes-webui.image
test -s build/hermes-webui.digest
test -s build/hermes-webui.image
'''
}
}
}
stage('Render reviewed Flux handoff') { stage('Render reviewed Flux handoff') {
steps { steps {
withCredentials([usernamePassword( withCredentials([usernamePassword(
@ -316,6 +504,10 @@ spec:
build/hermes-webui.digest \ build/hermes-webui.digest \
build/hermes-webui.image \ build/hermes-webui.image \
build/hermes-webui.source-revision \ build/hermes-webui.source-revision \
build/hermes-webui-arm64.digest \
build/hermes-webui-arm64.image \
build/hermes-webui-amd64.digest \
build/hermes-webui-amd64.image \
build/hermes-webui-release/hermes-chat-statefulset.yaml \ build/hermes-webui-release/hermes-chat-statefulset.yaml \
build/hermes-webui-release/hermes-dashboard-deployment.yaml \ build/hermes-webui-release/hermes-dashboard-deployment.yaml \
build/hermes-webui-release/hermes-webui-image.json \ build/hermes-webui-release/hermes-webui-image.json \
@ -336,7 +528,7 @@ spec:
--output-dir build/hermes-webui-release --output-dir build/hermes-webui-release
''' '''
archiveArtifacts( archiveArtifacts(
artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui.source-revision,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch', artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui.source-revision,build/hermes-webui-arm64.digest,build/hermes-webui-arm64.image,build/hermes-webui-amd64.digest,build/hermes-webui-amd64.image,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch',
allowEmptyArchive: false, allowEmptyArchive: false,
fingerprint: true fingerprint: true
) )

View File

@ -40,8 +40,11 @@ from typing import Any, Callable
REGISTRY_ORIGIN = "https://registry.bstein.dev" REGISTRY_ORIGIN = "https://registry.bstein.dev"
# The final (arch-less) Flux-visible tag; identical contract to the promoter. # The final (arch-less) Flux-visible tag; identical contract to the promoter.
# Both Hermes images that the multi-arch pipelines publish share the exact same
# arch-less final-tag contract; the repository name is the only difference and is
# captured here so the combiner stays fail-closed to just these two components.
DESTINATION_PATTERN = re.compile( DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/hermes-agent:" r"^registry\.bstein\.dev/bstein/(?P<component>hermes-agent|hermes-webui):"
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$" r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
) )
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
@ -224,7 +227,7 @@ def combine_multiarch_index(
raise ValueError("invalid multi-arch destination") raise ValueError("invalid multi-arch destination")
if set(arch_digests) != set(ARCHITECTURES): if set(arch_digests) != set(ARCHITECTURES):
raise ValueError("expected exactly the arm64 and amd64 per-arch digests") raise ValueError("expected exactly the arm64 and amd64 per-arch digests")
component = "hermes-agent" component = match.group("component")
index_tag = destination.rsplit(":", 1)[1] index_tag = destination.rsplit(":", 1)[1]
authorization = _authorization(username, password) authorization = _authorization(username, password)

View File

@ -1,8 +1,25 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-webui # dockerfiles/Dockerfile.hermes-webui
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui #
# Both FROM bases below are multi-arch (linux/amd64 + linux/arm64). Kaniko builds
# one native leaf per node arch (arm64 on titan-20, amd64 on titan-24) and each
# leaf selects the matching arch from these indexes; ci/scripts/hermes_multiarch_combine.py
# then binds the two leaves into one manifest list. This is what lets the agent
# pod's `hux` sidecar (which runs this image) schedule onto the amd64 node titan-22.
#
# The upstream WebUI base is a multi-arch OCI index
# (sha256:a83a3893... -> amd64 sha256:54fd4990..., arm64 sha256:9094ae6a...). It is
# mirrored digest-for-digest into the in-cluster Harbor `mirror` project by
# services/harbor/hermes-webui-base-mirror-job.yaml so the build never depends on
# ghcr.io egress (flaky from build pods). Bump this digest and BOTH args in that
# Job together, then an operator re-runs the (suspended) Job once.
FROM harbor-core.harbor.svc.cluster.local/mirror/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
FROM registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 # Layer each arch's WebUI on the matching leaf of the multi-arch Hermes agent index
# (sha256:a68d1c4d... -> amd64 sha256:c89ac4bc..., arm64 sha256:572854cb...), a Docker
# manifest list already in Harbor's bstein project. Kaniko's --registry-mirror pulls
# it internally through harbor-core.
FROM registry.bstein.dev/bstein/hermes-agent@sha256:a68d1c4d5517cc5e6719661e77be4f18d4964b07a85dcebaf6f62368646e4e6c
ARG HERMES_WEBUI_RELEASE_ID ARG HERMES_WEBUI_RELEASE_ID

View File

@ -0,0 +1,152 @@
# services/harbor/hermes-webui-base-mirror-job.yaml
#
# One-shot mirror of the upstream Hermes WebUI base image INDEX from ghcr.io into
# the in-cluster Harbor "mirror" project, so the reviewed image build
# (dockerfiles/Dockerfile.hermes-webui) pulls its `AS webui` FROM base internally
# with no ghcr.io fallback (ghcr/docker.io egress is IPv6-broken/flaky from build
# pods, which is why the agent base was mirrored the same way).
#
# The upstream digest sha256:a83a3893... is ALREADY a multi-arch OCI index
# (amd64 sha256:54fd4990..., arm64 sha256:9094ae6a...). `skopeo copy --all` is
# content-addressed, so it reproduces the identical index and both arch leaves in
# Harbor -- the build stays digest-pinned AND multi-arch.
#
# Kept `suspend: true` exactly like hermes-agent-base-mirror-job.yaml: it needs
# egress to ghcr.io and is only run deliberately, once per base-digest bump. To
# run it, an operator clears suspend (or `kubectl create job --from`) AFTER
# updating the digest in BOTH args below and in the Dockerfile `AS webui` FROM.
apiVersion: batch/v1
kind: Job
metadata:
name: harbor-hermes-webui-base-mirror-1
namespace: harbor
spec:
suspend: true
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
# This Job has an app init container (ensure-project) that reads the
# injected secret, so Vault's init must run FIRST — otherwise it is
# appended after ensure-project and the secret file isn't written yet.
vault.hashicorp.com/agent-init-first: "true"
vault.hashicorp.com/agent-run-as-user: "65532"
vault.hashicorp.com/agent-run-as-group: "65532"
vault.hashicorp.com/role: harbor-policy-bootstrap
vault.hashicorp.com/agent-inject-secret-harbor-admin-password: kv/data/atlas/harbor/harbor-core
vault.hashicorp.com/agent-inject-template-harbor-admin-password: |
{{- with secret "kv/data/atlas/harbor/harbor-core" -}}
{{ .Data.data.harbor_admin_password }}
{{- end -}}
spec:
serviceAccountName: harbor-policy-bootstrap
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: 65532
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
# Ensure the public "mirror" project exists before skopeo tries to push
# into it (Harbor only auto-creates repositories inside an existing
# project). Reuses the exact same generic ensure-project helper and
# Vault-injected admin password as the sibling agent base-mirror Job --
# no new credential and no new project is introduced.
- name: ensure-project
image: docker.io/library/python@sha256:efcdfa6a6b2fd2afb9c7dfa9a5b288a6f68338b5cfdebe6b637d986067d85757
imagePullPolicy: IfNotPresent
command: [python3, /scripts/harbor_mirror_project_ensure.py]
env:
- name: HARBOR_API_ORIGIN
value: http://harbor-core.harbor.svc.cluster.local/api/v2.0
- name: HARBOR_ADMIN_PASSWORD_FILE
value: /vault/secrets/harbor-admin-password
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
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}
containers:
# skopeo copies the WHOLE multi-arch index (--all) straight from ghcr.io
# to the internal Harbor registry. Source is the public upstream image, so
# no source credential is needed; the digest is asserted on both ends so a
# drifted upstream tag cannot be mirrored.
- name: mirror
image: quay.io/skopeo/stable@sha256:94f5c5e26997e2e78c234ec9abf19a391c234b39eb22e6d1210d0b527c97dcc8
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -eu
src="docker://ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2"
# Push to the EXTERNAL Harbor endpoint (valid TLS) — the same path
# kaniko already publishes to — to avoid harbor-core's internal
# HTTP/token-realm quirks. The image lands in the same Harbor
# 'mirror' project, so kaniko still pulls it internally via harbor-core.
dst="docker://registry.bstein.dev/mirror/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2"
pw="$(cat /vault/secrets/harbor-admin-password)"
exec skopeo copy --all \
--src-tls-verify=true \
--dest-tls-verify=true \
--dest-creds "admin:${pw}" \
"${src}" "${dst}"
env:
- name: HOME
value: /tmp
- name: TMPDIR
value: /tmp
- name: XDG_RUNTIME_DIR
value: /tmp
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: tmp
mountPath: /tmp
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi}
volumes:
- name: scripts
configMap:
name: harbor-hermes-webui-base-mirror-script
defaultMode: 0555
- name: tmp
emptyDir: {}

View File

@ -17,6 +17,7 @@ resources:
- hermes-webui-immutability-job.yaml - hermes-webui-immutability-job.yaml
- hermes-chat-router-immutability-job.yaml - hermes-chat-router-immutability-job.yaml
- hermes-agent-base-mirror-job.yaml - hermes-agent-base-mirror-job.yaml
- hermes-webui-base-mirror-job.yaml
- bootstrap-jobs/cassandra-registry-ensure-job.yaml - bootstrap-jobs/cassandra-registry-ensure-job.yaml
- image.yaml - image.yaml
configMapGenerator: configMapGenerator:
@ -36,3 +37,6 @@ configMapGenerator:
- name: harbor-hermes-agent-base-mirror-script - name: harbor-hermes-agent-base-mirror-script
files: files:
- harbor_mirror_project_ensure.py=scripts/harbor_mirror_project_ensure.py - harbor_mirror_project_ensure.py=scripts/harbor_mirror_project_ensure.py
- name: harbor-hermes-webui-base-mirror-script
files:
- harbor_mirror_project_ensure.py=scripts/harbor_mirror_project_ensure.py

View File

@ -383,3 +383,48 @@ def test_cli_rejects_mismatched_per_arch_evidence_pair(
) )
assert module.main() == 1 assert module.main() == 1
assert "does not match" in json.loads(capsys.readouterr().out)["error"] assert "does not match" in json.loads(capsys.readouterr().out)["error"]
# The combiner serves both Hermes multi-arch images; only the repository name in
# the destination differs. hermes-webui must route to its own registry paths.
WEBUI_DESTINATION = (
f"registry.bstein.dev/bstein/hermes-webui:git-{REVISION}-build-{BUILD}"
)
def test_destination_pattern_captures_both_components() -> None:
"""The fail-closed pattern accepts exactly hermes-agent and hermes-webui."""
module = _load()
agent = module.DESTINATION_PATTERN.fullmatch(DESTINATION)
webui = module.DESTINATION_PATTERN.fullmatch(WEBUI_DESTINATION)
assert agent is not None and agent.group("component") == "hermes-agent"
assert webui is not None and webui.group("component") == "hermes-webui"
# A third, unexpected repository is still rejected.
assert (
module.DESTINATION_PATTERN.fullmatch(
f"registry.bstein.dev/bstein/hermes-other:git-{REVISION}-build-{BUILD}"
)
is None
)
def test_combines_webui_leaves_under_the_webui_component() -> None:
"""A hermes-webui destination re-reads leaves from the webui registry paths."""
module = _load()
amd64, arm64 = _leaf("amd64"), _leaf("arm64")
registry = Registry(module, amd64, arm64)
result = module.combine_multiarch_index(
destination=WEBUI_DESTINATION,
arch_digests={"amd64": amd64["digest"], "arm64": arm64["digest"]},
username="robot",
password="private",
opener=registry,
)
assert result["component"] == "hermes-webui"
assert result["result"] == "published"
# Every registry call the combiner made must be scoped to the webui repo, and
# never leak into the agent repo.
assert registry.calls, "combiner made no registry calls"
for _method, url in registry.calls:
assert "/v2/bstein/hermes-webui/" in url
assert "hermes-agent" not in url

View File

@ -579,17 +579,173 @@ def test_webui_policy_rejects_disabled_duplicate_or_truncated_rules() -> None:
policy.list_rules(_FakePolicyClient([(200, b"[]", {"X-Total-Count": "1"})])) policy.list_rules(_FakePolicyClient([(200, b"[]", {"X-Total-Count": "1"})]))
def test_pipeline_archives_exact_eight_files_before_release() -> None: def test_pipeline_archives_exact_twelve_files_before_release() -> None:
"""Release cannot pass with missing digest, workload, or metadata evidence.""" """Release cannot pass with missing digest, workload, or metadata evidence.
The multi-arch topology archives the four arch-less index files, the four
per-arch leg leaves (arm64 + amd64 digest/image), and the four rendered Flux
handoff files -- twelve exact paths, no globs.
"""
source = PIPELINE.read_text(encoding="utf-8") source = PIPELINE.read_text(encoding="utf-8")
evidence = source.split("stage('Verify and archive release evidence')", 1)[1] evidence = source.split("stage('Verify and archive release evidence')", 1)[1]
evidence = evidence.split("stage('Publish Flux release tag')", 1)[0] evidence = evidence.split("stage('Publish Flux release tag')", 1)[0]
archive = evidence.split("artifacts: '", 1)[1].split("'", 1)[0].split(",") archive = evidence.split("artifacts: '", 1)[1].split("'", 1)[0].split(",")
assert len(archive) == len(set(archive)) == 8 assert len(archive) == len(set(archive)) == 12
assert all("*" not in path for path in archive) assert all("*" not in path for path in archive)
assert "find build -type f" in evidence assert "find build -type f" in evidence
assert "allowEmptyArchive: false" in evidence assert "allowEmptyArchive: false" in evidence
assert "build/hermes-webui.source-revision" in archive assert "build/hermes-webui.source-revision" in archive
for arch in ("arm64", "amd64"):
assert f"build/hermes-webui-{arch}.digest" in archive
assert f"build/hermes-webui-{arch}.image" in archive
assert "hermes-chat-statefulset.yaml" in evidence assert "hermes-chat-statefulset.yaml" in evidence
assert "hermes-dashboard-deployment.yaml" in evidence assert "hermes-dashboard-deployment.yaml" in evidence
assert " post {" not in source assert " post {" not in source
# --- Multi-arch (linux/amd64 + linux/arm64) topology -----------------------
#
# The WebUI image must be multi-arch so the agent pod's `hux` sidecar (which runs
# this image) can schedule onto the amd64 node titan-22. These tests lock the two
# legs + combine, the two internal multi-arch FROM bases, and the Harbor mirror
# Job that feeds the upstream WebUI base internally.
MIRROR_JOB = ROOT / "services/harbor/hermes-webui-base-mirror-job.yaml"
HARBOR_KUSTOMIZATION = ROOT / "services/harbor/kustomization.yaml"
WEBUI_BASE_DIGEST = (
"sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2"
)
AGENT_MULTIARCH_INDEX_DIGEST = (
"sha256:a68d1c4d5517cc5e6719661e77be4f18d4964b07a85dcebaf6f62368646e4e6c"
)
AGENT_SINGLE_ARCH_DIGEST = (
"sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107"
)
def _amd64_pod_spec() -> dict:
"""Parse the nested amd64 build pod (the second inline pod template)."""
source = PIPELINE.read_text(encoding="utf-8")
blocks = source.split('yaml """')[1:]
for block in blocks:
body = block.split('"""', 1)[0]
doc = yaml.safe_load(body)
labels = doc.get("metadata", {}).get("labels", {})
if str(labels.get("atlas.bstein.dev/workload", "")).endswith("-amd64"):
return doc["spec"]
raise AssertionError("no amd64 build pod template found in the pipeline")
def test_pipeline_builds_two_arch_legs_and_combines_one_index() -> None:
"""The pipeline builds arm64 + amd64 legs and binds them into one index."""
source = PIPELINE.read_text(encoding="utf-8")
assert "stage('Build arm64 leg without a daemon')" in source
assert "stage('Build amd64 leg without a daemon')" in source
assert "stage('Combine multi-arch index')" in source
# Each leg publishes an arch-suffixed candidate tag and its own evidence.
assert 'destination="$(cat build/hermes-webui.destination)-arm64"' in source
assert 'destination="$(cat build/hermes-webui.destination)-amd64"' in source
for arch in ("arm64", "amd64"):
assert f"build/hermes-webui-{arch}.digest" in source
assert f"build/hermes-webui-{arch}.image" in source
# Both legs must still stamp the WebUI release id (no arch drift in args).
assert (
'--build-arg="HERMES_WEBUI_RELEASE_ID='
'git-${source_revision}-build-${BUILD_NUMBER}"'
) in source
# The combiner reuses the shared, fail-closed manifest-list assembler.
assert "ci/scripts/hermes_multiarch_combine.py" in source
assert "--arm64-digest-file build/hermes-webui-arm64.digest" in source
assert "--amd64-digest-file build/hermes-webui-amd64.digest" in source
assert "--digest-file build/hermes-webui.digest" in source
# The amd64 leg runs on its own fresh pod, so it re-checks out and re-runs the
# exact release boundary before building.
assert source.count("checkout scm") >= 2
assert source.count('test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"') >= 2
# The final archived evidence covers the index AND both per-arch leaves.
for evidence in (
"build/hermes-webui-arm64.digest",
"build/hermes-webui-arm64.image",
"build/hermes-webui-amd64.digest",
"build/hermes-webui-amd64.image",
):
assert source.count(evidence) >= 3 # build, expected list, archive list
def test_amd64_leg_is_pinned_to_titan_24_without_worker_role() -> None:
"""The disposable amd64 leg targets the accelerator node by hostname only."""
spec = _amd64_pod_spec()
assert spec["serviceAccountName"] == "hermes-image-builder"
assert spec["automountServiceAccountToken"] is False
assert spec["nodeSelector"] == {
"kubernetes.io/arch": "amd64",
"kubernetes.io/hostname": "titan-24",
}
# titan-24 is an accelerator, not a general worker: never require the worker
# role label, and tolerate its guard taint so the pinned build lands.
assert "node-role.kubernetes.io/worker" not in spec["nodeSelector"]
assert spec["tolerations"] == [{"operator": "Exists"}]
containers = {item["name"]: item for item in spec["containers"]}
assert "kaniko" in containers
for container in containers.values():
sc = container["securityContext"]
assert sc["allowPrivilegeEscalation"] is False
assert sc["capabilities"]["drop"] == ["ALL"]
# Tight caps keep the disposable build off the co-hosted validator's back.
assert containers["kaniko"]["resources"]["limits"]["memory"] == "3Gi"
def test_dockerfile_bases_are_multiarch_and_pulled_internally() -> None:
"""Both FROM bases are multi-arch indexes sourced from inside the cluster."""
source = DOCKERFILE.read_text(encoding="utf-8")
# Upstream WebUI base now comes from the in-cluster Harbor mirror, not ghcr.
assert (
"FROM harbor-core.harbor.svc.cluster.local/mirror/hermes-webui@"
f"{WEBUI_BASE_DIGEST} AS webui"
) in source
assert "ghcr.io/nesquena" not in source
# The agent base is the multi-arch index, not the retired single-arch leaf.
assert (
f"FROM registry.bstein.dev/bstein/hermes-agent@{AGENT_MULTIARCH_INDEX_DIGEST}"
in source
)
assert AGENT_SINGLE_ARCH_DIGEST not in source
def test_webui_base_mirror_job_is_suspended_and_digest_pinned() -> None:
"""A suspended, digest-pinned skopeo Job mirrors the WebUI base into Harbor."""
job = yaml.safe_load(MIRROR_JOB.read_text(encoding="utf-8"))
assert job["kind"] == "Job"
assert job["metadata"]["name"] == "harbor-hermes-webui-base-mirror-1"
assert job["metadata"]["namespace"] == "harbor"
# Operator-run only: it needs ghcr egress and must never fire automatically.
assert job["spec"]["suspend"] is True
pod = job["spec"]["template"]["spec"]
mirror = next(c for c in pod["containers"] if c["name"] == "mirror")
args = "\n".join(mirror["args"])
assert "skopeo copy --all" in args
assert f"docker://ghcr.io/nesquena/hermes-webui@{WEBUI_BASE_DIGEST}" in args
assert (
f"docker://registry.bstein.dev/mirror/hermes-webui@{WEBUI_BASE_DIGEST}" in args
)
# Vault must init before the ensure-project init container reads the secret.
annotations = job["spec"]["template"]["metadata"]["annotations"]
assert annotations["vault.hashicorp.com/agent-init-first"] == "true"
init = next(c for c in pod["initContainers"] if c["name"] == "ensure-project")
assert init["command"] == ["python3", "/scripts/harbor_mirror_project_ensure.py"]
script_volume = next(v for v in pod["volumes"] if v["name"] == "scripts")
assert (
script_volume["configMap"]["name"] == "harbor-hermes-webui-base-mirror-script"
)
def test_webui_base_mirror_job_is_wired_into_harbor_kustomization() -> None:
"""Flux applies the mirror Job and mounts its ensure-project script."""
kustomization = yaml.safe_load(HARBOR_KUSTOMIZATION.read_text(encoding="utf-8"))
assert "hermes-webui-base-mirror-job.yaml" in kustomization["resources"]
generators = {g["name"]: g for g in kustomization["configMapGenerator"]}
assert "harbor-hermes-webui-base-mirror-script" in generators
assert (
"harbor_mirror_project_ensure.py=scripts/harbor_mirror_project_ensure.py"
in generators["harbor-hermes-webui-base-mirror-script"]["files"]
)