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:
commit
6f783b7778
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
152
services/harbor/hermes-webui-base-mirror-job.yaml
Normal file
152
services/harbor/hermes-webui-base-mirror-job.yaml
Normal 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: {}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user