release(hermes): automate private voice images
This commit is contained in:
parent
df18acbfa7
commit
79369c2357
212
ci/Jenkinsfile.hermes-voice-image
Normal file
212
ci/Jenkinsfile.hermes-voice-image
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
kubernetes {
|
||||||
|
defaultContainer 'python'
|
||||||
|
yaml """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
atlas.bstein.dev/workload: hermes-voice-image-builder
|
||||||
|
spec:
|
||||||
|
serviceAccountName: hermes-image-builder
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
nodeSelector:
|
||||||
|
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-22, titan-24]
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- weight: 100
|
||||||
|
preference:
|
||||||
|
matchExpressions:
|
||||||
|
- key: hardware
|
||||||
|
operator: In
|
||||||
|
values: [rpi5]
|
||||||
|
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: 256Mi}
|
||||||
|
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: 500m, memory: 512Mi}
|
||||||
|
- 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"]
|
||||||
|
runAsUser: 0
|
||||||
|
seccompProfile: {type: RuntimeDefault}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 250m, memory: 1Gi}
|
||||||
|
limits: {cpu: "2", memory: 4Gi}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parameters {
|
||||||
|
booleanParam(name: 'PUBLISH_IMAGE', defaultValue: false, description: 'Publish the reviewed voice image to Harbor.')
|
||||||
|
choice(name: 'IMAGE_COMPONENT', choices: ['stt', 'tts'], description: 'Private voice component to build.')
|
||||||
|
string(name: 'EXPECTED_SOURCE_REVISION', defaultValue: '', description: 'Full reviewed commit contained by main.')
|
||||||
|
string(name: 'CONFIRM_PUBLISH', defaultValue: '', description: 'Exact component-specific confirmation.')
|
||||||
|
}
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
|
||||||
|
skipDefaultCheckout(true)
|
||||||
|
timeout(time: 150, 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"
|
||||||
|
case "${IMAGE_COMPONENT}" in
|
||||||
|
stt) expected_confirmation='PUBLISH HERMES STT' ;;
|
||||||
|
tts) expected_confirmation='PUBLISH HERMES TTS' ;;
|
||||||
|
*) echo 'IMAGE_COMPONENT must be stt or tts' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
test "${CONFIRM_PUBLISH}" = "${expected_confirmation}"
|
||||||
|
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
|
||||||
|
actual_revision="$(git rev-parse HEAD)"
|
||||||
|
test "${actual_revision}" = "$(git rev-parse origin/main)"
|
||||||
|
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}"
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
case "${BUILD_NUMBER}" in ''|0*|*[!0-9]*) exit 2 ;; esac
|
||||||
|
image="registry.bstein.dev/bstein/hermes-jetson-${IMAGE_COMPONENT}"
|
||||||
|
printf '%s\n' "${image}:git-${actual_revision}-build-${BUILD_NUMBER}" > build/hermes-voice.destination
|
||||||
|
printf '%s\n' "${actual_revision}" > build/hermes-voice.source-revision
|
||||||
|
printf '%s\n' "${IMAGE_COMPONENT}" > build/hermes-voice.component
|
||||||
|
test -f "dockerfiles/Dockerfile.hermes-jetson-${IMAGE_COMPONENT}"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Validate reviewed voice source') {
|
||||||
|
steps {
|
||||||
|
container('python') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
||||||
|
--target=/tmp/hermes-voice-test-deps pytest==8.3.4 PyYAML==6.0.2
|
||||||
|
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
|
||||||
|
testing/tests/test_hermes_tts_language_routing.py \
|
||||||
|
testing/tests/test_hermes_voice_language_routing.py \
|
||||||
|
testing/tests/test_hermes_oci_promote.py \
|
||||||
|
testing/tests/test_hermes_image_automation.py \
|
||||||
|
testing/tests/test_hermes_voice_release.py
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
component="$(cat build/hermes-voice.component)"
|
||||||
|
destination="$(cat build/hermes-voice.destination)"
|
||||||
|
source_revision="$(cat build/hermes-voice.source-revision)"
|
||||||
|
config_path=/kaniko/.docker/config.json
|
||||||
|
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-jetson-${component}" \
|
||||||
|
--destination="${destination}" \
|
||||||
|
--digest-file="${WORKSPACE}/build/hermes-voice.digest" \
|
||||||
|
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-voice.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-jetson-${component}" \
|
||||||
|
--cleanup --push-retry=3
|
||||||
|
/busybox/chmod 644 build/hermes-voice.digest build/hermes-voice.image
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Verify, archive, and publish Flux release') {
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(credentialsId: 'harbor-robot', usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
destination="$(cat build/hermes-voice.destination)"
|
||||||
|
source_revision="$(cat build/hermes-voice.source-revision)"
|
||||||
|
component="$(cat build/hermes-voice.component)"
|
||||||
|
digest="$(cat build/hermes-voice.digest)"
|
||||||
|
image="$(cat build/hermes-voice.image)"
|
||||||
|
test "${image}" = "${destination}@${digest}"
|
||||||
|
python3 ci/scripts/hermes_oci_promote.py \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--digest-file build/hermes-voice.digest \
|
||||||
|
--source-revision "${source_revision}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
> build/hermes-voice.promotion.json
|
||||||
|
python3 -c 'import json; data=json.load(open("build/hermes-voice.promotion.json")); assert data["result"] in {"published", "already-present"}'
|
||||||
|
'''
|
||||||
|
archiveArtifacts(
|
||||||
|
artifacts: 'build/hermes-voice.component,build/hermes-voice.destination,build/hermes-voice.digest,build/hermes-voice.image,build/hermes-voice.source-revision,build/hermes-voice.promotion.json',
|
||||||
|
allowEmptyArchive: false,
|
||||||
|
fingerprint: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,8 @@ 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/(?P<component>hermes-(?:agent|webui)):"
|
r"^registry\.bstein\.dev/bstein/"
|
||||||
|
r"(?P<component>hermes-(?:agent|webui|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}$")
|
||||||
|
|||||||
@ -371,8 +371,9 @@ data:
|
|||||||
|
|
||||||
For an explicitly requested Hermes runtime release, trigger only the
|
For an explicitly requested Hermes runtime release, trigger only the
|
||||||
reviewed image lanes with `/opt/coordinator/jenkins_image_build_trigger.py`.
|
reviewed image lanes with `/opt/coordinator/jenkins_image_build_trigger.py`.
|
||||||
Use `--component agent` for backend/runtime changes and `--component webui`
|
Use `--component agent` for backend/runtime changes, `--component webui`
|
||||||
for chat UI changes, passing a full commit already contained by `main`.
|
for chat UI changes, and `--component stt` or `--component tts` for the
|
||||||
|
private voice services, passing a full commit already contained by `main`.
|
||||||
Jenkins builds the newest main containing that commit, publishes a final
|
Jenkins builds the newest main containing that commit, publishes a final
|
||||||
immutable release tag only after its evidence passes, and Flux applies the
|
immutable release tag only after its evidence passes, and Flux applies the
|
||||||
resulting digest. Candidate tags and failed builds never deploy. Follow the
|
resulting digest. Candidate tags and failed builds never deploy. Follow the
|
||||||
|
|||||||
@ -50,3 +50,55 @@ spec:
|
|||||||
order: asc
|
order: asc
|
||||||
digestReflectionPolicy: Always
|
digestReflectionPolicy: Always
|
||||||
interval: 1m0s
|
interval: 1m0s
|
||||||
|
---
|
||||||
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
|
kind: ImageRepository
|
||||||
|
metadata:
|
||||||
|
name: hermes-stt-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
image: registry.bstein.dev/bstein/hermes-jetson-stt
|
||||||
|
interval: 1m0s
|
||||||
|
---
|
||||||
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
|
kind: ImagePolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-stt-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
imageRepositoryRef:
|
||||||
|
name: hermes-stt-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:
|
||||||
|
name: hermes-tts-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
image: registry.bstein.dev/bstein/hermes-jetson-tts
|
||||||
|
interval: 1m0s
|
||||||
|
---
|
||||||
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
|
kind: ImagePolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-tts-release
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
imageRepositoryRef:
|
||||||
|
name: hermes-tts-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
|
||||||
|
|||||||
@ -23,6 +23,16 @@ JOBS = {
|
|||||||
"job": "hermes-webui-image",
|
"job": "hermes-webui-image",
|
||||||
"confirmation": "PUBLISH HERMES WEBUI",
|
"confirmation": "PUBLISH HERMES WEBUI",
|
||||||
},
|
},
|
||||||
|
"stt": {
|
||||||
|
"job": "hermes-voice-image",
|
||||||
|
"confirmation": "PUBLISH HERMES STT",
|
||||||
|
"parameters": {"IMAGE_COMPONENT": "stt"},
|
||||||
|
},
|
||||||
|
"tts": {
|
||||||
|
"job": "hermes-voice-image",
|
||||||
|
"confirmation": "PUBLISH HERMES TTS",
|
||||||
|
"parameters": {"IMAGE_COMPONENT": "tts"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
JOB_NAME = JOBS["agent"]["job"]
|
JOB_NAME = JOBS["agent"]["job"]
|
||||||
TOKEN_FILE = Path("/runtime-access/jenkins-image-build-token")
|
TOKEN_FILE = Path("/runtime-access/jenkins-image-build-token")
|
||||||
@ -61,19 +71,19 @@ 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 or webui")
|
raise ValueError("component must be agent, 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")
|
||||||
payload = urllib.parse.urlencode(
|
fields = {
|
||||||
{
|
"job": job["job"],
|
||||||
"job": job["job"],
|
"token": token,
|
||||||
"token": token,
|
"PUBLISH_IMAGE": "true",
|
||||||
"PUBLISH_IMAGE": "true",
|
"EXPECTED_SOURCE_REVISION": revision,
|
||||||
"EXPECTED_SOURCE_REVISION": revision,
|
"CONFIRM_PUBLISH": job["confirmation"],
|
||||||
"CONFIRM_PUBLISH": job["confirmation"],
|
}
|
||||||
}
|
fields.update(job.get("parameters", {}))
|
||||||
).encode("utf-8")
|
payload = urllib.parse.urlencode(fields).encode("utf-8")
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
JENKINS_BUILD_URL,
|
JENKINS_BUILD_URL,
|
||||||
data=payload,
|
data=payload,
|
||||||
|
|||||||
@ -31,7 +31,7 @@ spec:
|
|||||||
kubernetes.io/hostname: titan-21
|
kubernetes.io/hostname: titan-21
|
||||||
containers:
|
containers:
|
||||||
- name: stt
|
- name: stt
|
||||||
image: registry.bstein.dev/bstein/hermes-jetson-stt@sha256:dfb0b0788dcf63747d8c761c6d3ea6a7459bcd8d0a5c92bcd4f6f434a760249f
|
image: registry.bstein.dev/bstein/hermes-jetson-stt@sha256:dfb0b0788dcf63747d8c761c6d3ea6a7459bcd8d0a5c92bcd4f6f434a760249f # {"$imagepolicy": "hermes:hermes-stt-release"}
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- {name: http, containerPort: 9000, protocol: TCP}
|
- {name: http, containerPort: 9000, protocol: TCP}
|
||||||
@ -114,7 +114,7 @@ spec:
|
|||||||
app: hermes-tts
|
app: hermes-tts
|
||||||
annotations:
|
annotations:
|
||||||
ai.bstein.dev/role: private-chat-text-to-speech
|
ai.bstein.dev/role: private-chat-text-to-speech
|
||||||
ai.bstein.dev/model: piper-en-us-lessac-medium
|
ai.bstein.dev/model: piper-multilingual-en-ru-es
|
||||||
ai.bstein.dev/gpu: CPU-only beside Whisper on the voice node
|
ai.bstein.dev/gpu: CPU-only beside Whisper on the voice node
|
||||||
spec:
|
spec:
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
@ -123,7 +123,7 @@ spec:
|
|||||||
kubernetes.io/hostname: titan-21
|
kubernetes.io/hostname: titan-21
|
||||||
containers:
|
containers:
|
||||||
- name: tts
|
- name: tts
|
||||||
image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:5cb9e57faab46365bff606c559af57b9505892be2909aea1ac523568d478b2cc
|
image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:5cb9e57faab46365bff606c559af57b9505892be2909aea1ac523568d478b2cc # {"$imagepolicy": "hermes:hermes-tts-release"}
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- {name: http, containerPort: 9001, protocol: TCP}
|
- {name: http, containerPort: 9001, protocol: TCP}
|
||||||
@ -131,7 +131,6 @@ spec:
|
|||||||
- {name: HOME, value: /tmp}
|
- {name: HOME, value: /tmp}
|
||||||
- {name: XDG_CACHE_HOME, value: /tmp/cache}
|
- {name: XDG_CACHE_HOME, value: /tmp/cache}
|
||||||
- {name: HERMES_TTS_PORT, value: "9001"}
|
- {name: HERMES_TTS_PORT, value: "9001"}
|
||||||
- {name: HERMES_TTS_VOICE, value: en_US-lessac-medium}
|
|
||||||
- {name: HERMES_TTS_CACHE, value: /opt/models/piper}
|
- {name: HERMES_TTS_CACHE, value: /opt/models/piper}
|
||||||
- {name: HERMES_TTS_ONNX_THREADS, value: "2"}
|
- {name: HERMES_TTS_ONNX_THREADS, value: "2"}
|
||||||
startupProbe:
|
startupProbe:
|
||||||
|
|||||||
@ -700,6 +700,31 @@ data:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pipelineJob('hermes-voice-image') {
|
||||||
|
disabled(false)
|
||||||
|
description('Bounded daemonless Kaniko release for reviewed Hermes STT or TTS source. A validated immutable release tag is consumed by Flux.')
|
||||||
|
authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))
|
||||||
|
parameters {
|
||||||
|
booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes voice image.')
|
||||||
|
stringParam('IMAGE_COMPONENT', '', 'Exact component: stt or tts.')
|
||||||
|
stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit that must be contained by atlas/titan-iac main.')
|
||||||
|
stringParam('CONFIRM_PUBLISH', '', 'Exact component-specific confirmation.')
|
||||||
|
}
|
||||||
|
definition {
|
||||||
|
cpsScm {
|
||||||
|
scm {
|
||||||
|
git {
|
||||||
|
remote {
|
||||||
|
url('https://scm.bstein.dev/atlas/titan-iac.git')
|
||||||
|
credentials('gitea-pat')
|
||||||
|
}
|
||||||
|
branches('*/main')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scriptPath('ci/Jenkinsfile.hermes-voice-image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
multibranchPipelineJob('titan-iac-quality-gate') {
|
multibranchPipelineJob('titan-iac-quality-gate') {
|
||||||
branchSources {
|
branchSources {
|
||||||
branchSource {
|
branchSource {
|
||||||
|
|||||||
@ -463,7 +463,9 @@ def test_voice_workloads_have_deliberate_xavier_placement():
|
|||||||
tts_env = {
|
tts_env = {
|
||||||
item["name"]: item["value"] for item in tts["containers"][0]["env"]
|
item["name"]: item["value"] for item in tts["containers"][0]["env"]
|
||||||
}
|
}
|
||||||
assert tts_env["HERMES_TTS_VOICE"] == "en_US-lessac-medium"
|
# Keep the deployment neutral so the old and new immutable images can
|
||||||
|
# cross one Flux rollout safely; each image owns its compatible default.
|
||||||
|
assert "HERMES_TTS_VOICE" not in tts_env
|
||||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
||||||
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
||||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||||
|
|||||||
@ -27,7 +27,12 @@ def test_image_policies_observe_only_validated_release_tags() -> None:
|
|||||||
for item in documents
|
for item in documents
|
||||||
if item["kind"] == "ImagePolicy"
|
if item["kind"] == "ImagePolicy"
|
||||||
}
|
}
|
||||||
assert set(repositories) == {"hermes-agent-release", "hermes-webui-release"}
|
assert set(repositories) == {
|
||||||
|
"hermes-agent-release",
|
||||||
|
"hermes-webui-release",
|
||||||
|
"hermes-stt-release",
|
||||||
|
"hermes-tts-release",
|
||||||
|
}
|
||||||
assert set(policies) == set(repositories)
|
assert set(policies) == set(repositories)
|
||||||
for name, policy in policies.items():
|
for name, policy in policies.items():
|
||||||
assert policy["metadata"]["namespace"] == "hermes"
|
assert policy["metadata"]["namespace"] == "hermes"
|
||||||
@ -40,7 +45,7 @@ def test_image_policies_observe_only_validated_release_tags() -> None:
|
|||||||
assert policy["spec"]["digestReflectionPolicy"] == "Always"
|
assert policy["spec"]["digestReflectionPolicy"] == "Always"
|
||||||
|
|
||||||
|
|
||||||
def test_flux_updates_only_the_three_hermes_image_digests() -> None:
|
def test_flux_updates_only_the_reviewed_hermes_image_digests() -> None:
|
||||||
"""Flux persists selected digests to Git and rolls all matching workloads."""
|
"""Flux persists selected digests to Git and rolls all matching workloads."""
|
||||||
service_kustomization = (SERVICE / "kustomization.yaml").read_text(encoding="utf-8")
|
service_kustomization = (SERVICE / "kustomization.yaml").read_text(encoding="utf-8")
|
||||||
applications_kustomization = (APPLICATIONS / "kustomization.yaml").read_text(
|
applications_kustomization = (APPLICATIONS / "kustomization.yaml").read_text(
|
||||||
@ -52,6 +57,7 @@ def test_flux_updates_only_the_three_hermes_image_digests() -> None:
|
|||||||
agent = (SERVICE / "kustomization.yaml").read_text(encoding="utf-8")
|
agent = (SERVICE / "kustomization.yaml").read_text(encoding="utf-8")
|
||||||
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")
|
||||||
|
|
||||||
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
|
||||||
@ -73,3 +79,8 @@ def test_flux_updates_only_the_three_hermes_image_digests() -> None:
|
|||||||
)
|
)
|
||||||
assert "registry.bstein.dev/bstein/hermes-webui:" in marked_line
|
assert "registry.bstein.dev/bstein/hermes-webui:" in marked_line
|
||||||
assert "@sha256:" in marked_line
|
assert "@sha256:" in marked_line
|
||||||
|
for component in ("stt", "tts"):
|
||||||
|
marker = f'"$imagepolicy": "hermes:hermes-{component}-release"'
|
||||||
|
assert voice.count(marker) == 1
|
||||||
|
marked_line = next(line for line in voice.splitlines() if marker in line)
|
||||||
|
assert f"registry.bstein.dev/bstein/hermes-jetson-{component}@sha256:" in marked_line
|
||||||
|
|||||||
@ -271,12 +271,50 @@ 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 or webui"):
|
with pytest.raises(ValueError, match="agent, 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("component", "confirmation"),
|
||||||
|
[("stt", "PUBLISH HERMES STT"), ("tts", "PUBLISH HERMES TTS")],
|
||||||
|
)
|
||||||
|
def test_agent_trigger_binds_private_voice_component(
|
||||||
|
tmp_path: Path, component: str, confirmation: str
|
||||||
|
) -> None:
|
||||||
|
"""Voice releases select one fixed Jenkins job and component parameter."""
|
||||||
|
module = _load_trigger_module()
|
||||||
|
token_path = tmp_path / "token"
|
||||||
|
token_path.write_text("private-job-token\n", encoding="utf-8")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
status = 201
|
||||||
|
headers = {"Location": "https://ci.bstein.dev/queue/item/85/"}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def opener(request, timeout):
|
||||||
|
assert timeout == 20
|
||||||
|
captured["request"] = request
|
||||||
|
return Response(b"")
|
||||||
|
|
||||||
|
result = module.trigger_build(
|
||||||
|
"c" * 40, component=component, token_file=token_path, opener=opener
|
||||||
|
)
|
||||||
|
fields = urllib.parse.parse_qs(captured["request"].data.decode("utf-8"))
|
||||||
|
assert fields["job"] == ["hermes-voice-image"]
|
||||||
|
assert fields["IMAGE_COMPONENT"] == [component]
|
||||||
|
assert fields["CONFIRM_PUBLISH"] == [confirmation]
|
||||||
|
assert result["component"] == component
|
||||||
|
|
||||||
|
|
||||||
def test_agent_trigger_accepts_existing_queue_redirect(tmp_path: Path) -> None:
|
def test_agent_trigger_accepts_existing_queue_redirect(tmp_path: Path) -> None:
|
||||||
"""HTTP 303 means the exact release is already queued, not a trigger failure."""
|
"""HTTP 303 means the exact release is already queued, not a trigger failure."""
|
||||||
module = _load_trigger_module()
|
module = _load_trigger_module()
|
||||||
|
|||||||
@ -105,6 +105,13 @@ def test_default_voice_name_matches_the_dockerfile_env_default(tts):
|
|||||||
assert tts.DEFAULT_VOICE_NAME == AMY
|
assert tts.DEFAULT_VOICE_NAME == AMY
|
||||||
|
|
||||||
|
|
||||||
|
def test_deployment_does_not_override_the_image_default() -> None:
|
||||||
|
"""An old single-voice image and the new image can cross the Flux transition."""
|
||||||
|
manifest = (ROOT / "services/hermes/voice-deployment.yaml").read_text()
|
||||||
|
assert "HERMES_TTS_VOICE" not in manifest
|
||||||
|
assert "piper-multilingual-en-ru-es" in manifest
|
||||||
|
|
||||||
|
|
||||||
def test_resolved_voice_is_always_one_of_the_three_baked_names(tts):
|
def test_resolved_voice_is_always_one_of_the_three_baked_names(tts):
|
||||||
assert frozenset({AMY, IRINA, CLAUDE}) == tts.BAKED_VOICE_NAMES
|
assert frozenset({AMY, IRINA, CLAUDE}) == tts.BAKED_VOICE_NAMES
|
||||||
fuzz_inputs = [
|
fuzz_inputs = [
|
||||||
|
|||||||
43
testing/tests/test_hermes_voice_release.py
Normal file
43
testing/tests/test_hermes_voice_release.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Contracts for the bounded Hermes private-voice image release lane."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PIPELINE = ROOT / "ci/Jenkinsfile.hermes-voice-image"
|
||||||
|
JOBS = ROOT / "services/jenkins/configmap-jcasc.yaml"
|
||||||
|
TRIGGER = ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_pipeline_binds_component_source_digest_and_release() -> None:
|
||||||
|
pipeline = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
assert "IMAGE_COMPONENT must be stt or tts" in pipeline
|
||||||
|
assert "git merge-base --is-ancestor" in pipeline
|
||||||
|
assert "git status --porcelain" in pipeline
|
||||||
|
assert "Dockerfile.hermes-jetson-${component}" in pipeline
|
||||||
|
assert "--digest-file=" in pipeline
|
||||||
|
assert "hermes_oci_promote.py" in pipeline
|
||||||
|
assert "PUBLISH HERMES STT" in pipeline
|
||||||
|
assert "PUBLISH HERMES TTS" in pipeline
|
||||||
|
assert "archiveArtifacts(" in pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_jenkins_declares_one_token_guarded_voice_job() -> None:
|
||||||
|
jobs = JOBS.read_text(encoding="utf-8")
|
||||||
|
assert jobs.count("pipelineJob('hermes-voice-image')") == 1
|
||||||
|
block = jobs.split("pipelineJob('hermes-voice-image')", 1)[1].split(
|
||||||
|
"multibranchPipelineJob('titan-iac-quality-gate')", 1
|
||||||
|
)[0]
|
||||||
|
assert "authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))" in block
|
||||||
|
assert "scriptPath('ci/Jenkinsfile.hermes-voice-image')" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_trigger_exposes_both_voice_components() -> None:
|
||||||
|
trigger = TRIGGER.read_text(encoding="utf-8")
|
||||||
|
for component, confirmation in (
|
||||||
|
("stt", "PUBLISH HERMES STT"),
|
||||||
|
("tts", "PUBLISH HERMES TTS"),
|
||||||
|
):
|
||||||
|
assert f'"{component}": {{' in trigger
|
||||||
|
assert f'"confirmation": "{confirmation}"' in trigger
|
||||||
|
assert f'"parameters": {{"IMAGE_COMPONENT": "{component}"}}' in trigger
|
||||||
Loading…
x
Reference in New Issue
Block a user