Add reviewed Hermes WebUI release lane
This commit is contained in:
parent
81b3e6b992
commit
735a2b240d
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
|
||||
name: harbor-hermes-agent-immutability-ensure-1
|
||||
namespace: harbor
|
||||
- apiVersion: batch/v1
|
||||
kind: Job
|
||||
name: harbor-hermes-webui-immutability-ensure-1
|
||||
namespace: harbor
|
||||
dependsOn:
|
||||
- name: core
|
||||
- name: longhorn
|
||||
|
||||
@ -10,85 +10,9 @@ USER root
|
||||
# while the gateway remains the only process that owns an agent conversation.
|
||||
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
|
||||
# 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-stt-patch.py /tmp/hermes-webui-stt-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-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-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-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-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' \
|
||||
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
||||
/opt/hermes-webui/api/config.py \
|
||||
&& ! 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 "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
||||
&& 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 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
|
||||
&& 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-webui/api/routes.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
|
||||
- policy-bootstrap-serviceaccount.yaml
|
||||
- hermes-agent-immutability-job.yaml
|
||||
- hermes-webui-immutability-job.yaml
|
||||
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
||||
- image.yaml
|
||||
configMapGenerator:
|
||||
@ -23,3 +24,6 @@ configMapGenerator:
|
||||
- name: harbor-hermes-agent-immutability-script
|
||||
files:
|
||||
- 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') {
|
||||
branchSources {
|
||||
branchSource {
|
||||
|
||||
@ -1,9 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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__">
|
||||
</head>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
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