Compare commits
1 Commits
main
...
feature/t_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e47479cc |
314
ci/Jenkinsfile.hermes-webui-image
Normal file
314
ci/Jenkinsfile.hermes-webui-image
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
kubernetes {
|
||||||
|
defaultContainer 'python'
|
||||||
|
yaml """
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
atlas.bstein.dev/workload: hermes-webui-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: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
- 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: 250m
|
||||||
|
memory: 1Gi
|
||||||
|
limits:
|
||||||
|
cpu: "2"
|
||||||
|
memory: 4Gi
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parameters {
|
||||||
|
booleanParam(
|
||||||
|
name: 'PUBLISH_IMAGE',
|
||||||
|
defaultValue: false,
|
||||||
|
description: 'Publish the reviewed main revision to Harbor.'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'EXPECTED_SOURCE_REVISION',
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Exact 40-character commit on atlas/titan-iac main.'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'CONFIRM_PUBLISH',
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Enter PUBLISH HERMES WEBUI to confirm the release.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
environment {
|
||||||
|
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-webui'
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"
|
||||||
|
case "${EXPECTED_SOURCE_REVISION}" in
|
||||||
|
*[!0-9a-f]*|'')
|
||||||
|
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
|
||||||
|
actual_revision="$(git rev-parse HEAD)"
|
||||||
|
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
|
||||||
|
test "${actual_revision}" = "$(git rev-parse origin/main)"
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
test -f dockerfiles/Dockerfile.hermes-webui
|
||||||
|
case "${BUILD_NUMBER}" in
|
||||||
|
''|0*|*[!0-9]*)
|
||||||
|
echo "BUILD_NUMBER must be a positive decimal integer" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' \
|
||||||
|
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
|
||||||
|
> build/hermes-webui.destination
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Validate reviewed WebUI source') {
|
||||||
|
steps {
|
||||||
|
container('python') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
||||||
|
--target=/tmp/hermes-webui-release-test-deps \
|
||||||
|
pytest==8.3.4 PyYAML==6.0.2
|
||||||
|
PYTHONPATH=/tmp/hermes-webui-release-test-deps \
|
||||||
|
python3 -m pytest -q \
|
||||||
|
testing/tests/test_hermes_webui_brand.py \
|
||||||
|
testing/tests/test_hermes_webui_release.py
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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-webui.destination)"
|
||||||
|
python3 ci/scripts/hermes_webui_release.py assert-absent \
|
||||||
|
--source-revision "${EXPECTED_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-webui.destination)"
|
||||||
|
umask 077
|
||||||
|
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
|
||||||
|
/busybox/mkdir -p /kaniko/.docker
|
||||||
|
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
|
||||||
|
unset HARBOR_USER HARBOR_PASSWORD auth
|
||||||
|
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
|
||||||
|
umask 022
|
||||||
|
/kaniko/executor \
|
||||||
|
--registry-mirror=harbor-core.harbor.svc.cluster.local \
|
||||||
|
--insecure-registry=harbor-core.harbor.svc.cluster.local \
|
||||||
|
--context="dir://${WORKSPACE}" \
|
||||||
|
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
|
||||||
|
--destination="${destination}" \
|
||||||
|
--digest-file="${WORKSPACE}/build/hermes-webui.digest" \
|
||||||
|
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui.image" \
|
||||||
|
--label="org.opencontainers.image.revision=${EXPECTED_SOURCE_REVISION}" \
|
||||||
|
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
|
||||||
|
--label="org.opencontainers.image.title=hermes-webui" \
|
||||||
|
--cleanup \
|
||||||
|
--push-retry=3
|
||||||
|
/busybox/chmod 644 build/hermes-webui.digest build/hermes-webui.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-webui.destination)"
|
||||||
|
python3 ci/scripts/hermes_webui_release.py render \
|
||||||
|
--digest-file build/hermes-webui.digest \
|
||||||
|
--image-file build/hermes-webui.image \
|
||||||
|
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--chat-manifest services/hermes/chat-statefulset.yaml \
|
||||||
|
--dashboard-manifest services/hermes/deployment.yaml \
|
||||||
|
--output-dir build/hermes-webui-release
|
||||||
|
test -s build/hermes-webui-release/hermes-webui-image-update.patch
|
||||||
|
test -s build/hermes-webui-release/hermes-webui-image.json
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
expected_files="$(printf '%s\n' \
|
||||||
|
build/hermes-webui.destination \
|
||||||
|
build/hermes-webui.digest \
|
||||||
|
build/hermes-webui.image \
|
||||||
|
build/hermes-webui-release/hermes-chat-statefulset.yaml \
|
||||||
|
build/hermes-webui-release/hermes-dashboard-deployment.yaml \
|
||||||
|
build/hermes-webui-release/hermes-webui-image.json \
|
||||||
|
build/hermes-webui-release/hermes-webui-image-update.patch \
|
||||||
|
| LC_ALL=C sort)"
|
||||||
|
actual_files="$(find build -type f -print | LC_ALL=C sort)"
|
||||||
|
test "${actual_files}" = "${expected_files}"
|
||||||
|
destination="$(cat build/hermes-webui.destination)"
|
||||||
|
python3 ci/scripts/hermes_webui_release.py verify-evidence \
|
||||||
|
--digest-file build/hermes-webui.digest \
|
||||||
|
--image-file build/hermes-webui.image \
|
||||||
|
--source-revision "${EXPECTED_SOURCE_REVISION}" \
|
||||||
|
--build-number "${BUILD_NUMBER}" \
|
||||||
|
--destination "${destination}" \
|
||||||
|
--chat-manifest services/hermes/chat-statefulset.yaml \
|
||||||
|
--dashboard-manifest services/hermes/deployment.yaml \
|
||||||
|
--output-dir build/hermes-webui-release
|
||||||
|
'''
|
||||||
|
archiveArtifacts(
|
||||||
|
artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch',
|
||||||
|
allowEmptyArchive: false,
|
||||||
|
fingerprint: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cleanup {
|
||||||
|
container('kaniko') {
|
||||||
|
sh '''#!/busybox/sh
|
||||||
|
/busybox/rm -f /kaniko/.docker/config.json
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
ci/scripts/hermes_webui_flux_release.py
Normal file
207
ci/scripts/hermes_webui_flux_release.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render and revalidate the two-workload Hermes WebUI Flux handoff."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-webui"
|
||||||
|
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-webui:"
|
||||||
|
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validated(value: str, pattern: re.Pattern[str], label: str) -> str:
|
||||||
|
"""Return a normalized value 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 one unique build tag to the reviewed revision 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 must bind the reviewed revision and unique Jenkins build"
|
||||||
|
)
|
||||||
|
return revision, build
|
||||||
|
|
||||||
|
|
||||||
|
def render_workload(
|
||||||
|
source: str,
|
||||||
|
digest: str,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
name: str,
|
||||||
|
image: str = DEFAULT_IMAGE,
|
||||||
|
) -> str:
|
||||||
|
"""Replace one WebUI image in one exact Flux workload without reformatting."""
|
||||||
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
identity = re.compile(
|
||||||
|
rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
|
||||||
|
rf"metadata:\n name: {re.escape(name)}\n"
|
||||||
|
)
|
||||||
|
if not identity.search(source):
|
||||||
|
raise ValueError(f"Flux target identity changed: expected {kind}/{name}")
|
||||||
|
lines = source.splitlines(keepends=True)
|
||||||
|
matches: list[int] = []
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith(f"image: {image}@"):
|
||||||
|
continue
|
||||||
|
current_digest = stripped.removeprefix(f"image: {image}@")
|
||||||
|
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
|
||||||
|
matches.append(index)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected exactly one {image!r} image in {kind}/{name}; "
|
||||||
|
f"found {len(matches)}"
|
||||||
|
)
|
||||||
|
index = matches[0]
|
||||||
|
newline = "\n" if lines[index].endswith("\n") else ""
|
||||||
|
prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
|
||||||
|
lines[index] = f"{prefix}image: {image}@{digest}{newline}"
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _targets(chat_manifest: Path, dashboard_manifest: Path):
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
chat_manifest,
|
||||||
|
"StatefulSet",
|
||||||
|
"hermes-chat-tenant",
|
||||||
|
"hermes-chat-statefulset.yaml",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
dashboard_manifest,
|
||||||
|
"Deployment",
|
||||||
|
"hermes",
|
||||||
|
"hermes-dashboard-deployment.yaml",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(
|
||||||
|
digest: str, source_revision: str, build_number: str, destination: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"build_number": build_number,
|
||||||
|
"digest": digest,
|
||||||
|
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
|
||||||
|
"flux_targets": [
|
||||||
|
"apps/StatefulSet/hermes/hermes-chat-tenant",
|
||||||
|
"apps/Deployment/hermes/hermes",
|
||||||
|
],
|
||||||
|
"image": DEFAULT_IMAGE,
|
||||||
|
"published_tag": destination,
|
||||||
|
"source_revision": source_revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered_and_patch(
|
||||||
|
digest: str, chat_manifest: Path, dashboard_manifest: Path
|
||||||
|
) -> tuple[dict[str, str], str]:
|
||||||
|
rendered_targets: dict[str, str] = {}
|
||||||
|
patch_parts: list[str] = []
|
||||||
|
for path, kind, name, artifact_name in _targets(chat_manifest, dashboard_manifest):
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
rendered = render_workload(source, digest, kind=kind, name=name)
|
||||||
|
rendered_targets[artifact_name] = rendered
|
||||||
|
patch_parts.append(
|
||||||
|
"".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
source.splitlines(keepends=True),
|
||||||
|
rendered.splitlines(keepends=True),
|
||||||
|
fromfile=f"a/services/hermes/{path.name}",
|
||||||
|
tofile=f"b/services/hermes/{path.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rendered_targets, "".join(patch_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def write_release_artifacts(
|
||||||
|
*,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
chat_manifest: Path,
|
||||||
|
dashboard_manifest: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Write two rendered Flux targets, one patch, and credential-free evidence."""
|
||||||
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
source_revision, build_number = validate_destination(
|
||||||
|
destination, source_revision, build_number
|
||||||
|
)
|
||||||
|
rendered_targets, patch = _rendered_and_patch(
|
||||||
|
digest, chat_manifest, dashboard_manifest
|
||||||
|
)
|
||||||
|
if not patch:
|
||||||
|
raise ValueError("published digest already matches every Flux target")
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for artifact_name, rendered in rendered_targets.items():
|
||||||
|
(output_dir / artifact_name).write_text(rendered, encoding="utf-8")
|
||||||
|
(output_dir / "hermes-webui-image-update.patch").write_text(patch, encoding="utf-8")
|
||||||
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||||
|
(output_dir / "hermes-webui-image.json").write_text(
|
||||||
|
json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_artifacts(
|
||||||
|
*,
|
||||||
|
digest: str,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
chat_manifest: Path,
|
||||||
|
dashboard_manifest: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Revalidate the exact successful-build evidence without rewriting it."""
|
||||||
|
digest = validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
source_revision, build_number = validate_destination(
|
||||||
|
destination, source_revision, build_number
|
||||||
|
)
|
||||||
|
expected_names = {
|
||||||
|
"hermes-webui-image.json",
|
||||||
|
"hermes-webui-image-update.patch",
|
||||||
|
"hermes-chat-statefulset.yaml",
|
||||||
|
"hermes-dashboard-deployment.yaml",
|
||||||
|
}
|
||||||
|
entries = list(output_dir.iterdir())
|
||||||
|
if {entry.name for entry in entries} != expected_names or not all(
|
||||||
|
entry.is_file() and not entry.is_symlink() for entry in entries
|
||||||
|
):
|
||||||
|
raise ValueError("release output must contain exactly four evidence files")
|
||||||
|
rendered_targets, patch = _rendered_and_patch(
|
||||||
|
digest, chat_manifest, dashboard_manifest
|
||||||
|
)
|
||||||
|
metadata = _metadata(digest, source_revision, build_number, destination)
|
||||||
|
expected = {
|
||||||
|
"hermes-webui-image.json": json.dumps(metadata, indent=2, sort_keys=True)
|
||||||
|
+ "\n",
|
||||||
|
"hermes-webui-image-update.patch": patch,
|
||||||
|
**rendered_targets,
|
||||||
|
}
|
||||||
|
for name, expected_text in expected.items():
|
||||||
|
if (output_dir / name).read_text(encoding="utf-8") != expected_text:
|
||||||
|
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
|
||||||
374
ci/scripts/hermes_webui_release.py
Executable file
374
ci/scripts/hermes_webui_release.py
Executable file
@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify and render a reviewable Hermes WebUI image release."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from hermes_webui_flux_release import (
|
||||||
|
DEFAULT_IMAGE as DEFAULT_IMAGE,
|
||||||
|
DESTINATION_PATTERN,
|
||||||
|
DIGEST_PATTERN,
|
||||||
|
render_workload as render_workload,
|
||||||
|
validate_destination,
|
||||||
|
validate_release_artifacts as validate_flux_release_artifacts,
|
||||||
|
validated as _validated,
|
||||||
|
write_release_artifacts,
|
||||||
|
)
|
||||||
|
|
||||||
|
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||||
|
HARBOR_PROJECT = "bstein"
|
||||||
|
HARBOR_REPOSITORY = "hermes-webui"
|
||||||
|
IMMUTABLE_REPOSITORY_PATTERN = "hermes-webui"
|
||||||
|
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Never send registry credentials to a redirect target."""
|
||||||
|
|
||||||
|
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_kaniko_evidence(
|
||||||
|
*, digest_text: str, image_text: str, destination: str
|
||||||
|
) -> str:
|
||||||
|
"""Cross-check both independent Kaniko output files against the destination."""
|
||||||
|
digest_lines = digest_text.splitlines()
|
||||||
|
image_lines = image_text.splitlines()
|
||||||
|
if len(digest_lines) != 1:
|
||||||
|
raise ValueError("Kaniko digest evidence must contain exactly one line")
|
||||||
|
if len(image_lines) != 1:
|
||||||
|
raise ValueError("Kaniko image evidence must contain exactly one line")
|
||||||
|
digest = _validated(digest_lines[0], DIGEST_PATTERN, "image digest")
|
||||||
|
if image_lines[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:
|
||||||
|
"""Make a registry request 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 _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 tag with bounded response size."""
|
||||||
|
match = DESTINATION_PATTERN.fullmatch(destination)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("invalid destination")
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("Harbor credentials are empty")
|
||||||
|
tag = destination.rsplit(":", 1)[1]
|
||||||
|
encoded_tag = urllib.parse.quote(tag, safe="")
|
||||||
|
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
|
||||||
|
f"{HARBOR_REPOSITORY}/artifacts/{encoded_tag}"
|
||||||
|
"?with_immutable_status=true",
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
|
||||||
|
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 _immutable_rules_response(
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> tuple[int, bytes, dict[str, str]]:
|
||||||
|
"""Read the project policy with the same least-privilege publish identity."""
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("Harbor credentials are empty")
|
||||||
|
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
|
||||||
|
"?page=1&page_size=100",
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
|
||||||
|
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 _require_complete_rule_page(
|
||||||
|
rules: list[dict[str, Any]], headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
"""Require proof that the bounded first page contains every rule."""
|
||||||
|
raw_total = next(
|
||||||
|
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if raw_total is None or not str(raw_total).isdecimal():
|
||||||
|
raise RuntimeError("Harbor immutable rule list omitted a valid total count")
|
||||||
|
if int(raw_total) != len(rules):
|
||||||
|
raise RuntimeError("Harbor immutable rule list was truncated")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_immutable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Select only fields that bind the server-side build-tag policy."""
|
||||||
|
return {
|
||||||
|
"disabled": bool(rule.get("disabled", False)),
|
||||||
|
"action": rule.get("action"),
|
||||||
|
"template": rule.get("template"),
|
||||||
|
"tag_selectors": [
|
||||||
|
{
|
||||||
|
"kind": item.get("kind"),
|
||||||
|
"decoration": item.get("decoration"),
|
||||||
|
"pattern": item.get("pattern"),
|
||||||
|
}
|
||||||
|
for item in rule.get("tag_selectors") or []
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{
|
||||||
|
"kind": item.get("kind"),
|
||||||
|
"decoration": item.get("decoration"),
|
||||||
|
"pattern": item.get("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 before build unless the exact Harbor rule is active."""
|
||||||
|
status, body, headers = _immutable_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")
|
||||||
|
_require_complete_rule_page(rules, headers)
|
||||||
|
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 = [
|
||||||
|
_normalized_immutable_rule(item)
|
||||||
|
for item in rules
|
||||||
|
if _normalized_immutable_rule(item)["tag_selectors"]
|
||||||
|
== expected["tag_selectors"]
|
||||||
|
and _normalized_immutable_rule(item)["scope_selectors"]
|
||||||
|
== expected["scope_selectors"]
|
||||||
|
]
|
||||||
|
if matches != [expected]:
|
||||||
|
raise RuntimeError("Harbor immutable build-tag policy is absent or 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 push an already-used immutable identity."""
|
||||||
|
status, _body = _artifact_response(
|
||||||
|
destination, username=username, password=password, opener=opener
|
||||||
|
)
|
||||||
|
if status == 404:
|
||||||
|
return
|
||||||
|
if status == 200:
|
||||||
|
raise RuntimeError("Harbor destination tag already exists; refusing overwrite")
|
||||||
|
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_registry_digest(
|
||||||
|
destination: str,
|
||||||
|
digest: str,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
|
||||||
|
) -> None:
|
||||||
|
"""Verify Harbor independently resolves the pushed tag to Kaniko's digest."""
|
||||||
|
digest = _validated(digest, DIGEST_PATTERN, "image digest")
|
||||||
|
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
|
||||||
|
harbor_digest = str(artifact.get("digest") or "").strip()
|
||||||
|
if not DIGEST_PATTERN.fullmatch(harbor_digest):
|
||||||
|
raise RuntimeError("Harbor response omitted a valid artifact digest")
|
||||||
|
if harbor_digest != digest:
|
||||||
|
raise RuntimeError("Harbor digest does not match Kaniko evidence")
|
||||||
|
expected_tag = destination.rsplit(":", 1)[1]
|
||||||
|
matching_tags = [
|
||||||
|
item
|
||||||
|
for item in artifact.get("tags") or []
|
||||||
|
if isinstance(item, dict) and item.get("name") == expected_tag
|
||||||
|
]
|
||||||
|
if len(matching_tags) != 1:
|
||||||
|
raise RuntimeError("Harbor artifact does not contain the expected tag")
|
||||||
|
if matching_tags[0].get("immutable") is not True:
|
||||||
|
raise RuntimeError("Harbor did not enforce the expected tag as immutable")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_artifacts(
|
||||||
|
*,
|
||||||
|
digest_file: Path,
|
||||||
|
image_file: Path,
|
||||||
|
source_revision: str,
|
||||||
|
build_number: str,
|
||||||
|
destination: str,
|
||||||
|
chat_manifest: Path,
|
||||||
|
dashboard_manifest: Path,
|
||||||
|
output_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Revalidate the exact successful-build evidence without rewriting it."""
|
||||||
|
digest = validate_kaniko_evidence(
|
||||||
|
digest_text=digest_file.read_text(encoding="utf-8"),
|
||||||
|
image_text=image_file.read_text(encoding="utf-8"),
|
||||||
|
destination=destination,
|
||||||
|
)
|
||||||
|
validate_flux_release_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=source_revision,
|
||||||
|
build_number=build_number,
|
||||||
|
destination=destination,
|
||||||
|
chat_manifest=chat_manifest,
|
||||||
|
dashboard_manifest=dashboard_manifest,
|
||||||
|
output_dir=output_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credentials() -> tuple[str, str]:
|
||||||
|
"""Read the masked, runtime-only Jenkins credential environment."""
|
||||||
|
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 _common_arguments(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:
|
||||||
|
"""Fail closed around the unique tag, then verify and render after push."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
absent = commands.add_parser("assert-absent")
|
||||||
|
_common_arguments(absent)
|
||||||
|
render = commands.add_parser("render")
|
||||||
|
_common_arguments(render)
|
||||||
|
render.add_argument("--digest-file", required=True, type=Path)
|
||||||
|
render.add_argument("--image-file", required=True, type=Path)
|
||||||
|
render.add_argument("--chat-manifest", required=True, type=Path)
|
||||||
|
render.add_argument("--dashboard-manifest", required=True, type=Path)
|
||||||
|
render.add_argument("--output-dir", required=True, type=Path)
|
||||||
|
verify = commands.add_parser("verify-evidence")
|
||||||
|
_common_arguments(verify)
|
||||||
|
verify.add_argument("--digest-file", required=True, type=Path)
|
||||||
|
verify.add_argument("--image-file", required=True, type=Path)
|
||||||
|
verify.add_argument("--chat-manifest", required=True, type=Path)
|
||||||
|
verify.add_argument("--dashboard-manifest", required=True, type=Path)
|
||||||
|
verify.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":
|
||||||
|
validate_release_artifacts(
|
||||||
|
digest_file=args.digest_file,
|
||||||
|
image_file=args.image_file,
|
||||||
|
source_revision=args.source_revision,
|
||||||
|
build_number=args.build_number,
|
||||||
|
destination=args.destination,
|
||||||
|
chat_manifest=args.chat_manifest,
|
||||||
|
dashboard_manifest=args.dashboard_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, username=username, password=password
|
||||||
|
)
|
||||||
|
write_release_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=args.source_revision,
|
||||||
|
build_number=args.build_number,
|
||||||
|
destination=args.destination,
|
||||||
|
chat_manifest=args.chat_manifest,
|
||||||
|
dashboard_manifest=args.dashboard_manifest,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||||
|
raise SystemExit(main())
|
||||||
@ -22,6 +22,10 @@ spec:
|
|||||||
kind: Job
|
kind: Job
|
||||||
name: harbor-hermes-agent-immutability-ensure-1
|
name: harbor-hermes-agent-immutability-ensure-1
|
||||||
namespace: harbor
|
namespace: harbor
|
||||||
|
- apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
name: harbor-hermes-webui-immutability-ensure-1
|
||||||
|
namespace: harbor
|
||||||
dependsOn:
|
dependsOn:
|
||||||
- name: core
|
- name: core
|
||||||
- name: longhorn
|
- name: longhorn
|
||||||
|
|||||||
@ -10,85 +10,9 @@ USER root
|
|||||||
# while the gateway remains the only process that owns an agent conversation.
|
# while the gateway remains the only process that owns an agent conversation.
|
||||||
COPY --from=webui /apptoo /opt/hermes-webui
|
COPY --from=webui /apptoo /opt/hermes-webui
|
||||||
|
|
||||||
# The account policy caps user-selected reasoning at xhigh even when a provider
|
|
||||||
# advertises a newer, more expensive level.
|
|
||||||
RUN /opt/hermes/.venv/bin/python - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
config = Path("/opt/hermes-webui/api/config.py")
|
|
||||||
source = config.read_text(encoding="utf-8")
|
|
||||||
before = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")'
|
|
||||||
after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")'
|
|
||||||
if before not in source:
|
|
||||||
raise SystemExit("Hermes WebUI reasoning-effort patch context changed")
|
|
||||||
config.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
||||||
|
|
||||||
index = Path("/opt/hermes-webui/static/index.html")
|
|
||||||
source = index.read_text(encoding="utf-8")
|
|
||||||
before = ' <div class="reasoning-option" data-effort="max">Max</div>\n'
|
|
||||||
if before not in source:
|
|
||||||
raise SystemExit("Hermes WebUI xhigh UI patch context changed")
|
|
||||||
index.write_text(source.replace(before, "", 1), encoding="utf-8")
|
|
||||||
|
|
||||||
# oauth2-proxy returns 401 for browser API and health probes when the secure
|
|
||||||
# session expires. Re-enter OIDC with the complete return path instead of
|
|
||||||
# presenting an endless, inaccurate "connection lost" loop.
|
|
||||||
ui = Path("/opt/hermes-webui/static/ui.js")
|
|
||||||
source = ui.read_text(encoding="utf-8")
|
|
||||||
before = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
|
||||||
return !!(res&&res.ok);
|
|
||||||
'''
|
|
||||||
after = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
|
||||||
if(res&&(res.status===401||res.status===403)){
|
|
||||||
const rd=window.location.pathname+window.location.search+window.location.hash;
|
|
||||||
window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !!(res&&res.ok);
|
|
||||||
'''
|
|
||||||
if source.count(before) != 1:
|
|
||||||
raise SystemExit("Hermes WebUI auth-recovery patch context changed")
|
|
||||||
ui.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
||||||
|
|
||||||
# Make delegated session hierarchy obvious and collapsible in the sidebar.
|
|
||||||
sessions = Path("/opt/hermes-webui/static/sessions.js")
|
|
||||||
source = sessions.read_text(encoding="utf-8")
|
|
||||||
before = ''' const childLabel=t('session_meta_children', childCount);
|
|
||||||
childCountEl.textContent=childLabel;
|
|
||||||
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
|
||||||
'''
|
|
||||||
after = ''' const childLabel=t('session_meta_children', childCount);
|
|
||||||
const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
|
|
||||||
childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
|
|
||||||
childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
|
|
||||||
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
|
||||||
'''
|
|
||||||
if source.count(before) != 1:
|
|
||||||
raise SystemExit("Hermes WebUI child-session toggle patch context changed")
|
|
||||||
sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|
||||||
|
|
||||||
# A profile's model is only its default; a session-level selector can override
|
|
||||||
# it. Label the scope so the dropdown does not contradict the effective model.
|
|
||||||
panels = Path("/opt/hermes-webui/static/panels.js")
|
|
||||||
source = panels.read_text(encoding="utf-8")
|
|
||||||
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
|
||||||
after = ''' if (typeof p.model === 'string' && p.model) {
|
|
||||||
const routeLabels = {
|
|
||||||
'atlas/auto/fast': 'Automatic · Fast',
|
|
||||||
'atlas/auto/balanced': 'Automatic · Balanced',
|
|
||||||
'atlas/auto/deep': 'Automatic · Deep',
|
|
||||||
'atlas/auto/maximum': 'Automatic · Maximum',
|
|
||||||
};
|
|
||||||
meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
if source.count(before) != 2:
|
|
||||||
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
|
||||||
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
|
||||||
PY
|
|
||||||
|
|
||||||
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
||||||
# when a tenant's server-side STT capability reports the private Jetson route.
|
# when a tenant's server-side STT capability reports the private Jetson route.
|
||||||
|
COPY dockerfiles/hermes-webui-base-patch.py /tmp/hermes-webui-base-patch.py
|
||||||
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
|
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
|
||||||
COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
|
COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
|
||||||
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
|
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
|
||||||
@ -96,15 +20,24 @@ COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voic
|
|||||||
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
|
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
|
||||||
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
|
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
|
||||||
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
|
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
|
||||||
|
COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py
|
||||||
|
COPY dockerfiles/hermes-webui-brand.css /opt/hermes-webui/static/hermes-brand.css
|
||||||
|
COPY dockerfiles/hermes-webui-manifest.json /opt/hermes-webui/static/manifest.json
|
||||||
|
COPY dockerfiles/hermes-webui-assets/hermes-agent.ico /opt/hermes-webui/static/hermes-agent.ico
|
||||||
|
COPY dockerfiles/hermes-webui-assets/hermes-agent-192.png /opt/hermes-webui/static/hermes-agent-192.png
|
||||||
|
COPY dockerfiles/hermes-webui-assets/hermes-agent-512.png /opt/hermes-webui/static/hermes-agent-512.png
|
||||||
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py
|
||||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
|
||||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py
|
||||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
|
||||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
|
||||||
|
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py
|
||||||
|
|
||||||
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||||
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
||||||
/opt/hermes-webui/api/config.py \
|
/opt/hermes-webui/api/config.py \
|
||||||
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
||||||
|
&& grep -Fq 'res.status===401||res.status===403' /opt/hermes-webui/static/ui.js \
|
||||||
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
||||||
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
||||||
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
|
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
|
||||||
@ -124,6 +57,24 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
|||||||
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
|
||||||
&& grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
|
&& grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
|
||||||
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
|
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
|
||||||
|
&& grep -Fq '<title>Hermes Chat</title>' /opt/hermes-webui/static/index.html \
|
||||||
|
&& grep -Fq 'id="hermesBrandStyles"' /opt/hermes-webui/static/index.html \
|
||||||
|
&& grep -Fq 'static/hermes-agent-512.png' /opt/hermes-webui/static/index.html \
|
||||||
|
&& grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/hermes-brand.css \
|
||||||
|
&& grep -Fq '"name": "Hermes Chat"' /opt/hermes-webui/static/manifest.json \
|
||||||
|
&& grep -Fq "'./static/hermes-agent-512.png'" /opt/hermes-webui/static/sw.js \
|
||||||
|
&& printf '%s %s\n' \
|
||||||
|
'aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9' \
|
||||||
|
'/opt/hermes-webui/static/hermes-agent.ico' \
|
||||||
|
| sha256sum -c - \
|
||||||
|
&& printf '%s %s\n' \
|
||||||
|
'0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f' \
|
||||||
|
'/opt/hermes-webui/static/hermes-agent-192.png' \
|
||||||
|
| sha256sum -c - \
|
||||||
|
&& printf '%s %s\n' \
|
||||||
|
'6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d' \
|
||||||
|
'/opt/hermes-webui/static/hermes-agent-512.png' \
|
||||||
|
| sha256sum -c - \
|
||||||
&& /opt/hermes/.venv/bin/python -m py_compile \
|
&& /opt/hermes/.venv/bin/python -m py_compile \
|
||||||
/opt/hermes-webui/api/routes.py \
|
/opt/hermes-webui/api/routes.py \
|
||||||
/opt/hermes-webui/api/upload.py \
|
/opt/hermes-webui/api/upload.py \
|
||||||
|
|||||||
18
dockerfiles/hermes-webui-assets/SOURCE.md
Normal file
18
dockerfiles/hermes-webui-assets/SOURCE.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Hermes WebUI persona icon provenance
|
||||||
|
|
||||||
|
`hermes-agent.ico` is a byte-for-byte tracked copy of the canonical Hermes
|
||||||
|
Agent dashboard icon from `/opt/hermes/web/public/favicon.ico`. The same bytes
|
||||||
|
were independently present at `/opt/hermes/hermes_cli/web_dist/favicon.ico` on
|
||||||
|
the Atlas coordinator when this asset was imported on 2026-08-23.
|
||||||
|
|
||||||
|
- Canonical ICO SHA-256: `aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9`
|
||||||
|
- ICO payloads: PNG-encoded RGBA variants at 16x16, 32x32, and 48x48
|
||||||
|
- `hermes-agent-192.png`: 48px canonical variant resized to 192x192 with
|
||||||
|
Pillow 12.2.0 LANCZOS resampling; SHA-256
|
||||||
|
`0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f`
|
||||||
|
- `hermes-agent-512.png`: 48px canonical variant resized to 512x512 with
|
||||||
|
Pillow 12.2.0 LANCZOS resampling; SHA-256
|
||||||
|
`6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d`
|
||||||
|
|
||||||
|
The larger files are faithful format/size derivatives for PWA installation;
|
||||||
|
they do not redraw or replace the supplied persona.
|
||||||
BIN
dockerfiles/hermes-webui-assets/hermes-agent-192.png
Normal file
BIN
dockerfiles/hermes-webui-assets/hermes-agent-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
dockerfiles/hermes-webui-assets/hermes-agent-512.png
Normal file
BIN
dockerfiles/hermes-webui-assets/hermes-agent-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
dockerfiles/hermes-webui-assets/hermes-agent.ico
Normal file
BIN
dockerfiles/hermes-webui-assets/hermes-agent.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
76
dockerfiles/hermes-webui-base-patch.py
Normal file
76
dockerfiles/hermes-webui-base-patch.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply Atlas baseline UI policy patches to the pinned Hermes WebUI."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
config = Path("/opt/hermes-webui/api/config.py")
|
||||||
|
source = config.read_text(encoding="utf-8")
|
||||||
|
before = (
|
||||||
|
'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")'
|
||||||
|
)
|
||||||
|
after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")'
|
||||||
|
if source.count(before) != 1:
|
||||||
|
raise SystemExit("Hermes WebUI reasoning-effort patch context changed")
|
||||||
|
config.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||||
|
|
||||||
|
index = Path("/opt/hermes-webui/static/index.html")
|
||||||
|
source = index.read_text(encoding="utf-8")
|
||||||
|
before = ' <div class="reasoning-option" data-effort="max">Max</div>\n'
|
||||||
|
if source.count(before) != 1:
|
||||||
|
raise SystemExit("Hermes WebUI xhigh UI patch context changed")
|
||||||
|
index.write_text(source.replace(before, "", 1), encoding="utf-8")
|
||||||
|
|
||||||
|
# oauth2-proxy returns 401 for browser API and health probes when the secure
|
||||||
|
# session expires. Re-enter OIDC with the complete return path.
|
||||||
|
ui = Path("/opt/hermes-webui/static/ui.js")
|
||||||
|
source = ui.read_text(encoding="utf-8")
|
||||||
|
before = """ const res=await fetcher(_offlineHealthUrl(),opts);
|
||||||
|
return !!(res&&res.ok);
|
||||||
|
"""
|
||||||
|
after = """ const res=await fetcher(_offlineHealthUrl(),opts);
|
||||||
|
if(res&&(res.status===401||res.status===403)){
|
||||||
|
const rd=window.location.pathname+window.location.search+window.location.hash;
|
||||||
|
window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !!(res&&res.ok);
|
||||||
|
"""
|
||||||
|
if source.count(before) != 1:
|
||||||
|
raise SystemExit("Hermes WebUI auth-recovery patch context changed")
|
||||||
|
ui.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||||
|
|
||||||
|
# Make delegated session hierarchy obvious and collapsible in the sidebar.
|
||||||
|
sessions = Path("/opt/hermes-webui/static/sessions.js")
|
||||||
|
source = sessions.read_text(encoding="utf-8")
|
||||||
|
before = """ const childLabel=t('session_meta_children', childCount);
|
||||||
|
childCountEl.textContent=childLabel;
|
||||||
|
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
||||||
|
"""
|
||||||
|
after = """ const childLabel=t('session_meta_children', childCount);
|
||||||
|
const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
|
||||||
|
childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
|
||||||
|
childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
|
||||||
|
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
||||||
|
"""
|
||||||
|
if source.count(before) != 1:
|
||||||
|
raise SystemExit("Hermes WebUI child-session toggle patch context changed")
|
||||||
|
sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||||
|
|
||||||
|
# A profile's model is only its default; label that scope in both render paths.
|
||||||
|
panels = Path("/opt/hermes-webui/static/panels.js")
|
||||||
|
source = panels.read_text(encoding="utf-8")
|
||||||
|
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
||||||
|
after = """ if (typeof p.model === 'string' && p.model) {
|
||||||
|
const routeLabels = {
|
||||||
|
'atlas/auto/fast': 'Automatic · Fast',
|
||||||
|
'atlas/auto/balanced': 'Automatic · Balanced',
|
||||||
|
'atlas/auto/deep': 'Automatic · Deep',
|
||||||
|
'atlas/auto/maximum': 'Automatic · Maximum',
|
||||||
|
};
|
||||||
|
meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if source.count(before) != 2:
|
||||||
|
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
||||||
|
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
||||||
109
dockerfiles/hermes-webui-brand-patch.py
Normal file
109
dockerfiles/hermes-webui-brand-patch.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply fail-closed Hermes identity and PWA patches to pinned WebUI source."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
|
||||||
|
|
||||||
|
|
||||||
|
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
||||||
|
"""Replace one exact upstream fragment and reject pin drift."""
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if source.count(before) != count:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Hermes brand patch context changed in {path}: {before[:80]!r}"
|
||||||
|
)
|
||||||
|
path.write_text(source.replace(before, after, count), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_between_exact(path: Path, start: str, end: str, after: str) -> None:
|
||||||
|
"""Replace one uniquely bounded upstream region and reject ambiguous input."""
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if source.count(start) != 1 or source.count(end) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Hermes brand patch context changed in {path}: {start[:80]!r}"
|
||||||
|
)
|
||||||
|
start_index = source.index(start)
|
||||||
|
end_index = source.index(end, start_index)
|
||||||
|
path.write_text(source[:start_index] + after + source[end_index:], encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
index = ROOT / "static/index.html"
|
||||||
|
replace_exact(index, "<title>Hermes</title>", "<title>Hermes Chat</title>")
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
"""<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||||
|
<link rel="shortcut icon" href="static/favicon.ico">""",
|
||||||
|
"""<link rel="icon" type="image/x-icon" href="static/hermes-agent.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="static/hermes-agent-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="static/hermes-agent-512.png">""",
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<meta name="apple-mobile-web-app-title" content="Hermes">',
|
||||||
|
'<meta name="apple-mobile-web-app-title" content="Hermes Chat">',
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">',
|
||||||
|
'<link rel="apple-touch-icon" sizes="192x192" href="static/hermes-agent-192.png">',
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">',
|
||||||
|
'<meta name="theme-color" content="#E8F1F2" media="(prefers-color-scheme: light)">',
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<meta name="theme-color" content="#141425" media="(prefers-color-scheme: dark)">',
|
||||||
|
'<meta name="theme-color" content="#0D1420" media="(prefers-color-scheme: dark)">',
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">',
|
||||||
|
'<meta name="theme-color" id="hermes-theme-color" content="#0D1420">',
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
"var c=t==='dark'?'#141425':'#FAF7F0';",
|
||||||
|
"var c=t==='dark'?'#0D1420':'#E8F1F2';",
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
'<link id="voiceInstrumentStyles" rel="stylesheet" href="static/atlas-voice.css?v=__WEBUI_VERSION__">',
|
||||||
|
'<link id="voiceInstrumentStyles" rel="stylesheet" href="static/atlas-voice.css?v=__WEBUI_VERSION__">\n'
|
||||||
|
'<link id="hermesBrandStyles" rel="stylesheet" href="static/hermes-brand.css?v=__WEBUI_VERSION__">',
|
||||||
|
)
|
||||||
|
replace_between_exact(
|
||||||
|
index,
|
||||||
|
' <span class="app-titlebar-icon" aria-hidden="true">\n',
|
||||||
|
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>',
|
||||||
|
' <span class="app-titlebar-icon" aria-hidden="true">\n'
|
||||||
|
' <img src="static/hermes-agent-192.png" alt="">\n'
|
||||||
|
" </span>\n",
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
index,
|
||||||
|
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>',
|
||||||
|
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes Chat</span>',
|
||||||
|
)
|
||||||
|
|
||||||
|
service_worker = ROOT / "static/sw.js"
|
||||||
|
replace_exact(
|
||||||
|
service_worker,
|
||||||
|
" './static/style.css' + VQ,\n",
|
||||||
|
" './static/style.css' + VQ,\n './static/hermes-brand.css' + VQ,\n",
|
||||||
|
)
|
||||||
|
replace_exact(
|
||||||
|
service_worker,
|
||||||
|
" './static/favicon.svg',\n './static/favicon-32.png',\n './manifest.json',\n",
|
||||||
|
" './static/hermes-agent.ico',\n"
|
||||||
|
" './static/hermes-agent-192.png',\n"
|
||||||
|
" './static/hermes-agent-512.png',\n"
|
||||||
|
" './manifest.json',\n",
|
||||||
|
)
|
||||||
105
dockerfiles/hermes-webui-brand.css
Normal file
105
dockerfiles/hermes-webui-brand.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/* Restrained Hermes/Atlas identity layered after the pinned upstream theme. */
|
||||||
|
:root {
|
||||||
|
--accent: #187f8b;
|
||||||
|
--accent-hover: #126a74;
|
||||||
|
--accent-bg: rgba(24, 127, 139, 0.09);
|
||||||
|
--accent-bg-strong: rgba(24, 127, 139, 0.17);
|
||||||
|
--accent-text: #126f7a;
|
||||||
|
--blue: #187f9f;
|
||||||
|
--gold: #9a661f;
|
||||||
|
--focus-ring: rgba(24, 127, 139, 0.38);
|
||||||
|
--focus-glow: rgba(24, 127, 139, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #070a12;
|
||||||
|
--sidebar: #0d1420;
|
||||||
|
--surface: #111b29;
|
||||||
|
--surface-subtle: rgba(116, 202, 214, 0.035);
|
||||||
|
--surface-subtle-hover: rgba(116, 202, 214, 0.075);
|
||||||
|
--border: #203044;
|
||||||
|
--border2: rgba(174, 218, 224, 0.18);
|
||||||
|
--border-subtle: rgba(174, 218, 224, 0.08);
|
||||||
|
--border-muted: rgba(174, 218, 224, 0.13);
|
||||||
|
--text: #e8f1f4;
|
||||||
|
--strong: #f8fcfd;
|
||||||
|
--muted: #91a5b3;
|
||||||
|
--em: #c5d2d8;
|
||||||
|
--accent: #48cfcc;
|
||||||
|
--accent-hover: #75dedb;
|
||||||
|
--accent-bg: rgba(72, 207, 204, 0.09);
|
||||||
|
--accent-bg-strong: rgba(72, 207, 204, 0.17);
|
||||||
|
--accent-text: #6bd8d4;
|
||||||
|
--blue: #4ca4cd;
|
||||||
|
--gold: #f0b66b;
|
||||||
|
--code-bg: #09111c;
|
||||||
|
--code-inline-bg: rgba(4, 10, 17, 0.72);
|
||||||
|
--code-text: #b9e5e4;
|
||||||
|
--pre-text: #dce8ec;
|
||||||
|
--input-bg: rgba(193, 229, 233, 0.045);
|
||||||
|
--hover-bg: rgba(193, 229, 233, 0.07);
|
||||||
|
--topbar-bg: rgba(9, 14, 24, 0.96);
|
||||||
|
--main-bg: rgba(7, 10, 18, 0.72);
|
||||||
|
--focus-ring: rgba(72, 207, 204, 0.38);
|
||||||
|
--focus-glow: rgba(72, 207, 204, 0.12);
|
||||||
|
--error: #f08b79;
|
||||||
|
--success: #65c9a6;
|
||||||
|
--warning: #f0b66b;
|
||||||
|
--info: #69b9dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.07), transparent 31rem),
|
||||||
|
linear-gradient(145deg, #070a12, #080d17 55%, #071017);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .app-titlebar,
|
||||||
|
:root.dark .rail,
|
||||||
|
:root.dark .sidebar,
|
||||||
|
:root.dark .rightpanel,
|
||||||
|
:root.dark .topbar,
|
||||||
|
:root.dark .composer-wrap {
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: rgba(13, 20, 32, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar-icon img {
|
||||||
|
display: block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid rgba(72, 207, 204, 0.24);
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(72, 207, 204, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar-title {
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .composer-box:focus-within {
|
||||||
|
border-color: rgba(72, 207, 204, 0.66);
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the conversation instrument inside the same cyan/blue/gold family. */
|
||||||
|
:root.dark .voice-mode-bar {
|
||||||
|
--voice-accent: 72, 207, 204;
|
||||||
|
--voice-accent-secondary: 76, 164, 205;
|
||||||
|
border-bottom-color: rgba(174, 218, 224, 0.11);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.085), transparent 47%),
|
||||||
|
linear-gradient(180deg, rgba(17, 27, 41, 0.8), rgba(7, 10, 18, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root.dark body {
|
||||||
|
background: #070a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar-icon img,
|
||||||
|
:root.dark .composer-box:focus-within {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
dockerfiles/hermes-webui-manifest.json
Normal file
43
dockerfiles/hermes-webui-manifest.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"id": "./",
|
||||||
|
"name": "Hermes Chat",
|
||||||
|
"short_name": "Hermes",
|
||||||
|
"description": "Private Hermes Agent chat on Atlas",
|
||||||
|
"start_url": "./?source=pwa",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||||
|
"background_color": "#070A12",
|
||||||
|
"theme_color": "#0D1420",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "New conversation",
|
||||||
|
"short_name": "New chat",
|
||||||
|
"description": "Open Hermes ready for a new chat",
|
||||||
|
"url": "./?source=pwa&action=new-chat",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "static/hermes-agent-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "static/hermes-agent-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "static/hermes-agent-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
docs/hermes_webui_release.md
Normal file
39
docs/hermes_webui_release.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Hermes WebUI release lane
|
||||||
|
|
||||||
|
Hermes WebUI has a release lane separate from `hermes-agent-image`. The lane
|
||||||
|
builds `dockerfiles/Dockerfile.hermes-webui` from one exact reviewed `main`
|
||||||
|
commit, publishes a unique immutable Harbor tag, independently verifies the
|
||||||
|
Harbor digest, and renders a review-only Flux patch. It never writes Git and it
|
||||||
|
never reconciles or restarts a workload.
|
||||||
|
|
||||||
|
## Release sequence
|
||||||
|
|
||||||
|
1. Merge and review all WebUI source, patch, theme, and PWA asset changes.
|
||||||
|
2. Wait for Flux to complete both Harbor immutability Jobs and refresh Jenkins
|
||||||
|
JCasC from reviewed `main`.
|
||||||
|
3. Open the manual Jenkins job `hermes-webui-image` and set:
|
||||||
|
- `PUBLISH_IMAGE=true`
|
||||||
|
- `EXPECTED_SOURCE_REVISION` to the full 40-character `main` commit
|
||||||
|
- `CONFIRM_PUBLISH=PUBLISH HERMES WEBUI`
|
||||||
|
4. Retain the fingerprinted seven-file artifact set. In particular, compare
|
||||||
|
`hermes-webui-image.json` with Harbor and review
|
||||||
|
`hermes-webui-image-update.patch`.
|
||||||
|
5. Apply that patch on a fresh branch and open a separate review. The patch is
|
||||||
|
constrained to the `webui` container in:
|
||||||
|
- `StatefulSet/hermes-chat-tenant`
|
||||||
|
- `Deployment/hermes`
|
||||||
|
6. Merge the digest-only review to let Flux roll out desired state. Do not use a
|
||||||
|
manual `kubectl set image`, restart, or reconcile as a release substitute.
|
||||||
|
|
||||||
|
The release fails closed when the requested revision is not the checked-out
|
||||||
|
`origin/main`, the unique Harbor tag already exists, the exact WebUI immutable
|
||||||
|
tag policy is absent, Kaniko and Harbor disagree on the digest, either Flux
|
||||||
|
workload changes identity/image shape, or the evidence archive is incomplete.
|
||||||
|
|
||||||
|
## PWA identity source
|
||||||
|
|
||||||
|
The installed application uses the tracked canonical persona at
|
||||||
|
`dockerfiles/hermes-webui-assets/hermes-agent.ico`. Provenance, the canonical
|
||||||
|
SHA-256, and derivation details for the required 192px/512px PNGs are recorded
|
||||||
|
beside the asset in `SOURCE.md`; the image build never reads an icon from a
|
||||||
|
running coordinator.
|
||||||
79
services/harbor/hermes-webui-immutability-job.yaml
Normal file
79
services/harbor/hermes-webui-immutability-job.yaml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# services/harbor/hermes-webui-immutability-job.yaml
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: harbor-hermes-webui-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_webui_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-webui-immutability-script
|
||||||
|
defaultMode: 0555
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
@ -14,6 +14,7 @@ resources:
|
|||||||
- vault-sync-deployment.yaml
|
- vault-sync-deployment.yaml
|
||||||
- policy-bootstrap-serviceaccount.yaml
|
- policy-bootstrap-serviceaccount.yaml
|
||||||
- hermes-agent-immutability-job.yaml
|
- hermes-agent-immutability-job.yaml
|
||||||
|
- hermes-webui-immutability-job.yaml
|
||||||
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
||||||
- image.yaml
|
- image.yaml
|
||||||
configMapGenerator:
|
configMapGenerator:
|
||||||
@ -23,3 +24,6 @@ configMapGenerator:
|
|||||||
- name: harbor-hermes-agent-immutability-script
|
- name: harbor-hermes-agent-immutability-script
|
||||||
files:
|
files:
|
||||||
- harbor_hermes_agent_immutability_ensure.py=scripts/harbor_hermes_agent_immutability_ensure.py
|
- harbor_hermes_agent_immutability_ensure.py=scripts/harbor_hermes_agent_immutability_ensure.py
|
||||||
|
- name: harbor-hermes-webui-immutability-script
|
||||||
|
files:
|
||||||
|
- harbor_hermes_webui_immutability_ensure.py=scripts/harbor_hermes_webui_immutability_ensure.py
|
||||||
|
|||||||
@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and verify the narrowly scoped Hermes WebUI immutable-tag rule."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT = "bstein"
|
||||||
|
REPOSITORY_PATTERN = "hermes-webui"
|
||||||
|
TAG_PATTERN = "git-*-build-*"
|
||||||
|
EXPECTED_ORIGIN = "https://registry.bstein.dev/api/v2.0"
|
||||||
|
MAX_RESPONSE = 1_048_576
|
||||||
|
TRANSIENT_STATUSES = {429, 502, 503, 504}
|
||||||
|
EXPECTED_RULE = {
|
||||||
|
"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_PATTERN,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 not ready yet, rather than returning a policy decision."""
|
||||||
|
|
||||||
|
|
||||||
|
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": [
|
||||||
|
{
|
||||||
|
"kind": selector.get("kind"),
|
||||||
|
"decoration": selector.get("decoration"),
|
||||||
|
"pattern": selector.get("pattern"),
|
||||||
|
}
|
||||||
|
for selector in rule.get("tag_selectors") or []
|
||||||
|
if isinstance(selector, dict)
|
||||||
|
],
|
||||||
|
"scope_selectors": {
|
||||||
|
"repository": [
|
||||||
|
{
|
||||||
|
"kind": selector.get("kind"),
|
||||||
|
"decoration": selector.get("decoration"),
|
||||||
|
"pattern": selector.get("pattern"),
|
||||||
|
}
|
||||||
|
for selector in (rule.get("scope_selectors") or {}).get(
|
||||||
|
"repository", []
|
||||||
|
)
|
||||||
|
if isinstance(selector, dict)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def targets_webui_builds(rule: dict[str, Any]) -> bool:
|
||||||
|
"""Detect a rule that claims this exact repository and tag selector."""
|
||||||
|
normalized = normalized_rule(rule)
|
||||||
|
return (
|
||||||
|
normalized["tag_selectors"] == EXPECTED_RULE["tag_selectors"]
|
||||||
|
and normalized["scope_selectors"] == EXPECTED_RULE["scope_selectors"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HarborClient:
|
||||||
|
"""Bounded same-origin client for Harbor's immutable-tag API."""
|
||||||
|
|
||||||
|
def __init__(self, origin: str, username: str, password: str) -> None:
|
||||||
|
normalized_origin = origin.rstrip("/")
|
||||||
|
if normalized_origin != EXPECTED_ORIGIN:
|
||||||
|
raise ValueError("Harbor API origin is not the pinned production API")
|
||||||
|
self.origin = normalized_origin
|
||||||
|
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
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 one request, returning even non-2xx responses for strict 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) -> list[dict[str, Any]]:
|
||||||
|
"""Read and validate the complete small rule set for the project."""
|
||||||
|
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:
|
||||||
|
values = 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(values, list) or not all(
|
||||||
|
isinstance(item, dict) for item in values
|
||||||
|
):
|
||||||
|
raise RuntimeError("Harbor immutable rule list has an invalid shape")
|
||||||
|
raw_total = next(
|
||||||
|
(value for key, value in headers.items() if key.lower() == "x-total-count"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if raw_total is None or not str(raw_total).isdecimal():
|
||||||
|
raise RuntimeError("Harbor immutable rule list omitted a valid total count")
|
||||||
|
if int(raw_total) != len(values):
|
||||||
|
raise RuntimeError("Harbor immutable rule list was truncated")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_rule(client: HarborClient) -> int:
|
||||||
|
"""Create once, or validate the one exact enabled WebUI rule."""
|
||||||
|
rules = list_rules(client)
|
||||||
|
matches = [rule for rule in rules if targets_webui_builds(rule)]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError("multiple Hermes WebUI immutable rules exist")
|
||||||
|
if matches:
|
||||||
|
if normalized_rule(matches[0]) != EXPECTED_RULE:
|
||||||
|
raise RuntimeError("Hermes WebUI immutable rule is not enabled and exact")
|
||||||
|
rule_id = matches[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_RULE)
|
||||||
|
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("/")
|
||||||
|
expected_prefix = f"{api_path}{path}/"
|
||||||
|
if not location.startswith(expected_prefix):
|
||||||
|
raise RuntimeError("Harbor immutable rule create omitted the exact Location")
|
||||||
|
suffix = location[len(expected_prefix) :]
|
||||||
|
if not suffix.isdecimal() or int(suffix) < 1:
|
||||||
|
raise RuntimeError("Harbor immutable rule Location has an invalid ID")
|
||||||
|
|
||||||
|
for attempt in range(1, 6):
|
||||||
|
matches = [rule for rule in list_rules(client) if targets_webui_builds(rule)]
|
||||||
|
if len(matches) == 1 and normalized_rule(matches[0]) == EXPECTED_RULE:
|
||||||
|
rule_id = matches[0].get("id")
|
||||||
|
if rule_id == int(suffix):
|
||||||
|
return rule_id
|
||||||
|
if attempt < 5:
|
||||||
|
time.sleep(attempt)
|
||||||
|
raise RuntimeError("created Harbor immutable rule did not verify exactly")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("Harbor admin password is empty")
|
||||||
|
client = HarborClient(origin, "admin", password)
|
||||||
|
for attempt in range(1, 13):
|
||||||
|
try:
|
||||||
|
rule_id = ensure_rule(client)
|
||||||
|
break
|
||||||
|
except HarborUnavailable:
|
||||||
|
if attempt == 12:
|
||||||
|
raise
|
||||||
|
time.sleep(min(attempt * 2, 15))
|
||||||
|
print(f"Hermes WebUI immutable build-tag rule is active (id={rule_id})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
||||||
|
raise SystemExit(main())
|
||||||
@ -671,6 +671,24 @@ data:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pipelineJob('hermes-webui-image') {
|
||||||
|
disabled(false)
|
||||||
|
description('Human-gated, daemonless Kaniko build for the reviewed atlas/titan-iac main revision. Publishes an immutable Hermes WebUI image and archives a narrow two-workload Flux digest patch; it never mutates Git or deploys.')
|
||||||
|
definition {
|
||||||
|
cpsScm {
|
||||||
|
scm {
|
||||||
|
git {
|
||||||
|
remote {
|
||||||
|
url('https://scm.bstein.dev/atlas/titan-iac.git')
|
||||||
|
credentials('gitea-pat')
|
||||||
|
}
|
||||||
|
branches('*/main')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scriptPath('ci/Jenkinsfile.hermes-webui-image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
multibranchPipelineJob('titan-iac-quality-gate') {
|
multibranchPipelineJob('titan-iac-quality-gate') {
|
||||||
branchSources {
|
branchSources {
|
||||||
branchSource {
|
branchSource {
|
||||||
|
|||||||
@ -1,9 +1,28 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<title>Hermes</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||||
|
<link rel="shortcut icon" href="static/favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Hermes">
|
||||||
|
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#141425" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
|
||||||
|
<script>var c=t==='dark'?'#141425':'#FAF7F0';</script>
|
||||||
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header class="app-titlebar" role="banner">
|
||||||
|
<div class="app-titlebar-inner">
|
||||||
|
<span class="app-titlebar-icon" aria-hidden="true">
|
||||||
|
<svg><path d="fixture"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
|
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
|
||||||
<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
||||||
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||||
|
|||||||
7
testing/fixtures/hermes-webui-0.52.181/static/sw.js
Normal file
7
testing/fixtures/hermes-webui-0.52.181/static/sw.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const VQ = '?v=__WEBUI_VERSION__';
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
'./static/style.css' + VQ,
|
||||||
|
'./static/favicon.svg',
|
||||||
|
'./static/favicon-32.png',
|
||||||
|
'./manifest.json',
|
||||||
|
];
|
||||||
192
testing/tests/test_hermes_webui_brand.py
Normal file
192
testing/tests/test_hermes_webui_brand.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""Canonical icon, PWA, and fail-closed Hermes WebUI identity contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DOCKERFILES = ROOT / "dockerfiles"
|
||||||
|
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
|
||||||
|
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent"
|
||||||
|
ATLAS_PATCHER = DOCKERFILES / "hermes-webui-atlas-patch.py"
|
||||||
|
BRAND_PATCHER = DOCKERFILES / "hermes-webui-brand-patch.py"
|
||||||
|
ASSETS = DOCKERFILES / "hermes-webui-assets"
|
||||||
|
MANIFEST = DOCKERFILES / "hermes-webui-manifest.json"
|
||||||
|
BRAND_CSS = DOCKERFILES / "hermes-webui-brand.css"
|
||||||
|
|
||||||
|
EXPECTED_HASHES = {
|
||||||
|
"hermes-agent.ico": "aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9",
|
||||||
|
"hermes-agent-192.png": "0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f",
|
||||||
|
"hermes-agent-512.png": "6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_fixture(tmp_path: Path) -> Path:
|
||||||
|
target = tmp_path / "hermes-webui"
|
||||||
|
agent_target = tmp_path / "hermes-agent"
|
||||||
|
shutil.copytree(FIXTURE, target)
|
||||||
|
shutil.copytree(AGENT_FIXTURE, agent_target)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
||||||
|
env["HERMES_AGENT_PATCH_ROOT"] = str(agent_target)
|
||||||
|
for patcher in (ATLAS_PATCHER, BRAND_PATCHER):
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(patcher)],
|
||||||
|
cwd=ROOT,
|
||||||
|
env=env,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _png_size(path: Path) -> tuple[int, int]:
|
||||||
|
payload = path.read_bytes()
|
||||||
|
assert payload.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
assert payload[12:16] == b"IHDR"
|
||||||
|
return struct.unpack(">II", payload[16:24])
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_icon_provenance_format_and_pwa_derivatives() -> None:
|
||||||
|
"""The supplied persona is tracked exactly and only resized for PWA use."""
|
||||||
|
for name, expected in EXPECTED_HASHES.items():
|
||||||
|
payload = (ASSETS / name).read_bytes()
|
||||||
|
assert hashlib.sha256(payload).hexdigest() == expected
|
||||||
|
|
||||||
|
ico = (ASSETS / "hermes-agent.ico").read_bytes()
|
||||||
|
reserved, image_type, count = struct.unpack_from("<HHH", ico)
|
||||||
|
assert (reserved, image_type, count) == (0, 1, 3)
|
||||||
|
sizes = set()
|
||||||
|
for index in range(count):
|
||||||
|
offset = 6 + index * 16
|
||||||
|
width, height, _colors, _reserved, planes, depth, length, start = (
|
||||||
|
struct.unpack_from("<BBBBHHII", ico, offset)
|
||||||
|
)
|
||||||
|
sizes.add((width or 256, height or 256))
|
||||||
|
assert (planes, depth) == (1, 32)
|
||||||
|
assert ico[start : start + 8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert start + length <= len(ico)
|
||||||
|
assert sizes == {(16, 16), (32, 32), (48, 48)}
|
||||||
|
assert _png_size(ASSETS / "hermes-agent-192.png") == (192, 192)
|
||||||
|
assert _png_size(ASSETS / "hermes-agent-512.png") == (512, 512)
|
||||||
|
|
||||||
|
provenance = (ASSETS / "SOURCE.md").read_text(encoding="utf-8")
|
||||||
|
assert "/opt/hermes/web/public/favicon.ico" in provenance
|
||||||
|
assert EXPECTED_HASHES["hermes-agent.ico"] in provenance
|
||||||
|
assert "LANCZOS" in provenance
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_is_installable_scoped_and_uses_only_canonical_persona() -> None:
|
||||||
|
"""The app has both mandatory icon sizes and no remote or secret-bearing data."""
|
||||||
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
assert manifest["id"] == "./"
|
||||||
|
assert manifest["name"] == "Hermes Chat"
|
||||||
|
assert manifest["short_name"] == "Hermes"
|
||||||
|
assert manifest["start_url"].startswith("./")
|
||||||
|
assert manifest["scope"] == "./"
|
||||||
|
assert manifest["display"] == "standalone"
|
||||||
|
assert manifest["background_color"] == "#070A12"
|
||||||
|
assert manifest["theme_color"] == "#0D1420"
|
||||||
|
icons = manifest["icons"]
|
||||||
|
assert {(icon["sizes"], icon["type"]) for icon in icons} == {
|
||||||
|
("192x192", "image/png"),
|
||||||
|
("512x512", "image/png"),
|
||||||
|
}
|
||||||
|
assert {icon["src"] for icon in icons} == {
|
||||||
|
"static/hermes-agent-192.png",
|
||||||
|
"static/hermes-agent-512.png",
|
||||||
|
}
|
||||||
|
assert all(icon["purpose"] == "any" for icon in icons)
|
||||||
|
serialized = json.dumps(manifest).lower()
|
||||||
|
assert "http:" not in serialized and "https:" not in serialized
|
||||||
|
assert "secret" not in serialized and "token" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_patchers_apply_title_icons_theme_and_cache_contract(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Exercise the shipped patchers against pinned upstream source fragments."""
|
||||||
|
target = _patched_fixture(tmp_path)
|
||||||
|
index = (target / "static/index.html").read_text(encoding="utf-8")
|
||||||
|
worker = (target / "static/sw.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert index.count("<title>Hermes Chat</title>") == 1
|
||||||
|
assert index.count('id="hermesBrandStyles"') == 1
|
||||||
|
assert 'href="static/hermes-agent.ico"' in index
|
||||||
|
assert 'sizes="192x192" href="static/hermes-agent-192.png"' in index
|
||||||
|
assert 'sizes="512x512" href="static/hermes-agent-512.png"' in index
|
||||||
|
assert '<meta name="apple-mobile-web-app-title" content="Hermes Chat">' in index
|
||||||
|
assert '<meta name="theme-color" content="#0D1420"' in index
|
||||||
|
assert 'id="appTitlebarTitle">Hermes Chat</span>' in index
|
||||||
|
assert '<img src="static/hermes-agent-192.png" alt="">' in index
|
||||||
|
assert "favicon.svg" not in index
|
||||||
|
assert "favicon-32.png" not in index
|
||||||
|
|
||||||
|
assert worker.count("'./static/hermes-brand.css' + VQ") == 1
|
||||||
|
for name in EXPECTED_HASHES:
|
||||||
|
assert worker.count(f"'./static/{name}'") == 1
|
||||||
|
assert "favicon.svg" not in worker
|
||||||
|
assert "favicon-32.png" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_brand_patch_rejects_upstream_drift_before_partial_success(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A changed pinned title/favicon context cannot silently ship partial branding."""
|
||||||
|
target = _patched_fixture(tmp_path)
|
||||||
|
index = target / "static/index.html"
|
||||||
|
index.write_text(
|
||||||
|
index.read_text(encoding="utf-8").replace(
|
||||||
|
"<title>Hermes Chat</title>", "<title>Upstream changed</title>", 1
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(BRAND_PATCHER)],
|
||||||
|
cwd=ROOT,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "brand patch context changed" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
|
||||||
|
"""Identity colors retain system controls and disable cosmetic motion."""
|
||||||
|
css = BRAND_CSS.read_text(encoding="utf-8")
|
||||||
|
dark = css.split(":root.dark {", 1)[1]
|
||||||
|
assert "color-scheme: dark" in dark
|
||||||
|
assert "--bg: #070a12" in dark
|
||||||
|
assert "--text: #e8f1f4" in dark
|
||||||
|
assert "--accent: #48cfcc" in dark
|
||||||
|
assert "--voice-accent: 72, 207, 204" in css
|
||||||
|
assert "--voice-accent-secondary: 76, 164, 205" in css
|
||||||
|
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||||
|
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
||||||
|
assert "transition: none !important" in reduced
|
||||||
|
assert "animation:" not in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
|
||||||
|
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
|
||||||
|
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
||||||
|
assert "/opt/hermes/web/public/favicon.ico" not in dockerfile
|
||||||
|
assert "COPY dockerfiles/hermes-webui-brand-patch.py" in dockerfile
|
||||||
|
assert "python /tmp/hermes-webui-brand-patch.py" in dockerfile
|
||||||
|
assert "COPY dockerfiles/hermes-webui-manifest.json" in dockerfile
|
||||||
|
assert "COPY dockerfiles/hermes-webui-brand.css" in dockerfile
|
||||||
|
for name, digest in EXPECTED_HASHES.items():
|
||||||
|
assert f"COPY dockerfiles/hermes-webui-assets/{name}" in dockerfile
|
||||||
|
assert digest in dockerfile
|
||||||
425
testing/tests/test_hermes_webui_release.py
Normal file
425
testing/tests/test_hermes_webui_release.py
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
"""Independent build, Harbor evidence, and Flux handoff for Hermes WebUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PIPELINE = ROOT / "ci/Jenkinsfile.hermes-webui-image"
|
||||||
|
RELEASE = ROOT / "ci/scripts/hermes_webui_release.py"
|
||||||
|
DOCKERFILE = ROOT / "dockerfiles/Dockerfile.hermes-webui"
|
||||||
|
CHAT = ROOT / "services/hermes/chat-statefulset.yaml"
|
||||||
|
DASHBOARD = ROOT / "services/hermes/deployment.yaml"
|
||||||
|
POLICY = ROOT / "services/harbor/scripts/harbor_hermes_webui_immutability_ensure.py"
|
||||||
|
sys.path.insert(0, str(RELEASE.parent))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _pod_spec() -> dict:
|
||||||
|
source = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
pod_yaml = source.split('yaml """', 1)[1].split('"""', 1)[0]
|
||||||
|
return yaml.safe_load(pod_yaml)["spec"]
|
||||||
|
|
||||||
|
|
||||||
|
def _release_fixture(tmp_path: Path):
|
||||||
|
module = _load(RELEASE, f"hermes_webui_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 / "hermes-webui.digest"
|
||||||
|
image_file = tmp_path / "hermes-webui.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_file": digest_file,
|
||||||
|
"image_file": image_file,
|
||||||
|
"source_revision": revision,
|
||||||
|
"build_number": build,
|
||||||
|
"destination": destination,
|
||||||
|
"chat_manifest": CHAT,
|
||||||
|
"dashboard_manifest": DASHBOARD,
|
||||||
|
"output_dir": output,
|
||||||
|
}
|
||||||
|
module.write_release_artifacts(
|
||||||
|
digest=digest,
|
||||||
|
source_revision=revision,
|
||||||
|
build_number=build,
|
||||||
|
destination=destination,
|
||||||
|
chat_manifest=CHAT,
|
||||||
|
dashboard_manifest=DASHBOARD,
|
||||||
|
output_dir=output,
|
||||||
|
)
|
||||||
|
return module, digest, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_job_is_independent_manual_and_main_only() -> None:
|
||||||
|
"""WebUI has its own job and never widens the existing agent-only lane."""
|
||||||
|
config = yaml.safe_load(
|
||||||
|
(ROOT / "services/jenkins/configmap-jcasc.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
jobs = config["data"]["jobs.yaml"]
|
||||||
|
assert jobs.count("pipelineJob('hermes-agent-image')") == 1
|
||||||
|
assert jobs.count("pipelineJob('hermes-webui-image')") == 1
|
||||||
|
block = jobs.split("pipelineJob('hermes-webui-image')", 1)[1].split(
|
||||||
|
"multibranchPipelineJob(", 1
|
||||||
|
)[0]
|
||||||
|
assert "branches('*/main')" in block
|
||||||
|
assert "scriptPath('ci/Jenkinsfile.hermes-webui-image')" in block
|
||||||
|
assert "pipelineTriggers" not in block
|
||||||
|
assert "HERMES_AGENT_IMAGE_BUILD_TOKEN" not in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_builds_exact_reviewed_main_and_never_deploys() -> None:
|
||||||
|
"""Publish is explicit, immutable, evidence-producing, and Git/Flux review only."""
|
||||||
|
source = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
assert 'test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"' in source
|
||||||
|
assert 'test "${actual_revision}" = "$(git rev-parse origin/main)"' in source
|
||||||
|
assert "dockerfiles/Dockerfile.hermes-webui" in source
|
||||||
|
assert "ci/scripts/hermes_webui_release.py" in source
|
||||||
|
assert "registry.bstein.dev/bstein/hermes-webui" in source
|
||||||
|
assert "Dockerfile.hermes-agent" not in source
|
||||||
|
assert "hermes_image_release.py" not in source
|
||||||
|
assert "HERMES_KANIKO_HEREDOC_COMPAT" not in source
|
||||||
|
assert "--digest-file=" in source
|
||||||
|
assert "--image-name-tag-with-digest-file=" in source
|
||||||
|
assert "org.opencontainers.image.revision=${EXPECTED_SOURCE_REVISION}" in source
|
||||||
|
assert "assert-absent" in source and "verify-evidence" in source
|
||||||
|
assert "test_hermes_webui_brand.py" in source
|
||||||
|
assert "test_hermes_webui_release.py" in source
|
||||||
|
for forbidden in ("kubectl ", "flux reconcile", "git push", "git commit"):
|
||||||
|
assert forbidden not in source
|
||||||
|
|
||||||
|
spec = _pod_spec()
|
||||||
|
assert spec["serviceAccountName"] == "hermes-image-builder"
|
||||||
|
assert spec["automountServiceAccountToken"] is False
|
||||||
|
assert spec["enableServiceLinks"] is False
|
||||||
|
containers = {item["name"]: item for item in spec["containers"]}
|
||||||
|
assert "kaniko" in containers
|
||||||
|
assert "docker.sock" not in source and "hostPath" not in source
|
||||||
|
for container in containers.values():
|
||||||
|
assert container["securityContext"]["allowPrivilegeEscalation"] is False
|
||||||
|
assert container["securityContext"]["capabilities"]["drop"] == ["ALL"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_dockerfile_is_kaniko_safe_and_uses_reviewed_repo_source() -> None:
|
||||||
|
"""The dedicated build consumes tracked patches/assets without RUN heredocs."""
|
||||||
|
source = DOCKERFILE.read_text(encoding="utf-8")
|
||||||
|
assert "<<" not in source
|
||||||
|
assert "hermes-webui-base-patch.py" in source
|
||||||
|
assert "hermes-webui-brand-patch.py" in source
|
||||||
|
assert "hermes-webui-stt-patch.py" in source
|
||||||
|
assert "hermes-webui-atlas-voice.js" in source
|
||||||
|
assert "hermes-webui-manifest.json" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderer_updates_exact_chat_and_dashboard_webui_only(tmp_path: Path) -> None:
|
||||||
|
"""One digest patch spans the two same-policy WebUI consumers and nothing else."""
|
||||||
|
module, digest, kwargs = _release_fixture(tmp_path)
|
||||||
|
output = kwargs["output_dir"]
|
||||||
|
patch = (output / "hermes-webui-image-update.patch").read_text(encoding="utf-8")
|
||||||
|
assert patch.count(f"+ image: {module.DEFAULT_IMAGE}@{digest}") == 2
|
||||||
|
assert "services/hermes/chat-statefulset.yaml" in patch
|
||||||
|
assert "services/hermes/deployment.yaml" in patch
|
||||||
|
assert "hermes-agent@sha256" not in "\n".join(
|
||||||
|
line for line in patch.splitlines() if line.startswith("+")
|
||||||
|
)
|
||||||
|
assert CHAT.read_text(encoding="utf-8") != (
|
||||||
|
output / "hermes-chat-statefulset.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert DASHBOARD.read_text(encoding="utf-8") != (
|
||||||
|
output / "hermes-dashboard-deployment.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
metadata = json.loads((output / "hermes-webui-image.json").read_text())
|
||||||
|
assert metadata["digest"] == digest
|
||||||
|
assert metadata["flux_image"] == f"{module.DEFAULT_IMAGE}@{digest}"
|
||||||
|
assert metadata["flux_targets"] == [
|
||||||
|
"apps/StatefulSet/hermes/hermes-chat-tenant",
|
||||||
|
"apps/Deployment/hermes/hermes",
|
||||||
|
]
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("source", "kind", "name", "match"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: wrong\n",
|
||||||
|
"Deployment",
|
||||||
|
"hermes",
|
||||||
|
"identity changed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes\n",
|
||||||
|
"Deployment",
|
||||||
|
"hermes",
|
||||||
|
"found 0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes\n"
|
||||||
|
"spec:\n image: registry.bstein.dev/bstein/hermes-webui:latest\n",
|
||||||
|
"Deployment",
|
||||||
|
"hermes",
|
||||||
|
"found 0",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_renderer_fails_closed_on_flux_target_drift(
|
||||||
|
source: str, kind: str, name: str, match: str
|
||||||
|
) -> None:
|
||||||
|
module = _load(RELEASE, f"webui_renderer_{abs(hash(source))}")
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
module.render_workload(source, "sha256:" + "a" * 64, kind=kind, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_revalidation_rejects_extra_or_changed_files(tmp_path: Path) -> None:
|
||||||
|
"""Archived output is an exact deterministic set, not a best-effort bundle."""
|
||||||
|
module, _digest, kwargs = _release_fixture(tmp_path)
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
extra = kwargs["output_dir"] / "unexpected"
|
||||||
|
extra.write_text("surprise\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="exactly four"):
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
extra.unlink()
|
||||||
|
metadata = kwargs["output_dir"] / "hermes-webui-image.json"
|
||||||
|
metadata.write_text("{}\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="incomplete or mismatched"):
|
||||||
|
module.validate_release_artifacts(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class _Response(io.BytesIO):
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
def __init__(self, body: bytes, headers: dict[str, str] | None = None):
|
||||||
|
super().__init__(body)
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_verifies_exact_webui_harbor_artifact_and_policy() -> None:
|
||||||
|
"""Independent evidence resolves the tag and exact WebUI immutability rule."""
|
||||||
|
module = _load(RELEASE, "webui_registry_contract")
|
||||||
|
revision = "a" * 40
|
||||||
|
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-9"
|
||||||
|
digest = "sha256:" + "b" * 64
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def artifact_open(request, timeout):
|
||||||
|
seen.append((request, timeout))
|
||||||
|
return _Response(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"digest": digest,
|
||||||
|
"tags": [{"name": f"git-{revision}-build-9", "immutable": True}],
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
module.verify_registry_digest(
|
||||||
|
destination, digest, username="robot", password="private", opener=artifact_open
|
||||||
|
)
|
||||||
|
request = seen[0][0]
|
||||||
|
assert "/repositories/hermes-webui/artifacts/" in request.full_url
|
||||||
|
assert request.full_url.startswith("https://registry.bstein.dev/api/v2.0/")
|
||||||
|
assert request.get_header("Authorization").startswith("Basic ")
|
||||||
|
assert seen[0][1] == 20
|
||||||
|
|
||||||
|
def policy_open(request, timeout):
|
||||||
|
assert request.full_url.endswith(
|
||||||
|
"/projects/bstein/immutabletagrules?page=1&page_size=100"
|
||||||
|
)
|
||||||
|
return _Response(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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-webui",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
).encode(),
|
||||||
|
{"X-Total-Count": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
module.verify_immutable_policy(
|
||||||
|
username="robot", password="private", opener=policy_open
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flux_tracks_webui_policy_before_jenkins() -> None:
|
||||||
|
"""The immutable Harbor rule is reviewed desired state, not a pipeline wish."""
|
||||||
|
harbor = yaml.safe_load(
|
||||||
|
(
|
||||||
|
ROOT / "clusters/atlas/flux-system/applications/harbor/kustomization.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
checks = harbor["spec"]["healthChecks"]
|
||||||
|
assert {
|
||||||
|
"apiVersion": "batch/v1",
|
||||||
|
"kind": "Job",
|
||||||
|
"name": "harbor-hermes-webui-immutability-ensure-1",
|
||||||
|
"namespace": "harbor",
|
||||||
|
} in checks
|
||||||
|
jenkins = yaml.safe_load(
|
||||||
|
(
|
||||||
|
ROOT / "clusters/atlas/flux-system/applications/jenkins/kustomization.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert "harbor" in {item["name"] for item in jenkins["spec"]["dependsOn"]}
|
||||||
|
|
||||||
|
policy = _load(POLICY, "webui_policy_contract")
|
||||||
|
assert policy.REPOSITORY_PATTERN == "hermes-webui"
|
||||||
|
assert policy.TAG_PATTERN == "git-*-build-*"
|
||||||
|
assert policy.EXPECTED_RULE["disabled"] is False
|
||||||
|
assert "robot" not in POLICY.read_text(encoding="utf-8").lower()
|
||||||
|
|
||||||
|
job = yaml.safe_load(
|
||||||
|
(ROOT / "services/harbor/hermes-webui-immutability-job.yaml").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"
|
||||||
|
)
|
||||||
|
pod = template["spec"]
|
||||||
|
assert pod["serviceAccountName"] == "harbor-policy-bootstrap"
|
||||||
|
assert pod["enableServiceLinks"] is False
|
||||||
|
container = pod["containers"][0]
|
||||||
|
assert "@sha256:" in container["image"]
|
||||||
|
assert container["securityContext"]["readOnlyRootFilesystem"] is True
|
||||||
|
assert container["securityContext"]["runAsNonRoot"] is True
|
||||||
|
assert container["securityContext"]["capabilities"]["drop"] == ["ALL"]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePolicyClient:
|
||||||
|
origin = "https://registry.bstein.dev/api/v2.0"
|
||||||
|
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def request(self, method, path, payload=None):
|
||||||
|
self.calls.append((method, path, payload))
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_policy_is_idempotent_and_create_is_reread() -> None:
|
||||||
|
"""The desired Harbor rule validates in place or verifies its exact new ID."""
|
||||||
|
policy = _load(POLICY, "webui_policy_idempotency")
|
||||||
|
existing = {"id": 17, **policy.EXPECTED_RULE}
|
||||||
|
client = _FakePolicyClient(
|
||||||
|
[(200, json.dumps([existing]).encode(), {"X-Total-Count": "1"})]
|
||||||
|
)
|
||||||
|
assert policy.ensure_rule(client) == 17
|
||||||
|
assert [call[0] for call in client.calls] == ["GET"]
|
||||||
|
|
||||||
|
created = {"id": 23, **policy.EXPECTED_RULE}
|
||||||
|
client = _FakePolicyClient(
|
||||||
|
[
|
||||||
|
(200, b"[]", {"X-Total-Count": "0"}),
|
||||||
|
(
|
||||||
|
201,
|
||||||
|
b"",
|
||||||
|
{"Location": ("/api/v2.0/projects/bstein/immutabletagrules/23")},
|
||||||
|
),
|
||||||
|
(200, json.dumps([created]).encode(), {"X-Total-Count": "1"}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert policy.ensure_rule(client) == 23
|
||||||
|
assert client.calls[1] == (
|
||||||
|
"POST",
|
||||||
|
"/projects/bstein/immutabletagrules",
|
||||||
|
policy.EXPECTED_RULE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_policy_rejects_disabled_duplicate_or_truncated_rules() -> None:
|
||||||
|
"""Ambiguous or incomplete Harbor evidence can never unblock publication."""
|
||||||
|
policy = _load(POLICY, "webui_policy_rejections")
|
||||||
|
disabled = {"id": 17, **policy.EXPECTED_RULE, "disabled": True}
|
||||||
|
with pytest.raises(RuntimeError, match="not enabled and exact"):
|
||||||
|
policy.ensure_rule(
|
||||||
|
_FakePolicyClient(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
json.dumps([disabled]).encode(),
|
||||||
|
{"X-Total-Count": "1"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate = [
|
||||||
|
{"id": 17, **policy.EXPECTED_RULE},
|
||||||
|
{"id": 18, **policy.EXPECTED_RULE},
|
||||||
|
]
|
||||||
|
with pytest.raises(RuntimeError, match="multiple Hermes WebUI"):
|
||||||
|
policy.ensure_rule(
|
||||||
|
_FakePolicyClient(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
json.dumps(duplicate).encode(),
|
||||||
|
{"X-Total-Count": "2"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="truncated"):
|
||||||
|
policy.list_rules(_FakePolicyClient([(200, b"[]", {"X-Total-Count": "1"})]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_archives_exact_seven_files() -> None:
|
||||||
|
"""Publication cannot pass with missing digest, workload, or metadata evidence."""
|
||||||
|
source = PIPELINE.read_text(encoding="utf-8")
|
||||||
|
post = source.split(" post {", 1)[1]
|
||||||
|
archive = post.split("artifacts: '", 1)[1].split("'", 1)[0].split(",")
|
||||||
|
assert len(archive) == len(set(archive)) == 7
|
||||||
|
assert all("*" not in path for path in archive)
|
||||||
|
assert "find build -type f" in post
|
||||||
|
assert "allowEmptyArchive: false" in post
|
||||||
|
assert "hermes-chat-statefulset.yaml" in post
|
||||||
|
assert "hermes-dashboard-deployment.yaml" in post
|
||||||
Loading…
x
Reference in New Issue
Block a user