release(hermes): automate chat router image
This commit is contained in:
parent
53d7c2c586
commit
31c5eae5ae
339
ci/Jenkinsfile.hermes-chat-router-image
Normal file
339
ci/Jenkinsfile.hermes-chat-router-image
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
kubernetes {
|
||||||
|
defaultContainer 'python'
|
||||||
|
yaml """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
atlas.bstein.dev/workload: hermes-chat-router-image-builder
|
||||||
|
spec:
|
||||||
|
serviceAccountName: hermes-image-builder
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/arch: arm64
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: In
|
||||||
|
values: [titan-20]
|
||||||
|
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: 500m, memory: 512Mi}
|
||||||
|
- name: python
|
||||||
|
image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
|
||||||
|
command: ["sleep"]
|
||||||
|
args: ["99d"]
|
||||||
|
tty: true
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
|
limits: {cpu: 250m, memory: 256Mi}
|
||||||
|
- name: golang
|
||||||
|
image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
|
||||||
|
command: ["sleep"]
|
||||||
|
args: ["99d"]
|
||||||
|
tty: true
|
||||||
|
env:
|
||||||
|
- {name: GOCACHE, value: /tmp/go-cache}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
|
- 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: 256Mi
|
||||||
|
ephemeral-storage: 2Gi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 2Gi
|
||||||
|
ephemeral-storage: 5Gi
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parameters {
|
||||||
|
booleanParam(
|
||||||
|
name: 'PUBLISH_IMAGE',
|
||||||
|
defaultValue: false,
|
||||||
|
description: 'Publish the reviewed main revision to Harbor.'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'EXPECTED_SOURCE_REVISION',
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Full reviewed commit that must be contained by atlas/titan-iac main.'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'CONFIRM_PUBLISH',
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Enter PUBLISH HERMES CHAT ROUTER to confirm the release.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
environment {
|
||||||
|
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-chat-router'
|
||||||
|
}
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
|
||||||
|
skipDefaultCheckout(true)
|
||||||
|
timeout(time: 45, unit: 'MINUTES')
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Checkout reviewed source') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Enforce release boundary') {
|
||||||
|
steps {
|
||||||
|
container('jnlp') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
mkdir -p build
|
||||||
|
test "${PUBLISH_IMAGE}" = "true"
|
||||||
|
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES CHAT ROUTER"
|
||||||
|
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-chat-router
|
||||||
|
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-chat-router.destination
|
||||||
|
printf '%s\n' "${actual_revision}" \
|
||||||
|
> build/hermes-chat-router.source-revision
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Validate reviewed router source') {
|
||||||
|
steps {
|
||||||
|
container('python') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
||||||
|
--target=/tmp/hermes-chat-router-test-deps \
|
||||||
|
pytest==8.3.4 PyYAML==6.0.2
|
||||||
|
PYTHONPATH=/tmp/hermes-chat-router-test-deps \
|
||||||
|
python3 -m pytest -q \
|
||||||
|
testing/tests/test_hermes_chat_router_release.py \
|
||||||
|
testing/tests/test_hermes_oci_promote.py \
|
||||||
|
testing/tests/test_hermes_image_automation.py
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
container('golang') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd services/hermes/router
|
||||||
|
GO111MODULE=off CGO_ENABLED=0 go test ./...
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Reject replay before publish') {
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'harbor-robot',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
destination="$(cat build/hermes-chat-router.destination)"
|
||||||
|
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||||
|
python3 ci/scripts/hermes_chat_router_release.py assert-absent \
|
||||||
|
--source-revision "${source_revision}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
--destination "${destination}"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Build and publish without a daemon') {
|
||||||
|
steps {
|
||||||
|
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-chat-router.destination)"
|
||||||
|
source_revision="$(cat build/hermes-chat-router.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-chat-router" \
|
||||||
|
--destination="${destination}" \
|
||||||
|
--digest-file="${WORKSPACE}/build/hermes-chat-router.digest" \
|
||||||
|
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-chat-router.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-chat-router" \
|
||||||
|
--cleanup \
|
||||||
|
--push-retry=3
|
||||||
|
/busybox/chmod 644 \
|
||||||
|
build/hermes-chat-router.digest build/hermes-chat-router.image
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Render reviewed Flux handoff') {
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'harbor-robot',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
destination="$(cat build/hermes-chat-router.destination)"
|
||||||
|
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||||
|
python3 ci/scripts/hermes_chat_router_release.py render \
|
||||||
|
--digest-file build/hermes-chat-router.digest \
|
||||||
|
--image-file build/hermes-chat-router.image \
|
||||||
|
--source-revision "${source_revision}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--manifest services/hermes/chat-router.yaml \
|
||||||
|
--output-dir build/hermes-chat-router-release
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Verify and archive release evidence') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
expected_files="$(printf '%s\n' \
|
||||||
|
build/hermes-chat-router.destination \
|
||||||
|
build/hermes-chat-router.digest \
|
||||||
|
build/hermes-chat-router.image \
|
||||||
|
build/hermes-chat-router.source-revision \
|
||||||
|
build/hermes-chat-router-release/hermes-chat-router-deployment.yaml \
|
||||||
|
build/hermes-chat-router-release/hermes-chat-router-image-update.patch \
|
||||||
|
build/hermes-chat-router-release/hermes-chat-router-image.json \
|
||||||
|
| LC_ALL=C sort)"
|
||||||
|
actual_files="$(find build -type f -print | LC_ALL=C sort)"
|
||||||
|
test "${actual_files}" = "${expected_files}"
|
||||||
|
destination="$(cat build/hermes-chat-router.destination)"
|
||||||
|
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||||
|
python3 ci/scripts/hermes_chat_router_release.py verify-evidence \
|
||||||
|
--digest-file build/hermes-chat-router.digest \
|
||||||
|
--image-file build/hermes-chat-router.image \
|
||||||
|
--source-revision "${source_revision}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--manifest services/hermes/chat-router.yaml \
|
||||||
|
--output-dir build/hermes-chat-router-release
|
||||||
|
'''
|
||||||
|
archiveArtifacts(
|
||||||
|
artifacts: 'build/hermes-chat-router.destination,build/hermes-chat-router.digest,build/hermes-chat-router.image,build/hermes-chat-router.source-revision,build/hermes-chat-router-release/hermes-chat-router-deployment.yaml,build/hermes-chat-router-release/hermes-chat-router-image-update.patch,build/hermes-chat-router-release/hermes-chat-router-image.json',
|
||||||
|
allowEmptyArchive: false,
|
||||||
|
fingerprint: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Publish Flux release tag') {
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'harbor-robot',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
destination="$(cat build/hermes-chat-router.destination)"
|
||||||
|
source_revision="$(cat build/hermes-chat-router.source-revision)"
|
||||||
|
python3 ci/scripts/hermes_oci_promote.py \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--digest-file build/hermes-chat-router.digest \
|
||||||
|
--source-revision "${source_revision}" \
|
||||||
|
--build-number "${BUILD_NUMBER}"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
479
ci/scripts/hermes_chat_router_release.py
Normal file
479
ci/scripts/hermes_chat_router_release.py
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify and render a reviewable Hermes chat-router image release."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-chat-router"
|
||||||
|
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||||
|
HARBOR_PROJECT = "bstein"
|
||||||
|
HARBOR_REPOSITORY = "hermes-chat-router"
|
||||||
|
IMMUTABLE_REPOSITORY_PATTERN = "hermes-chat-router"
|
||||||
|
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
|
||||||
|
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
|
||||||
|
DESTINATION_PATTERN = re.compile(
|
||||||
|
r"^registry\.bstein\.dev/bstein/hermes-chat-router:"
|
||||||
|
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Never forward registry credentials to a redirect target."""
|
||||||
|
|
||||||
|
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validated(value: str, pattern: re.Pattern[str], label: str) -> str:
|
||||||
|
"""Return a normalized value only when it matches the release contract."""
|
||||||
|
normalized = value.strip()
|
||||||
|
if not pattern.fullmatch(normalized):
|
||||||
|
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def validate_destination(
|
||||||
|
destination: str, source_revision: str, build_number: str
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Bind the unique build tag to one reviewed source and Jenkins build."""
|
||||||
|
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
|
||||||
|
build = _validated(build_number, BUILD_PATTERN, "build number")
|
||||||
|
match = DESTINATION_PATTERN.fullmatch(destination.strip())
|
||||||
|
if not match or match.groups() != (revision, build):
|
||||||
|
raise ValueError("destination does not match the reviewed revision and build")
|
||||||
|
return revision, build
|
||||||
|
|
||||||
|
|
||||||
|
def validate_kaniko_evidence(
|
||||||
|
*, digest_text: str, image_text: str, destination: str
|
||||||
|
) -> str:
|
||||||
|
"""Cross-check Kaniko's two independent output files."""
|
||||||
|
digests = digest_text.splitlines()
|
||||||
|
images = image_text.splitlines()
|
||||||
|
if len(digests) != 1 or len(images) != 1:
|
||||||
|
raise ValueError("Kaniko evidence must contain exactly one line per file")
|
||||||
|
digest = _validated(digests[0], DIGEST_PATTERN, "image digest")
|
||||||
|
if images[0].strip() != f"{destination}@{digest}":
|
||||||
|
raise ValueError("Kaniko image evidence does not match destination and digest")
|
||||||
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
|
||||||
|
"""Return same-origin registry responses without following redirects."""
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect())
|
||||||
|
try:
|
||||||
|
return opener.open(request, timeout=timeout)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def _authorization(username: str, password: str) -> str:
|
||||||
|
"""Build a Basic header without placing credentials in a URL."""
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("Harbor credentials are unavailable")
|
||||||
|
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_response(
|
||||||
|
destination: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
"""Read one exact Harbor artifact by candidate tag."""
|
||||||
|
if not DESTINATION_PATTERN.fullmatch(destination):
|
||||||
|
raise ValueError("invalid destination")
|
||||||
|
tag = urllib.parse.quote(destination.rsplit(":", 1)[1], safe="")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
|
||||||
|
f"{HARBOR_REPOSITORY}/artifacts/{tag}?with_immutable_status=true",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": _authorization(username, password),
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
with opener(request, 20) as response:
|
||||||
|
body = response.read(1_048_577)
|
||||||
|
if len(body) > 1_048_576:
|
||||||
|
raise RuntimeError("Harbor artifact response exceeded the size limit")
|
||||||
|
return int(response.status), body
|
||||||
|
|
||||||
|
|
||||||
|
def _rules_response(
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> tuple[int, bytes, dict[str, str]]:
|
||||||
|
"""Read the complete bounded Harbor immutable-rule page."""
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
|
||||||
|
"?page=1&page_size=100",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": _authorization(username, password),
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
with opener(request, 20) as response:
|
||||||
|
body = response.read(1_048_577)
|
||||||
|
if len(body) > 1_048_576:
|
||||||
|
raise RuntimeError("Harbor immutable rule response exceeded the size limit")
|
||||||
|
return int(response.status), body, dict(response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Select only immutable-policy fields used by this lane."""
|
||||||
|
return {
|
||||||
|
"disabled": bool(rule.get("disabled", False)),
|
||||||
|
"action": rule.get("action"),
|
||||||
|
"template": rule.get("template"),
|
||||||
|
"tag_selectors": [
|
||||||
|
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||||
|
for item in rule.get("tag_selectors") or []
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||||
|
for item in (rule.get("scope_selectors") or {}).get("repository", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_immutable_policy(
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> None:
|
||||||
|
"""Fail closed unless one exact active router immutability rule exists."""
|
||||||
|
status, body, headers = _rules_response(
|
||||||
|
username=username, password=password, opener=opener
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
|
||||||
|
try:
|
||||||
|
rules = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
|
||||||
|
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||||
|
raise RuntimeError("Harbor immutable rule list has an invalid shape")
|
||||||
|
total = next(
|
||||||
|
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if total is None or not str(total).isdecimal() or int(total) != len(rules):
|
||||||
|
raise RuntimeError("Harbor immutable rule page is incomplete")
|
||||||
|
expected = {
|
||||||
|
"disabled": False,
|
||||||
|
"action": "immutable",
|
||||||
|
"template": "immutable_template",
|
||||||
|
"tag_selectors": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "matches",
|
||||||
|
"pattern": IMMUTABLE_TAG_PATTERN,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "repoMatches",
|
||||||
|
"pattern": IMMUTABLE_REPOSITORY_PATTERN,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
matches = [
|
||||||
|
value
|
||||||
|
for value in map(_normalized_rule, rules)
|
||||||
|
if value["tag_selectors"] == expected["tag_selectors"]
|
||||||
|
and value["scope_selectors"] == expected["scope_selectors"]
|
||||||
|
]
|
||||||
|
if matches != [expected]:
|
||||||
|
raise RuntimeError("Harbor router immutable build-tag policy is not exact")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_tag_absent(
|
||||||
|
destination: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> None:
|
||||||
|
"""Reject replay before Kaniko can target an already-used build tag."""
|
||||||
|
status, _ = _artifact_response(
|
||||||
|
destination, username=username, password=password, opener=opener
|
||||||
|
)
|
||||||
|
if status == 404:
|
||||||
|
return
|
||||||
|
if status == 200:
|
||||||
|
raise RuntimeError("Harbor destination tag already exists")
|
||||||
|
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_registry_digest(
|
||||||
|
destination: str,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> None:
|
||||||
|
"""Verify Harbor's digest, immutable tag, and persisted source label."""
|
||||||
|
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
|
||||||
|
status, body = _artifact_response(
|
||||||
|
destination, username=username, password=password, opener=opener
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
|
||||||
|
try:
|
||||||
|
artifact = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("Harbor returned invalid artifact JSON") from exc
|
||||||
|
tag = destination.rsplit(":", 1)[1]
|
||||||
|
matching = [
|
||||||
|
item
|
||||||
|
for item in artifact.get("tags") or []
|
||||||
|
if isinstance(item, dict) and item.get("name") == tag
|
||||||
|
]
|
||||||
|
labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
|
||||||
|
if artifact.get("digest") != digest:
|
||||||
|
raise RuntimeError("Harbor digest does not match Kaniko evidence")
|
||||||
|
if len(matching) != 1 or matching[0].get("immutable") is not True:
|
||||||
|
raise RuntimeError("Harbor did not enforce the candidate tag as immutable")
|
||||||
|
if not isinstance(labels, dict) or labels.get(
|
||||||
|
"org.opencontainers.image.revision"
|
||||||
|
) != revision:
|
||||||
|
raise RuntimeError("Harbor OCI source-revision label does not match")
|
||||||
|
|
||||||
|
|
||||||
|
def render_workload(source: str, digest: str) -> str:
|
||||||
|
"""Replace the single exact router image while preserving the Flux marker."""
|
||||||
|
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
identity = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes-chat-router\n"
|
||||||
|
if not source.startswith("# services/hermes/chat-router.yaml\n" + identity):
|
||||||
|
raise ValueError("Flux target identity changed")
|
||||||
|
lines = source.splitlines(keepends=True)
|
||||||
|
matches: list[int] = []
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith("image: "):
|
||||||
|
continue
|
||||||
|
value = stripped.removeprefix("image: ").split(" #", 1)[0]
|
||||||
|
image, separator, current_digest = value.rpartition("@")
|
||||||
|
if separator and re.fullmatch(
|
||||||
|
rf"{re.escape(DEFAULT_IMAGE)}(?::[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})?",
|
||||||
|
image,
|
||||||
|
):
|
||||||
|
_validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
||||||
|
matches.append(index)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(f"expected exactly one router image; found {len(matches)}")
|
||||||
|
index = matches[0]
|
||||||
|
indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||||
|
comment = ""
|
||||||
|
if " #" in lines[index]:
|
||||||
|
comment = " #" + lines[index].split(" #", 1)[1].rstrip("\n")
|
||||||
|
newline = "\n" if lines[index].endswith("\n") else ""
|
||||||
|
lines[index] = f"{indent}image: {DEFAULT_IMAGE}@{digest}{comment}{newline}"
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(
|
||||||
|
digest: str, revision: str, build: str, destination: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"build_number": build,
|
||||||
|
"digest": digest,
|
||||||
|
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||||
|
"flux_targets": ["apps/Deployment/hermes/hermes-chat-router"],
|
||||||
|
"image": DEFAULT_IMAGE,
|
||||||
|
"published_tag": destination,
|
||||||
|
"source_revision": revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_artifacts(
|
||||||
|
*,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
manifest: Path,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
source = manifest.read_text(encoding="utf-8")
|
||||||
|
rendered = render_workload(source, digest)
|
||||||
|
patch = "".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
source.splitlines(keepends=True),
|
||||||
|
rendered.splitlines(keepends=True),
|
||||||
|
fromfile="a/services/hermes/chat-router.yaml",
|
||||||
|
tofile="b/services/hermes/chat-router.yaml",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not patch:
|
||||||
|
raise ValueError("published digest already matches the Flux target")
|
||||||
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||||
|
return {
|
||||||
|
"hermes-chat-router-deployment.yaml": rendered,
|
||||||
|
"hermes-chat-router-image-update.patch": patch,
|
||||||
|
"hermes-chat-router-image.json": json.dumps(
|
||||||
|
metadata, indent=2, sort_keys=True
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_release_artifacts(
|
||||||
|
*,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
manifest: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Write deterministic, credential-free Flux handoff evidence."""
|
||||||
|
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
revision, build = validate_destination(destination, source_revision, build_number)
|
||||||
|
expected = _expected_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=revision,
|
||||||
|
build_number=build,
|
||||||
|
destination=destination,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, content in expected.items():
|
||||||
|
(output_dir / name).write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_artifacts(
|
||||||
|
*,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
manifest: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Recompute and compare every archived handoff byte."""
|
||||||
|
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
revision, build = validate_destination(destination, source_revision, build_number)
|
||||||
|
expected = _expected_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=revision,
|
||||||
|
build_number=build,
|
||||||
|
destination=destination,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
entries = list(output_dir.iterdir())
|
||||||
|
if {entry.name for entry in entries} != set(expected) or not all(
|
||||||
|
entry.is_file() and not entry.is_symlink() for entry in entries
|
||||||
|
):
|
||||||
|
raise ValueError("release output must contain exactly three evidence files")
|
||||||
|
for name, content in expected.items():
|
||||||
|
if (output_dir / name).read_text(encoding="utf-8") != content:
|
||||||
|
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _credentials() -> tuple[str, str]:
|
||||||
|
username = os.environ.get("HARBOR_USER", "")
|
||||||
|
password = os.environ.get("HARBOR_PASSWORD", "")
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("Harbor credentials are unavailable")
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
|
def _add_common(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--source-revision", required=True)
|
||||||
|
parser.add_argument("--build-number", required=True)
|
||||||
|
parser.add_argument("--destination", required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run one fail-closed candidate or evidence operation."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
absent = commands.add_parser("assert-absent")
|
||||||
|
_add_common(absent)
|
||||||
|
for name in ("render", "verify-evidence"):
|
||||||
|
command = commands.add_parser(name)
|
||||||
|
_add_common(command)
|
||||||
|
command.add_argument("--digest-file", required=True, type=Path)
|
||||||
|
command.add_argument("--image-file", required=True, type=Path)
|
||||||
|
command.add_argument("--manifest", required=True, type=Path)
|
||||||
|
command.add_argument("--output-dir", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
validate_destination(args.destination, args.source_revision, args.build_number)
|
||||||
|
if args.command == "verify-evidence":
|
||||||
|
digest = validate_kaniko_evidence(
|
||||||
|
digest_text=args.digest_file.read_text(encoding="utf-8"),
|
||||||
|
image_text=args.image_file.read_text(encoding="utf-8"),
|
||||||
|
destination=args.destination,
|
||||||
|
)
|
||||||
|
validate_release_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=args.source_revision,
|
||||||
|
build_number=args.build_number,
|
||||||
|
destination=args.destination,
|
||||||
|
manifest=args.manifest,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
username, password = _credentials()
|
||||||
|
if args.command == "assert-absent":
|
||||||
|
verify_immutable_policy(username=username, password=password)
|
||||||
|
assert_tag_absent(
|
||||||
|
args.destination, username=username, password=password
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
digest = validate_kaniko_evidence(
|
||||||
|
digest_text=args.digest_file.read_text(encoding="utf-8"),
|
||||||
|
image_text=args.image_file.read_text(encoding="utf-8"),
|
||||||
|
destination=args.destination,
|
||||||
|
)
|
||||||
|
verify_registry_digest(
|
||||||
|
args.destination,
|
||||||
|
digest,
|
||||||
|
args.source_revision,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
write_release_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=args.source_revision,
|
||||||
|
build_number=args.build_number,
|
||||||
|
destination=args.destination,
|
||||||
|
manifest=args.manifest,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||||
|
raise SystemExit(main())
|
||||||
@ -18,7 +18,7 @@ from typing import Any, Callable
|
|||||||
REGISTRY_ORIGIN = "https://registry.bstein.dev"
|
REGISTRY_ORIGIN = "https://registry.bstein.dev"
|
||||||
DESTINATION_PATTERN = re.compile(
|
DESTINATION_PATTERN = re.compile(
|
||||||
r"^registry\.bstein\.dev/bstein/"
|
r"^registry\.bstein\.dev/bstein/"
|
||||||
r"(?P<component>hermes-(?:agent|webui|jetson-(?:stt|tts))):"
|
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
|
||||||
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}$")
|
||||||
|
|||||||
@ -26,6 +26,10 @@ spec:
|
|||||||
kind: Job
|
kind: Job
|
||||||
name: harbor-hermes-webui-immutability-ensure-1
|
name: harbor-hermes-webui-immutability-ensure-1
|
||||||
namespace: harbor
|
namespace: harbor
|
||||||
|
- apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
name: harbor-hermes-chat-router-immutability-ensure-1
|
||||||
|
namespace: harbor
|
||||||
dependsOn:
|
dependsOn:
|
||||||
- name: core
|
- name: core
|
||||||
- name: longhorn
|
- name: longhorn
|
||||||
|
|||||||
79
services/harbor/hermes-chat-router-immutability-job.yaml
Normal file
79
services/harbor/hermes-chat-router-immutability-job.yaml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# services/harbor/hermes-chat-router-immutability-job.yaml
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: harbor-hermes-chat-router-immutability-ensure-1
|
||||||
|
namespace: harbor
|
||||||
|
spec:
|
||||||
|
backoffLimit: 2
|
||||||
|
activeDeadlineSeconds: 600
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
|
vault.hashicorp.com/agent-pre-populate-only: "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
|
||||||
|
containers:
|
||||||
|
- name: ensure
|
||||||
|
image: docker.io/library/python@sha256:efcdfa6a6b2fd2afb9c7dfa9a5b288a6f68338b5cfdebe6b637d986067d85757
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: [python3, /scripts/harbor_hermes_chat_router_immutability_ensure.py]
|
||||||
|
env:
|
||||||
|
- name: HARBOR_API_ORIGIN
|
||||||
|
value: https://registry.bstein.dev/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}
|
||||||
|
volumes:
|
||||||
|
- name: scripts
|
||||||
|
configMap:
|
||||||
|
name: harbor-hermes-chat-router-immutability-script
|
||||||
|
defaultMode: 0555
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
@ -15,6 +15,7 @@ resources:
|
|||||||
- policy-bootstrap-serviceaccount.yaml
|
- policy-bootstrap-serviceaccount.yaml
|
||||||
- hermes-agent-immutability-job.yaml
|
- hermes-agent-immutability-job.yaml
|
||||||
- hermes-webui-immutability-job.yaml
|
- hermes-webui-immutability-job.yaml
|
||||||
|
- hermes-chat-router-immutability-job.yaml
|
||||||
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
||||||
- image.yaml
|
- image.yaml
|
||||||
configMapGenerator:
|
configMapGenerator:
|
||||||
@ -27,3 +28,7 @@ configMapGenerator:
|
|||||||
- name: harbor-hermes-webui-immutability-script
|
- name: harbor-hermes-webui-immutability-script
|
||||||
files:
|
files:
|
||||||
- harbor_hermes_webui_immutability_ensure.py=scripts/harbor_hermes_webui_immutability_ensure.py
|
- harbor_hermes_webui_immutability_ensure.py=scripts/harbor_hermes_webui_immutability_ensure.py
|
||||||
|
- name: harbor-hermes-chat-router-immutability-script
|
||||||
|
files:
|
||||||
|
- harbor_immutable_rule_ensure.py=scripts/harbor_immutable_rule_ensure.py
|
||||||
|
- harbor_hermes_chat_router_immutability_ensure.py=scripts/harbor_hermes_chat_router_immutability_ensure.py
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and verify the Hermes chat-router immutable build-tag rule."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from harbor_immutable_rule_ensure import HarborClient, HarborUnavailable, ensure_rule
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT = "bstein"
|
||||||
|
REPOSITORY_PATTERN = "hermes-chat-router"
|
||||||
|
TAG_PATTERN = "git-*-build-*"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Load the runtime-only admin credential and enforce tracked policy."""
|
||||||
|
origin = os.environ.get("HARBOR_API_ORIGIN", "")
|
||||||
|
password_file = Path(os.environ.get("HARBOR_ADMIN_PASSWORD_FILE", ""))
|
||||||
|
password = password_file.read_text(encoding="utf-8").strip()
|
||||||
|
client = HarborClient(origin, "admin", password)
|
||||||
|
for attempt in range(1, 13):
|
||||||
|
try:
|
||||||
|
rule_id = ensure_rule(
|
||||||
|
client,
|
||||||
|
project=PROJECT,
|
||||||
|
repository=REPOSITORY_PATTERN,
|
||||||
|
tag_pattern=TAG_PATTERN,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except HarborUnavailable:
|
||||||
|
if attempt == 12:
|
||||||
|
raise
|
||||||
|
time.sleep(min(attempt * 2, 15))
|
||||||
|
print(f"Hermes chat-router immutable build-tag rule is active (id={rule_id})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||||
|
raise SystemExit(main())
|
||||||
184
services/harbor/scripts/harbor_immutable_rule_ensure.py
Normal file
184
services/harbor/scripts/harbor_immutable_rule_ensure.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""Bounded helpers for enforcing one exact Harbor immutable-tag rule."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||||
|
MAX_RESPONSE = 1_048_576
|
||||||
|
TRANSIENT_STATUSES = {429, 502, 503, 504}
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Prevent Basic credentials from following an unexpected redirect."""
|
||||||
|
|
||||||
|
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class HarborUnavailable(RuntimeError):
|
||||||
|
"""Harbor is temporarily unavailable rather than denying policy."""
|
||||||
|
|
||||||
|
|
||||||
|
def expected_rule(repository: str, tag_pattern: str) -> dict[str, Any]:
|
||||||
|
"""Build the exact enabled immutable rule for one literal repository."""
|
||||||
|
return {
|
||||||
|
"disabled": False,
|
||||||
|
"action": "immutable",
|
||||||
|
"template": "immutable_template",
|
||||||
|
"tag_selectors": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "matches",
|
||||||
|
"pattern": tag_pattern,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "repoMatches",
|
||||||
|
"pattern": repository,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return only the immutable contract fields Harbor must preserve."""
|
||||||
|
return {
|
||||||
|
"disabled": bool(rule.get("disabled", False)),
|
||||||
|
"action": rule.get("action"),
|
||||||
|
"template": rule.get("template"),
|
||||||
|
"tag_selectors": [
|
||||||
|
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||||
|
for item in rule.get("tag_selectors") or []
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
|
||||||
|
for item in (rule.get("scope_selectors") or {}).get("repository", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HarborClient:
|
||||||
|
"""Small same-origin client for Harbor's immutable-tag API."""
|
||||||
|
|
||||||
|
def __init__(self, origin: str, username: str, password: str) -> None:
|
||||||
|
if origin.rstrip("/") != EXPECTED_ORIGIN:
|
||||||
|
raise ValueError("Harbor API origin is not the pinned production API")
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("Harbor admin credential is empty")
|
||||||
|
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
self.origin = EXPECTED_ORIGIN
|
||||||
|
self.headers = {"Authorization": f"Basic {token}"}
|
||||||
|
self.opener = urllib.request.build_opener(NoRedirect())
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self, method: str, path: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> tuple[int, bytes, dict[str, str]]:
|
||||||
|
"""Issue a bounded request and preserve non-2xx responses for checks."""
|
||||||
|
data = None
|
||||||
|
headers = dict(self.headers)
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.origin}{path}", data=data, headers=headers, method=method
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self.opener.open(request, timeout=20)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
response = exc
|
||||||
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
|
raise HarborUnavailable("Harbor policy API is unavailable") from exc
|
||||||
|
with response:
|
||||||
|
body = response.read(MAX_RESPONSE + 1)
|
||||||
|
if len(body) > MAX_RESPONSE:
|
||||||
|
raise RuntimeError("Harbor response exceeded the size limit")
|
||||||
|
return int(response.status), body, dict(response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def list_rules(client: HarborClient, project: str) -> list[dict[str, Any]]:
|
||||||
|
"""Read and validate the complete bounded project rule list."""
|
||||||
|
path = f"/projects/{project}/immutabletagrules?page=1&page_size=100"
|
||||||
|
status, body, headers = client.request("GET", path)
|
||||||
|
if status in TRANSIENT_STATUSES:
|
||||||
|
raise HarborUnavailable(f"Harbor immutable rule list returned HTTP {status}")
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(f"Harbor immutable rule list returned HTTP {status}")
|
||||||
|
try:
|
||||||
|
rules = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
|
||||||
|
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||||
|
raise RuntimeError("Harbor immutable rule list has an invalid shape")
|
||||||
|
total = next(
|
||||||
|
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if total is None or not str(total).isdecimal() or int(total) != len(rules):
|
||||||
|
raise RuntimeError("Harbor immutable rule list is incomplete")
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_rule(
|
||||||
|
client: HarborClient, *, project: str, repository: str, tag_pattern: str
|
||||||
|
) -> int:
|
||||||
|
"""Create once or verify the one exact enabled repository rule."""
|
||||||
|
expected = expected_rule(repository, tag_pattern)
|
||||||
|
|
||||||
|
def matches() -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in list_rules(client, project)
|
||||||
|
if normalized_rule(item)["tag_selectors"] == expected["tag_selectors"]
|
||||||
|
and normalized_rule(item)["scope_selectors"]
|
||||||
|
== expected["scope_selectors"]
|
||||||
|
]
|
||||||
|
|
||||||
|
existing = matches()
|
||||||
|
if len(existing) > 1:
|
||||||
|
raise RuntimeError("multiple matching immutable rules exist")
|
||||||
|
if existing:
|
||||||
|
if normalized_rule(existing[0]) != expected:
|
||||||
|
raise RuntimeError("matching immutable rule is not enabled and exact")
|
||||||
|
rule_id = existing[0].get("id")
|
||||||
|
if not isinstance(rule_id, int) or rule_id < 1:
|
||||||
|
raise RuntimeError("Harbor immutable rule omitted a valid ID")
|
||||||
|
return rule_id
|
||||||
|
path = f"/projects/{project}/immutabletagrules"
|
||||||
|
status, _body, headers = client.request("POST", path, expected)
|
||||||
|
if status in TRANSIENT_STATUSES:
|
||||||
|
raise HarborUnavailable(f"Harbor immutable rule create returned HTTP {status}")
|
||||||
|
if status != 201:
|
||||||
|
raise RuntimeError(f"Harbor immutable rule create returned HTTP {status}")
|
||||||
|
location = headers.get("Location") or headers.get("location") or ""
|
||||||
|
api_path = urllib.parse.urlsplit(client.origin).path.rstrip("/")
|
||||||
|
prefix = f"{api_path}{path}/"
|
||||||
|
suffix = location.removeprefix(prefix) if location.startswith(prefix) else ""
|
||||||
|
if not suffix.isdecimal() or int(suffix) < 1:
|
||||||
|
raise RuntimeError("Harbor immutable rule create omitted the exact Location")
|
||||||
|
for attempt in range(1, 6):
|
||||||
|
created = matches()
|
||||||
|
if (
|
||||||
|
len(created) == 1
|
||||||
|
and normalized_rule(created[0]) == expected
|
||||||
|
and created[0].get("id") == int(suffix)
|
||||||
|
):
|
||||||
|
return int(suffix)
|
||||||
|
if attempt < 5:
|
||||||
|
time.sleep(attempt)
|
||||||
|
raise RuntimeError("created Harbor immutable rule did not verify exactly")
|
||||||
@ -62,7 +62,7 @@ spec:
|
|||||||
values: [rpi5]
|
values: [rpi5]
|
||||||
containers:
|
containers:
|
||||||
- name: router
|
- name: router
|
||||||
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:6744cb7b87c6050f1b97c0675cd280b8295b3b5826ba1d6d92e37aee6fd0b8c4
|
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:6744cb7b87c6050f1b97c0675cd280b8295b3b5826ba1d6d92e37aee6fd0b8c4 # {"$imagepolicy": "hermes:hermes-chat-router-release"}
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- {name: http, containerPort: 8080, protocol: TCP}
|
- {name: http, containerPort: 8080, protocol: TCP}
|
||||||
|
|||||||
@ -53,6 +53,32 @@ spec:
|
|||||||
---
|
---
|
||||||
apiVersion: image.toolkit.fluxcd.io/v1
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
kind: ImageRepository
|
kind: ImageRepository
|
||||||
|
metadata:
|
||||||
|
name: hermes-chat-router-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
image: registry.bstein.dev/bstein/hermes-chat-router
|
||||||
|
interval: 1m0s
|
||||||
|
---
|
||||||
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
|
kind: ImagePolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-chat-router-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
imageRepositoryRef:
|
||||||
|
name: hermes-chat-router-release
|
||||||
|
filterTags:
|
||||||
|
pattern: '^git-[0-9a-f]{40}-build-(?P<build>[1-9][0-9]*)-release$'
|
||||||
|
extract: '$build'
|
||||||
|
policy:
|
||||||
|
numerical:
|
||||||
|
order: asc
|
||||||
|
digestReflectionPolicy: Always
|
||||||
|
interval: 1m0s
|
||||||
|
---
|
||||||
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
|
kind: ImageRepository
|
||||||
metadata:
|
metadata:
|
||||||
name: hermes-stt-release
|
name: hermes-stt-release
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
|
|||||||
@ -34,6 +34,13 @@ COMPONENTS = {
|
|||||||
("statefulset", "hermes-chat-tenant", "hermes-chat-tenant"),
|
("statefulset", "hermes-chat-tenant", "hermes-chat-tenant"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"router": {
|
||||||
|
"policy": "hermes-chat-router-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-chat-router",
|
||||||
|
"workloads": (
|
||||||
|
("deployment", "hermes-chat-router", "hermes-chat-router"),
|
||||||
|
),
|
||||||
|
},
|
||||||
"stt": {
|
"stt": {
|
||||||
"policy": "hermes-stt-release",
|
"policy": "hermes-stt-release",
|
||||||
"repository": "registry.bstein.dev/bstein/hermes-jetson-stt",
|
"repository": "registry.bstein.dev/bstein/hermes-jetson-stt",
|
||||||
@ -192,7 +199,7 @@ def _workload_status(
|
|||||||
def inspect_release(component: str, revision: str) -> dict:
|
def inspect_release(component: str, revision: str) -> dict:
|
||||||
"""Return safe end-to-end release state for one exact reviewed revision."""
|
"""Return safe end-to-end release state for one exact reviewed revision."""
|
||||||
if component not in COMPONENTS:
|
if component not in COMPONENTS:
|
||||||
raise ValueError("component must be agent, webui, stt, or tts")
|
raise ValueError("component must be agent, router, webui, stt, or tts")
|
||||||
if not REVISION_PATTERN.fullmatch(revision):
|
if not REVISION_PATTERN.fullmatch(revision):
|
||||||
raise ValueError("revision must be a lowercase full 40-character commit")
|
raise ValueError("revision must be a lowercase full 40-character commit")
|
||||||
config = COMPONENTS[component]
|
config = COMPONENTS[component]
|
||||||
|
|||||||
@ -27,6 +27,10 @@ JOBS = {
|
|||||||
"job": "hermes-webui-image",
|
"job": "hermes-webui-image",
|
||||||
"confirmation": "PUBLISH HERMES WEBUI",
|
"confirmation": "PUBLISH HERMES WEBUI",
|
||||||
},
|
},
|
||||||
|
"router": {
|
||||||
|
"job": "hermes-chat-router-image",
|
||||||
|
"confirmation": "PUBLISH HERMES CHAT ROUTER",
|
||||||
|
},
|
||||||
"stt": {
|
"stt": {
|
||||||
"job": "hermes-voice-image",
|
"job": "hermes-voice-image",
|
||||||
"confirmation": "PUBLISH HERMES STT",
|
"confirmation": "PUBLISH HERMES STT",
|
||||||
@ -108,7 +112,7 @@ def trigger_build(
|
|||||||
raise ValueError("revision must be a lowercase full 40-character commit")
|
raise ValueError("revision must be a lowercase full 40-character commit")
|
||||||
job = JOBS.get(component)
|
job = JOBS.get(component)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError("component must be agent, webui, stt, or tts")
|
raise ValueError("component must be agent, router, webui, stt, or tts")
|
||||||
token = token_file.read_text(encoding="utf-8").strip()
|
token = token_file.read_text(encoding="utf-8").strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise RuntimeError("Jenkins image-build token is empty")
|
raise RuntimeError("Jenkins image-build token is empty")
|
||||||
|
|||||||
@ -700,6 +700,30 @@ data:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pipelineJob('hermes-chat-router-image') {
|
||||||
|
disabled(false)
|
||||||
|
description('Bounded daemonless Kaniko release for one exact reviewed Hermes chat-router revision already contained by main. Archives exact evidence, then publishes an immutable tag for Flux deployment.')
|
||||||
|
authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))
|
||||||
|
parameters {
|
||||||
|
booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes chat-router image.')
|
||||||
|
stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit that must be contained by atlas/titan-iac main.')
|
||||||
|
stringParam('CONFIRM_PUBLISH', '', 'Exact confirmation: PUBLISH HERMES CHAT ROUTER')
|
||||||
|
}
|
||||||
|
definition {
|
||||||
|
cpsScm {
|
||||||
|
scm {
|
||||||
|
git {
|
||||||
|
remote {
|
||||||
|
url('https://scm.bstein.dev/atlas/titan-iac.git')
|
||||||
|
credentials('gitea-pat')
|
||||||
|
}
|
||||||
|
branches('*/main')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scriptPath('ci/Jenkinsfile.hermes-chat-router-image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pipelineJob('hermes-voice-image') {
|
pipelineJob('hermes-voice-image') {
|
||||||
disabled(false)
|
disabled(false)
|
||||||
description('Bounded daemonless Kaniko release for reviewed Hermes STT or TTS source. A validated immutable release tag is consumed by Flux.')
|
description('Bounded daemonless Kaniko release for reviewed Hermes STT or TTS source. A validated immutable release tag is consumed by Flux.')
|
||||||
|
|||||||
416
testing/tests/test_hermes_chat_router_release.py
Normal file
416
testing/tests/test_hermes_chat_router_release.py
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
"""Exact-source, Harbor, and Flux contracts for the chat-router release lane."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PIPELINE = ROOT / "ci/Jenkinsfile.hermes-chat-router-image"
|
||||||
|
RELEASE = ROOT / "ci/scripts/hermes_chat_router_release.py"
|
||||||
|
PROMOTE = ROOT / "ci/scripts/hermes_oci_promote.py"
|
||||||
|
MANIFEST = ROOT / "services/hermes/chat-router.yaml"
|
||||||
|
IMAGE_POLICY = ROOT / "services/hermes/image.yaml"
|
||||||
|
JENKINS = ROOT / "services/jenkins/configmap-jcasc.yaml"
|
||||||
|
TRIGGER = ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
|
||||||
|
STATUS = ROOT / "services/hermes/scripts/hermes_image_release_status.py"
|
||||||
|
HARBOR_JOB = ROOT / "services/harbor/hermes-chat-router-immutability-job.yaml"
|
||||||
|
HARBOR_POLICY = (
|
||||||
|
ROOT / "services/harbor/scripts/harbor_hermes_chat_router_immutability_ensure.py"
|
||||||
|
)
|
||||||
|
HARBOR_GENERIC = ROOT / "services/harbor/scripts/harbor_immutable_rule_ensure.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
"""Minimal context-managed urllib response."""
|
||||||
|
|
||||||
|
def __init__(self, status: int, body: bytes = b"", headers=None):
|
||||||
|
super().__init__(body)
|
||||||
|
self.status = status
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(tmp_path: Path):
|
||||||
|
module = _load(RELEASE, f"router_release_{tmp_path.name}")
|
||||||
|
digest = "sha256:" + "7" * 64
|
||||||
|
revision = "8" * 40
|
||||||
|
build = "23"
|
||||||
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-{build}"
|
||||||
|
digest_file = tmp_path / "digest"
|
||||||
|
image_file = tmp_path / "image"
|
||||||
|
digest_file.write_text(digest + "\n", encoding="utf-8")
|
||||||
|
image_file.write_text(f"{destination}@{digest}\n", encoding="utf-8")
|
||||||
|
output = tmp_path / "release"
|
||||||
|
kwargs = {
|
||||||
|
"digest": digest,
|
||||||
|
"source_revision": revision,
|
||||||
|
"build_number": build,
|
||||||
|
"destination": destination,
|
||||||
|
"manifest": MANIFEST,
|
||||||
|
"output_dir": output,
|
||||||
|
}
|
||||||
|
module.write_release_artifacts(**kwargs)
|
||||||
|
return module, digest_file, image_file, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_builds_one_exact_reviewed_router_revision() -> None:
|
||||||
|
"""The job detaches the reviewed main ancestor and never mutates Git or K8s."""
|
||||||
|
source = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
assert 'test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES CHAT ROUTER"' in source
|
||||||
|
assert 'test "${main_revision}" = "$(git rev-parse origin/main)"' in source
|
||||||
|
assert 'git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}"' in source
|
||||||
|
assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in source
|
||||||
|
assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in source
|
||||||
|
assert "dockerfiles/Dockerfile.hermes-chat-router" in source
|
||||||
|
assert "GO111MODULE=off CGO_ENABLED=0 go test ./..." in source
|
||||||
|
assert "ci/scripts/hermes_chat_router_release.py assert-absent" in source
|
||||||
|
assert "ci/scripts/hermes_oci_promote.py" in source
|
||||||
|
assert "--digest-file=" in source
|
||||||
|
assert "--image-name-tag-with-digest-file=" in source
|
||||||
|
for label in (
|
||||||
|
"org.opencontainers.image.revision=${source_revision}",
|
||||||
|
"org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac",
|
||||||
|
"org.opencontainers.image.title=hermes-chat-router",
|
||||||
|
):
|
||||||
|
assert label in source
|
||||||
|
for forbidden in ("kubectl ", "flux reconcile", "git push", "git commit"):
|
||||||
|
assert forbidden not in source
|
||||||
|
pod_yaml = source.split('yaml """', 1)[1].split('"""', 1)[0]
|
||||||
|
pod = yaml.safe_load(pod_yaml)["spec"]
|
||||||
|
assert pod["serviceAccountName"] == "hermes-image-builder"
|
||||||
|
assert pod["automountServiceAccountToken"] is False
|
||||||
|
assert pod["enableServiceLinks"] is False
|
||||||
|
containers = {item["name"]: item for item in pod["containers"]}
|
||||||
|
assert set(containers) == {"jnlp", "python", "golang", "kaniko"}
|
||||||
|
assert pod["affinity"]["nodeAffinity"][
|
||||||
|
"requiredDuringSchedulingIgnoredDuringExecution"
|
||||||
|
]["nodeSelectorTerms"][0]["matchExpressions"][0]["values"] == ["titan-20"]
|
||||||
|
assert "docker.sock" not in source and "hostPath" not in source
|
||||||
|
for container in containers.values():
|
||||||
|
security = container["securityContext"]
|
||||||
|
assert security["allowPrivilegeEscalation"] is False
|
||||||
|
assert security["capabilities"]["drop"] == ["ALL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jenkins_job_is_token_guarded_main_only_and_non_concurrent() -> None:
|
||||||
|
"""Only the fixed router job and fixed confirmation can enter the lane."""
|
||||||
|
config = yaml.safe_load(JENKINS.read_text(encoding="utf-8"))
|
||||||
|
jobs = config["data"]["jobs.yaml"]
|
||||||
|
assert jobs.count("pipelineJob('hermes-chat-router-image')") == 1
|
||||||
|
block = jobs.split("pipelineJob('hermes-chat-router-image')", 1)[1].split(
|
||||||
|
"pipelineJob('hermes-voice-image')", 1
|
||||||
|
)[0]
|
||||||
|
assert "branches('*/main')" in block
|
||||||
|
assert "scriptPath('ci/Jenkinsfile.hermes-chat-router-image')" in block
|
||||||
|
assert "authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))" in block
|
||||||
|
assert "PUBLISH HERMES CHAT ROUTER" in block
|
||||||
|
assert "pipelineTriggers" not in block
|
||||||
|
pipeline = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
assert "disableConcurrentBuilds()" in pipeline
|
||||||
|
assert "artifactDaysToKeepStr: '30'" in pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_renders_exactly_one_flux_consumer_and_revalidates(tmp_path: Path) -> None:
|
||||||
|
"""A release handoff contains only the one router Deployment mutation."""
|
||||||
|
module, digest_file, image_file, kwargs = _fixture(tmp_path)
|
||||||
|
output = kwargs["output_dir"]
|
||||||
|
patch = (output / "hermes-chat-router-image-update.patch").read_text()
|
||||||
|
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{kwargs['digest']}") == 1
|
||||||
|
assert "services/hermes/chat-router.yaml" in patch
|
||||||
|
assert "chat-statefulset.yaml" not in patch
|
||||||
|
metadata = json.loads((output / "hermes-chat-router-image.json").read_text())
|
||||||
|
assert metadata["source_revision"] == kwargs["source_revision"]
|
||||||
|
assert metadata["flux_targets"] == [
|
||||||
|
"apps/Deployment/hermes/hermes-chat-router"
|
||||||
|
]
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
assert module.validate_kaniko_evidence(
|
||||||
|
digest_text=digest_file.read_text(),
|
||||||
|
image_text=image_file.read_text(),
|
||||||
|
destination=kwargs["destination"],
|
||||||
|
) == kwargs["digest"]
|
||||||
|
(output / "extra").write_text("no\n")
|
||||||
|
with pytest.raises(ValueError, match="exactly three"):
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"source",
|
||||||
|
[
|
||||||
|
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: wrong\n",
|
||||||
|
"# services/hermes/chat-router.yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes-chat-router\n",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_renderer_fails_closed_on_target_drift(source: str) -> None:
|
||||||
|
"""Identity or consumer-count drift cannot silently broaden promotion."""
|
||||||
|
module = _load(RELEASE, f"router_drift_{len(source)}")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
module.render_workload(source, "sha256:" + "a" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_harbor_candidate_evidence_and_policy_are_exact() -> None:
|
||||||
|
"""Candidate acceptance requires an immutable tag, digest, and OCI revision."""
|
||||||
|
module = _load(RELEASE, "router_registry_contract")
|
||||||
|
revision = "a" * 40
|
||||||
|
digest = "sha256:" + "b" * 64
|
||||||
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-9"
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def artifact_open(request, timeout):
|
||||||
|
seen.append((request, timeout))
|
||||||
|
return Response(
|
||||||
|
200,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"digest": digest,
|
||||||
|
"tags": [
|
||||||
|
{"name": f"git-{revision}-build-9", "immutable": True}
|
||||||
|
],
|
||||||
|
"extra_attrs": {
|
||||||
|
"config": {
|
||||||
|
"Labels": {
|
||||||
|
"org.opencontainers.image.revision": revision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
module.verify_registry_digest(
|
||||||
|
destination,
|
||||||
|
digest,
|
||||||
|
revision,
|
||||||
|
username="robot",
|
||||||
|
password="private",
|
||||||
|
opener=artifact_open,
|
||||||
|
)
|
||||||
|
assert "/repositories/hermes-chat-router/artifacts/" in seen[0][0].full_url
|
||||||
|
assert seen[0][0].get_header("Authorization").startswith("Basic ")
|
||||||
|
|
||||||
|
def rule_open(_request, _timeout):
|
||||||
|
expected = {
|
||||||
|
"disabled": False,
|
||||||
|
"action": "immutable",
|
||||||
|
"template": "immutable_template",
|
||||||
|
"tag_selectors": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "matches",
|
||||||
|
"pattern": "git-*-build-*",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{
|
||||||
|
"kind": "doublestar",
|
||||||
|
"decoration": "repoMatches",
|
||||||
|
"pattern": "hermes-chat-router",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
200, json.dumps([expected]).encode(), {"X-Total-Count": "1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
module.verify_immutable_policy(
|
||||||
|
username="robot", password="private", opener=rule_open
|
||||||
|
)
|
||||||
|
assert module.assert_tag_absent(
|
||||||
|
destination,
|
||||||
|
username="robot",
|
||||||
|
password="private",
|
||||||
|
opener=lambda *_args: Response(404),
|
||||||
|
) is None
|
||||||
|
with pytest.raises(RuntimeError, match="already exists"):
|
||||||
|
module.assert_tag_absent(
|
||||||
|
destination,
|
||||||
|
username="robot",
|
||||||
|
password="private",
|
||||||
|
opener=lambda *_args: Response(200),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_harbor_policy_is_flux_tracked_and_vault_injected() -> None:
|
||||||
|
"""The preflight rule is desired state and no credential is stored in Git."""
|
||||||
|
app = yaml.safe_load(
|
||||||
|
(ROOT / "clusters/atlas/flux-system/applications/harbor/kustomization.yaml")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
"apiVersion": "batch/v1",
|
||||||
|
"kind": "Job",
|
||||||
|
"name": "harbor-hermes-chat-router-immutability-ensure-1",
|
||||||
|
"namespace": "harbor",
|
||||||
|
} in app["spec"]["healthChecks"]
|
||||||
|
_load(HARBOR_GENERIC, "harbor_immutable_rule_ensure")
|
||||||
|
policy = _load(HARBOR_POLICY, "router_harbor_policy")
|
||||||
|
assert policy.REPOSITORY_PATTERN == "hermes-chat-router"
|
||||||
|
assert policy.TAG_PATTERN == "git-*-build-*"
|
||||||
|
job = yaml.safe_load(HARBOR_JOB.read_text(encoding="utf-8"))
|
||||||
|
template = job["spec"]["template"]
|
||||||
|
annotations = template["metadata"]["annotations"]
|
||||||
|
assert annotations["vault.hashicorp.com/role"] == "harbor-policy-bootstrap"
|
||||||
|
assert annotations[
|
||||||
|
"vault.hashicorp.com/agent-inject-secret-harbor-admin-password"
|
||||||
|
] == "kv/data/atlas/harbor/harbor-core"
|
||||||
|
container = template["spec"]["containers"][0]
|
||||||
|
assert "@sha256:" in container["image"]
|
||||||
|
assert container["securityContext"]["readOnlyRootFilesystem"] is True
|
||||||
|
assert container["securityContext"]["runAsNonRoot"] is True
|
||||||
|
source = HARBOR_POLICY.read_text().lower() + HARBOR_GENERIC.read_text().lower()
|
||||||
|
assert "private-token" not in source and "password123" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_harbor_policy_helper_creates_once_and_rejects_weakened_rule() -> None:
|
||||||
|
"""Creation is verified by ID; a disabled matching rule fails closed."""
|
||||||
|
module = _load(HARBOR_GENERIC, "router_harbor_helper")
|
||||||
|
expected = module.expected_rule("hermes-chat-router", "git-*-build-*")
|
||||||
|
created = {**expected, "id": 19}
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
origin = module.EXPECTED_ORIGIN
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def request(self, method, path, payload=None):
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls == 1:
|
||||||
|
return 200, b"[]", {"X-Total-Count": "0"}
|
||||||
|
if self.calls == 2:
|
||||||
|
assert method == "POST" and payload == expected
|
||||||
|
return 201, b"", {"Location": "/api/v2.0" + path + "/19"}
|
||||||
|
return 200, json.dumps([created]).encode(), {"X-Total-Count": "1"}
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
assert module.ensure_rule(
|
||||||
|
client,
|
||||||
|
project="bstein",
|
||||||
|
repository="hermes-chat-router",
|
||||||
|
tag_pattern="git-*-build-*",
|
||||||
|
) == 19
|
||||||
|
weakened = {**created, "disabled": True}
|
||||||
|
|
||||||
|
class Weakened:
|
||||||
|
origin = module.EXPECTED_ORIGIN
|
||||||
|
|
||||||
|
def request(self, *_args, **_kwargs):
|
||||||
|
return 200, json.dumps([weakened]).encode(), {"X-Total-Count": "1"}
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="not enabled and exact"):
|
||||||
|
module.ensure_rule(
|
||||||
|
Weakened(),
|
||||||
|
project="bstein",
|
||||||
|
repository="hermes-chat-router",
|
||||||
|
tag_pattern="git-*-build-*",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flux_policy_and_marker_select_only_router_releases() -> None:
|
||||||
|
"""Candidates remain invisible until the exact release suffix exists."""
|
||||||
|
documents = list(yaml.safe_load_all(IMAGE_POLICY.read_text(encoding="utf-8")))
|
||||||
|
repository = next(
|
||||||
|
item
|
||||||
|
for item in documents
|
||||||
|
if item["kind"] == "ImageRepository"
|
||||||
|
and item["metadata"]["name"] == "hermes-chat-router-release"
|
||||||
|
)
|
||||||
|
policy = next(
|
||||||
|
item
|
||||||
|
for item in documents
|
||||||
|
if item["kind"] == "ImagePolicy"
|
||||||
|
and item["metadata"]["name"] == "hermes-chat-router-release"
|
||||||
|
)
|
||||||
|
assert repository["spec"]["image"] == (
|
||||||
|
"registry.bstein.dev/bstein/hermes-chat-router"
|
||||||
|
)
|
||||||
|
assert policy["spec"]["filterTags"]["pattern"].endswith("-release$")
|
||||||
|
assert policy["spec"]["digestReflectionPolicy"] == "Always"
|
||||||
|
marker = '"$imagepolicy": "hermes:hermes-chat-router-release"'
|
||||||
|
assert MANIFEST.read_text(encoding="utf-8").count(marker) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_trigger_and_status_support_only_fixed_router_contract(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Hermes can request and follow router delivery without choosing a job."""
|
||||||
|
trigger = _load(TRIGGER, "router_release_trigger")
|
||||||
|
token = tmp_path / "token"
|
||||||
|
token.write_text("private-token\n", encoding="utf-8")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def opener(request, timeout):
|
||||||
|
captured["request"] = request
|
||||||
|
assert timeout == 20
|
||||||
|
return Response(
|
||||||
|
201, headers={"Location": "https://ci.bstein.dev/queue/item/42/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
revision = "c" * 40
|
||||||
|
result = trigger.trigger_build(
|
||||||
|
revision, component="router", token_file=token, opener=opener
|
||||||
|
)
|
||||||
|
fields = urllib.parse.parse_qs(captured["request"].data.decode())
|
||||||
|
assert fields["job"] == ["hermes-chat-router-image"]
|
||||||
|
assert fields["CONFIRM_PUBLISH"] == ["PUBLISH HERMES CHAT ROUTER"]
|
||||||
|
assert "private-token" not in json.dumps(result)
|
||||||
|
assert result["follow_command"].endswith(
|
||||||
|
f"--component router --revision {revision} --wait"
|
||||||
|
)
|
||||||
|
status = _load(STATUS, "router_release_status")
|
||||||
|
assert status.COMPONENTS["router"] == {
|
||||||
|
"policy": "hermes-chat-router-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-chat-router",
|
||||||
|
"workloads": (
|
||||||
|
("deployment", "hermes-chat-router", "hermes-chat-router"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_promoter_accepts_router_and_rejects_other_repositories() -> None:
|
||||||
|
"""The manifest copier allow-list adds only chat-router."""
|
||||||
|
module = _load(PROMOTE, "router_promote_contract")
|
||||||
|
revision = "d" * 40
|
||||||
|
digest = "sha256:" + "e" * 64
|
||||||
|
destination = (
|
||||||
|
"registry.bstein.dev/bstein/hermes-chat-router:"
|
||||||
|
f"git-{revision}-build-7"
|
||||||
|
)
|
||||||
|
component, tag, observed = module._validated_release(
|
||||||
|
destination, digest, revision, "7"
|
||||||
|
)
|
||||||
|
assert component == "hermes-chat-router"
|
||||||
|
assert tag == f"git-{revision}-build-7"
|
||||||
|
assert observed == digest
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
module._validated_release(
|
||||||
|
"registry.bstein.dev/bstein/not-router:git-" + revision + "-build-7",
|
||||||
|
digest,
|
||||||
|
revision,
|
||||||
|
"7",
|
||||||
|
)
|
||||||
@ -29,6 +29,7 @@ def test_image_policies_observe_only_validated_release_tags() -> None:
|
|||||||
}
|
}
|
||||||
assert set(repositories) == {
|
assert set(repositories) == {
|
||||||
"hermes-agent-release",
|
"hermes-agent-release",
|
||||||
|
"hermes-chat-router-release",
|
||||||
"hermes-webui-release",
|
"hermes-webui-release",
|
||||||
"hermes-stt-release",
|
"hermes-stt-release",
|
||||||
"hermes-tts-release",
|
"hermes-tts-release",
|
||||||
@ -58,6 +59,7 @@ def test_flux_updates_only_the_reviewed_hermes_image_digests() -> None:
|
|||||||
chat = (SERVICE / "chat-statefulset.yaml").read_text(encoding="utf-8")
|
chat = (SERVICE / "chat-statefulset.yaml").read_text(encoding="utf-8")
|
||||||
dashboard = (SERVICE / "deployment.yaml").read_text(encoding="utf-8")
|
dashboard = (SERVICE / "deployment.yaml").read_text(encoding="utf-8")
|
||||||
voice = (SERVICE / "voice-deployment.yaml").read_text(encoding="utf-8")
|
voice = (SERVICE / "voice-deployment.yaml").read_text(encoding="utf-8")
|
||||||
|
router = (SERVICE / "chat-router.yaml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert " - image.yaml" in service_kustomization
|
assert " - image.yaml" in service_kustomization
|
||||||
assert " - hermes/image-automation.yaml" in applications_kustomization
|
assert " - hermes/image-automation.yaml" in applications_kustomization
|
||||||
@ -69,7 +71,7 @@ def test_flux_updates_only_the_reviewed_hermes_image_digests() -> None:
|
|||||||
}
|
}
|
||||||
assert agent.count('"$imagepolicy": "hermes:hermes-agent-release:digest"') == 1
|
assert agent.count('"$imagepolicy": "hermes:hermes-agent-release:digest"') == 1
|
||||||
webui_marker = '"$imagepolicy": "hermes:hermes-webui-release"'
|
webui_marker = '"$imagepolicy": "hermes:hermes-webui-release"'
|
||||||
assert chat.count(webui_marker) == 1
|
assert chat.count(webui_marker) == 2
|
||||||
assert dashboard.count(webui_marker) == 1
|
assert dashboard.count(webui_marker) == 1
|
||||||
# A digest-only setter replaces the complete YAML scalar with ``sha256:...``.
|
# A digest-only setter replaces the complete YAML scalar with ``sha256:...``.
|
||||||
# Whole-image setters must retain the registry and repository in pod specs.
|
# Whole-image setters must retain the registry and repository in pod specs.
|
||||||
@ -85,3 +87,8 @@ def test_flux_updates_only_the_reviewed_hermes_image_digests() -> None:
|
|||||||
marked_line = next(line for line in voice.splitlines() if marker in line)
|
marked_line = next(line for line in voice.splitlines() if marker in line)
|
||||||
assert f"registry.bstein.dev/bstein/hermes-jetson-{component}" in marked_line
|
assert f"registry.bstein.dev/bstein/hermes-jetson-{component}" in marked_line
|
||||||
assert "@sha256:" in marked_line
|
assert "@sha256:" in marked_line
|
||||||
|
router_marker = '"$imagepolicy": "hermes:hermes-chat-router-release"'
|
||||||
|
assert router.count(router_marker) == 1
|
||||||
|
marked_line = next(line for line in router.splitlines() if router_marker in line)
|
||||||
|
assert "registry.bstein.dev/bstein/hermes-chat-router" in marked_line
|
||||||
|
assert "@sha256:" in marked_line
|
||||||
|
|||||||
@ -273,7 +273,7 @@ def test_agent_trigger_can_select_only_the_bounded_webui_job(tmp_path: Path) ->
|
|||||||
assert fields["job"] == ["hermes-webui-image"]
|
assert fields["job"] == ["hermes-webui-image"]
|
||||||
assert fields["CONFIRM_PUBLISH"] == ["PUBLISH HERMES WEBUI"]
|
assert fields["CONFIRM_PUBLISH"] == ["PUBLISH HERMES WEBUI"]
|
||||||
assert result["component"] == "webui"
|
assert result["component"] == "webui"
|
||||||
with pytest.raises(ValueError, match="agent, webui, stt, or tts"):
|
with pytest.raises(ValueError, match="agent, router, webui, stt, or tts"):
|
||||||
module.trigger_build(
|
module.trigger_build(
|
||||||
"b" * 40, component="other", token_file=token_path, opener=opener
|
"b" * 40, component="other", token_file=token_path, opener=opener
|
||||||
)
|
)
|
||||||
|
|||||||
@ -138,7 +138,7 @@ def _install_fake(module, monkeypatch: pytest.MonkeyPatch, objects: dict) -> Non
|
|||||||
monkeypatch.setattr(module, "_kubectl_json", fake)
|
monkeypatch.setattr(module, "_kubectl_json", fake)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("component", ["agent", "webui", "stt", "tts"])
|
@pytest.mark.parametrize("component", ["agent", "router", "webui", "stt", "tts"])
|
||||||
def test_exact_release_convergence_covers_every_component(
|
def test_exact_release_convergence_covers_every_component(
|
||||||
monkeypatch: pytest.MonkeyPatch, component: str
|
monkeypatch: pytest.MonkeyPatch, component: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user