Compare commits

..

No commits in common. "main" and "feature/hermes-next-hux" have entirely different histories.

805 changed files with 2854 additions and 101158 deletions

25
Jenkinsfile vendored
View File

@ -2,16 +2,11 @@
pipeline {
agent {
kubernetes {
// Keep build I/O off the node's runtime USB drive.
workspaceVolume dynamicPVC(accessModes: 'ReadWriteOnce', requestsSize: '20Gi', storageClassName: 'ci-scratch')
defaultContainer 'python'
yaml """
apiVersion: v1
kind: Pod
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
@ -20,9 +15,12 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- {key: hardware, operator: In, values: [rpi5, rpi4]}
- {key: node-role.kubernetes.io/worker, operator: In, values: ["true"]}
- {key: kubernetes.io/hostname, operator: NotIn, values: [titan-04, titan-05, titan-06, titan-08, titan-11, titan-12, titan-13, titan-14, titan-15, titan-17, titan-18, titan-19]}
- key: kubernetes.io/hostname
operator: NotIn
values:
- titan-04
- titan-06
- titan-11
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
@ -67,10 +65,6 @@ spec:
}
}
environment {
PIP_CACHE_DIR = '/home/jenkins/agent/.cache/pip'
NPM_CONFIG_CACHE = '/home/jenkins/agent/.cache/npm'
SONAR_USER_HOME = '/home/jenkins/agent/.cache/sonar'
TMPDIR = '/home/jenkins/agent/.cache/tmp'
PIP_DISABLE_PIP_VERSION_CHECK = '1'
PYTHONUNBUFFERED = '1'
SUITE_NAME = 'titan_iac'
@ -92,11 +86,6 @@ spec:
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '200', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '120'))
}
stages {
stage('Prepare build scratch') {
steps {
sh 'mkdir -p /home/jenkins/agent/.cache/tmp /home/jenkins/agent/.cache/go-tmp'
}
}
stage('Checkout') {
steps {
checkout scm
@ -491,7 +480,7 @@ PY
set +x
git config user.email "jenkins@bstein.dev"
git config user.name "jenkins"
git remote set-url origin https://${GIT_USER}:${GIT_TOKEN}@scm.bstein.dev/titan/atlas-iac.git
git remote set-url origin https://${GIT_USER}:${GIT_TOKEN}@scm.bstein.dev/atlas/titan-iac.git
git push origin HEAD:${FLUX_BRANCH}
'''
}

View File

@ -1,9 +1,9 @@
# atlas-iac
# titan-iac
Flux-managed Kubernetes desired-state config for `bstein.dev`.
Canonical source URL:
- `ssh://git@scm.bstein.dev:2242/titan/atlas-iac.git`
- `ssh://git@scm.bstein.dev:2242/atlas/titan-iac.git`
## Scope
@ -15,69 +15,3 @@ This repo contains cluster configuration consumed by Flux:
## Apply model
I use Git + Flux as the source of truth and avoid manual in-cluster edits for durable changes.
## Finding the configuration
Start with the reference chain, not a search for every file mentioning an app:
1. `clusters/atlas/flux-system/kustomization.yaml` includes the platform and application Flux definitions.
2. A definition under `clusters/atlas/flux-system/platform/` or `applications/` names the folder Flux reconciles in `spec.path`.
3. That folder's `kustomization.yaml` lists the resources and patches actually included. A file elsewhere in the repository is not automatically deployed.
4. A `HelmRelease` selects a chart and its values; a `Deployment` or `StatefulSet` defines the workload directly.
For example, Grafana and metrics configuration starts at
`clusters/atlas/flux-system/platform/monitoring/kustomization.yaml`, which points
to `services/monitoring/`. Dashboard source is generated by
`scripts/render/dashboards_render_atlas.py`; edit the generator when changing
generated dashboards.
| Location | Purpose |
|---|---|
| `infrastructure/` | Shared platform components such as networking, storage and PostgreSQL |
| `services/<name>/` | Application resources, settings and any service-specific `NOTES.md` |
| `services/maintenance/` | In-cluster maintenance tool configuration, including Soteria, Metis and Ariadne |
| `scripts/ops/` | Operator commands; inspect the script and its documented options before use |
| `dockerfiles/` | Custom image definitions |
Ananke also runs outside Kubernetes on hosts. Its deployed host configuration
and version must be checked separately; this repository alone is not yet a
complete description of every host-side setting.
## First checks when something is wrong
Run these read-only commands on the existing administrative host:
```bash
kubectl get nodes -o wide
kubectl get deployments,statefulsets -A
kubectl get kustomizations.kustomize.toolkit.fluxcd.io -A
kubectl get helmreleases.helm.toolkit.fluxcd.io -A
```
For one affected service, inspect its namespace and warning events:
```bash
NS=monitoring
kubectl -n "$NS" get pods -o wide
kubectl -n "$NS" get events --field-selector type=Warning --sort-by=.lastTimestamp
kubectl -n "$NS" get pvc
```
`Pending` with scheduling errors usually points to capacity or placement;
`FailedMount` points to storage; `Running` without readiness points to the
application, its probe or a dependency. Use the specific evidence before
choosing a repair. Avoid starting with a cluster-wide restart or recovery script.
Start with [Cluster operations](docs/CLUSTER_OPERATIONS.md) for the daily checks,
change workflow and recovery ownership. The
[implementation record](docs/CLUSTER_STABILIZATION.md) distinguishes verified
repairs from remaining faults. A green Flux/Helm status alone is not proof of
application health or restore readiness.
## Operating principle
Ordinary Kubernetes and component configuration should handle normal operation.
Homegrown recovery tools should cover specific demonstrated gaps. Core cluster
operation and recovery must not require an AI assistant or a model-backed
decision. A procedure should explain what it changes, how to check success and
how to recover if it fails; conversation history is not operational documentation.

View File

@ -28,7 +28,6 @@ spec:
operator: NotIn
values:
- titan-04
- titan-08
- titan-14
- titan-18
- titan-19
@ -100,14 +99,9 @@ spec:
requests:
cpu: 250m
memory: 1Gi
# The agent image build expands the SQLite and web build layers in
# its workspace. Reserve enough node-local space to keep it off the
# 25 GiB workers, whose unrequested Kaniko workspace was evicted.
ephemeral-storage: 32Gi
limits:
cpu: "2"
memory: 4Gi
ephemeral-storage: 40Gi
"""
}
}
@ -120,7 +114,7 @@ spec:
string(
name: 'EXPECTED_SOURCE_REVISION',
defaultValue: '',
description: 'Full reviewed commit already contained by titan/atlas-iac main.'
description: 'Full reviewed commit already contained by atlas/titan-iac main.'
)
string(
name: 'CONFIRM_PUBLISH',
@ -184,15 +178,7 @@ spec:
container('python') {
sh '''
set -eu
# Install the pinned test deps fully offline from the reviewed,
# in-repo wheelhouse (ci/vendor/hermes-agent-test-wheels). --no-index
# forbids any network index, so this stage never resolves
# pypi.org/files.pythonhosted.org and cannot fail on public-internet
# DNS. The wheels match this pipeline's arm64 python:3.12 container
# (PyYAML is the cp312 manylinux aarch64 build; the rest are
# py3-none-any). Bump the wheelhouse when these pins change.
python3 -m pip install --disable-pip-version-check --no-cache-dir \
--no-index --find-links="${WORKSPACE}/ci/vendor/hermes-agent-test-wheels" \
--target=/tmp/hermes-agent-release-test-deps \
pytest==8.3.4 PyYAML==6.0.2
PYTHONPATH=/tmp/hermes-agent-release-test-deps \
@ -202,7 +188,6 @@ spec:
testing/tests/test_hermes_image_builder_coverage.py \
testing/tests/test_hermes_image_builder_fresh_review.py \
testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_multiarch_combine.py \
testing/tests/test_hermes_image_automation.py
'''
}
@ -228,24 +213,9 @@ spec:
}
}
}
stage('Build arm64 leg without a daemon') {
stage('Build and publish without a daemon') {
steps {
container('kaniko') {
sh '''#!/busybox/sh
set -eu
minimum_available_kib=16777216
available_kib="$(/busybox/df -Pk / | /busybox/awk 'NR == 2 { print $4 }')"
case "${available_kib}" in
''|*[!0-9]*)
echo "cannot determine Kaniko ephemeral-storage availability" >&2
exit 2
;;
esac
if [ "${available_kib}" -lt "${minimum_available_kib}" ]; then
echo "Kaniko requires at least 16 GiB of free ephemeral storage" >&2
exit 1
fi
'''
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
@ -255,7 +225,7 @@ spec:
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-agent.destination)-arm64"
destination="$(cat build/hermes-agent.destination)"
source_revision="$(cat build/hermes-agent.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
@ -270,200 +240,18 @@ spec:
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-agent" \
--destination="${destination}" \
--digest-file="${WORKSPACE}/build/hermes-agent-arm64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-agent-arm64.image" \
--digest-file="${WORKSPACE}/build/hermes-agent.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-agent.image" \
--build-arg=HERMES_KANIKO_HEREDOC_COMPAT=1 \
--label="org.opencontainers.image.revision=${source_revision}" \
--cleanup \
--push-retry=3
/busybox/chmod 644 build/hermes-agent-arm64.digest build/hermes-agent-arm64.image
/busybox/chmod 644 build/hermes-agent.digest build/hermes-agent.image
'''
}
}
}
}
stage('Build amd64 leg without a daemon') {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
atlas.bstein.dev/workload: hermes-agent-image-builder-amd64
spec:
serviceAccountName: hermes-image-builder
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# titan-24 is an accelerator node (not a general worker) that co-hosts the
# out-of-cluster Sui validator. Pin the disposable amd64 build to it by
# hostname + arch ONLY — do NOT require node-role worker, so titan-24 is never
# opened to general cluster scheduling. The toleration + tight caps below keep
# this off the validator's back.
nodeSelector:
kubernetes.io/arch: amd64
kubernetes.io/hostname: titan-24
tolerations:
# titan-24 co-hosts the out-of-cluster Sui validator; tolerate whatever
# PreferNoSchedule/NoSchedule guard taint the node carries so the pinned
# build lands, and rely on the tight resource caps below (not scheduling
# priority) to keep the disposable build from starving the validator.
- operator: Exists
imagePullSecrets:
- name: harbor-bstein-robot
containers:
- name: jnlp
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 250m
memory: 384Mi
- name: kaniko
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
command: ["/busybox/sh", "-c"]
args: ["/busybox/sleep 99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
privileged: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "1500m"
memory: 3Gi
"""
}
}
steps {
// This amd64 leg runs on its own fresh pod (titan-24), so it must check
// out the SCM itself before the reviewed-revision git boundary check —
// otherwise `git rev-parse origin/main` fails with "not a git repository".
checkout scm
container('jnlp') {
sh '''
set -eu
mkdir -p build
test "${PUBLISH_IMAGE}" = "true"
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES AGENT"
case "${EXPECTED_SOURCE_REVISION}" in
*[!0-9a-f]*|'')
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
exit 2
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-agent
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-agent.destination
printf '%s\n' "${actual_revision}" > build/hermes-agent.source-revision
'''
}
container('kaniko') {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''#!/busybox/sh
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-agent.destination)-amd64"
source_revision="$(cat build/hermes-agent.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
/busybox/mkdir -p /kaniko/.docker
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
unset HARBOR_USER HARBOR_PASSWORD auth
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
umask 022
/kaniko/executor \
--registry-mirror=harbor-core.harbor.svc.cluster.local \
--insecure-registry=harbor-core.harbor.svc.cluster.local \
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-agent" \
--destination="${destination}" \
--digest-file="${WORKSPACE}/build/hermes-agent-amd64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-agent-amd64.image" \
--build-arg=HERMES_KANIKO_HEREDOC_COMPAT=1 \
--label="org.opencontainers.image.revision=${source_revision}" \
--cleanup \
--push-retry=3
/busybox/chmod 644 build/hermes-agent-amd64.digest build/hermes-agent-amd64.image
'''
}
}
stash(
name: 'hermes-agent-amd64-evidence',
includes: 'build/hermes-agent-amd64.digest,build/hermes-agent-amd64.image'
)
}
}
stage('Combine multi-arch index') {
steps {
unstash 'hermes-agent-amd64-evidence'
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-agent.destination)"
source_revision="$(cat build/hermes-agent.source-revision)"
python3 ci/scripts/hermes_multiarch_combine.py \
--destination "${destination}" \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--arm64-digest-file build/hermes-agent-arm64.digest \
--arm64-image-file build/hermes-agent-arm64.image \
--amd64-digest-file build/hermes-agent-amd64.digest \
--amd64-image-file build/hermes-agent-amd64.image \
--digest-file build/hermes-agent.digest \
--image-file build/hermes-agent.image
test -s build/hermes-agent.digest
test -s build/hermes-agent.image
'''
}
}
}
stage('Render reviewed Flux handoff') {
steps {
withCredentials([usernamePassword(
@ -499,10 +287,6 @@ spec:
build/hermes-agent.digest \
build/hermes-agent.image \
build/hermes-agent.source-revision \
build/hermes-agent-arm64.digest \
build/hermes-agent-arm64.image \
build/hermes-agent-amd64.digest \
build/hermes-agent-amd64.image \
build/hermes-agent-release/hermes-agent-image.json \
build/hermes-agent-release/hermes-image-update.patch \
build/hermes-agent-release/hermes-kustomization.yaml \
@ -521,7 +305,7 @@ spec:
--output-dir build/hermes-agent-release
'''
archiveArtifacts(
artifacts: 'build/hermes-agent.destination,build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent.source-revision,build/hermes-agent-arm64.digest,build/hermes-agent-arm64.image,build/hermes-agent-amd64.digest,build/hermes-agent-amd64.image,build/hermes-agent-release/hermes-agent-image.json,build/hermes-agent-release/hermes-image-update.patch,build/hermes-agent-release/hermes-kustomization.yaml',
artifacts: 'build/hermes-agent.destination,build/hermes-agent.digest,build/hermes-agent.image,build/hermes-agent.source-revision,build/hermes-agent-release/hermes-agent-image.json,build/hermes-agent-release/hermes-image-update.patch,build/hermes-agent-release/hermes-kustomization.yaml',
allowEmptyArchive: false,
fingerprint: true
)

View File

@ -1,339 +0,0 @@
pipeline {
agent {
kubernetes {
defaultContainer 'python'
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
atlas.bstein.dev/workload: hermes-chat-router-image-builder
spec:
serviceAccountName: hermes-image-builder
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
nodeSelector:
kubernetes.io/arch: arm64
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [titan-20]
imagePullSecrets:
- name: harbor-bstein-robot
containers:
- name: jnlp
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi}
- name: python
image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
command: ["sleep"]
args: ["99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi}
- name: golang
image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
command: ["sleep"]
args: ["99d"]
tty: true
env:
- {name: GOCACHE, value: /tmp/go-cache}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
- name: kaniko
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
command: ["/busybox/sh", "-c"]
args: ["/busybox/sleep 99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
privileged: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 2Gi
limits:
cpu: "1"
memory: 2Gi
ephemeral-storage: 5Gi
"""
}
}
parameters {
booleanParam(
name: 'PUBLISH_IMAGE',
defaultValue: false,
description: 'Publish the reviewed main revision to Harbor.'
)
string(
name: 'EXPECTED_SOURCE_REVISION',
defaultValue: '',
description: 'Full reviewed commit that must be contained by titan/atlas-iac main.'
)
string(
name: 'CONFIRM_PUBLISH',
defaultValue: '',
description: 'Enter PUBLISH HERMES CHAT ROUTER to confirm the release.'
)
}
environment {
HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-chat-router'
}
options {
disableConcurrentBuilds()
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
skipDefaultCheckout(true)
timeout(time: 45, unit: 'MINUTES')
}
stages {
stage('Checkout reviewed source') {
steps {
checkout scm
}
}
stage('Enforce release boundary') {
steps {
container('jnlp') {
sh '''
set -eu
mkdir -p build
test "${PUBLISH_IMAGE}" = "true"
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES CHAT ROUTER"
case "${EXPECTED_SOURCE_REVISION}" in
*[!0-9a-f]*|'')
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
exit 2
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-chat-router
case "${BUILD_NUMBER}" in
''|0*|*[!0-9]*)
echo "BUILD_NUMBER must be a positive decimal integer" >&2
exit 2
;;
esac
printf '%s\n' \
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
> build/hermes-chat-router.destination
printf '%s\n' "${actual_revision}" \
> build/hermes-chat-router.source-revision
'''
}
}
}
stage('Validate reviewed router source') {
steps {
container('python') {
sh '''
set -eu
python3 -m pip install --disable-pip-version-check --no-cache-dir \
--target=/tmp/hermes-chat-router-test-deps \
pytest==8.3.4 PyYAML==6.0.2
PYTHONPATH=/tmp/hermes-chat-router-test-deps \
python3 -m pytest -q \
testing/tests/test_hermes_chat_router_release.py \
testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_image_automation.py
'''
}
container('golang') {
sh '''
set -eu
cd services/hermes/router
GO111MODULE=off CGO_ENABLED=0 go test ./...
'''
}
}
}
stage('Reject replay before publish') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py assert-absent \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}"
'''
}
}
}
stage('Build and publish without a daemon') {
steps {
container('kaniko') {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''#!/busybox/sh
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
/busybox/mkdir -p /kaniko/.docker
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
unset HARBOR_USER HARBOR_PASSWORD auth
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
umask 022
/kaniko/executor \
--registry-mirror=harbor-core.harbor.svc.cluster.local \
--insecure-registry=harbor-core.harbor.svc.cluster.local \
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-chat-router" \
--destination="${destination}" \
--digest-file="${WORKSPACE}/build/hermes-chat-router.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-chat-router.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/titan/atlas-iac" \
--label="org.opencontainers.image.title=hermes-chat-router" \
--cleanup \
--push-retry=3
/busybox/chmod 644 \
build/hermes-chat-router.digest build/hermes-chat-router.image
'''
}
}
}
}
stage('Render reviewed Flux handoff') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py render \
--digest-file build/hermes-chat-router.digest \
--image-file build/hermes-chat-router.image \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}" \
--manifest services/hermes/chat-router.yaml \
--output-dir build/hermes-chat-router-release
'''
}
}
}
stage('Verify and archive release evidence') {
steps {
sh '''
set -eu
expected_files="$(printf '%s\n' \
build/hermes-chat-router.destination \
build/hermes-chat-router.digest \
build/hermes-chat-router.image \
build/hermes-chat-router.source-revision \
build/hermes-chat-router-release/hermes-chat-router-deployment.yaml \
build/hermes-chat-router-release/hermes-chat-router-image-update.patch \
build/hermes-chat-router-release/hermes-chat-router-image.json \
| LC_ALL=C sort)"
actual_files="$(find build -type f -print | LC_ALL=C sort)"
test "${actual_files}" = "${expected_files}"
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_chat_router_release.py verify-evidence \
--digest-file build/hermes-chat-router.digest \
--image-file build/hermes-chat-router.image \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--destination "${destination}" \
--manifest services/hermes/chat-router.yaml \
--output-dir build/hermes-chat-router-release
'''
archiveArtifacts(
artifacts: 'build/hermes-chat-router.destination,build/hermes-chat-router.digest,build/hermes-chat-router.image,build/hermes-chat-router.source-revision,build/hermes-chat-router-release/hermes-chat-router-deployment.yaml,build/hermes-chat-router-release/hermes-chat-router-image-update.patch,build/hermes-chat-router-release/hermes-chat-router-image.json',
allowEmptyArchive: false,
fingerprint: true
)
}
}
stage('Publish Flux release tag') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-chat-router.destination)"
source_revision="$(cat build/hermes-chat-router.source-revision)"
python3 ci/scripts/hermes_oci_promote.py \
--destination "${destination}" \
--digest-file build/hermes-chat-router.digest \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}"
'''
}
}
}
}
}

View File

@ -195,7 +195,7 @@ spec:
--digest-file="${WORKSPACE}/build/hermes-voice.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-voice.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/titan/atlas-iac" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
--label="org.opencontainers.image.title=hermes-jetson-${component}" \
--cleanup --push-retry=3
/busybox/chmod 644 build/hermes-voice.digest build/hermes-voice.image

View File

@ -106,7 +106,7 @@ spec:
string(
name: 'EXPECTED_SOURCE_REVISION',
defaultValue: '',
description: 'Full reviewed commit that must be contained by titan/atlas-iac main.'
description: 'Full reviewed commit that must be contained by atlas/titan-iac main.'
)
string(
name: 'CONFIRM_PUBLISH',
@ -192,30 +192,8 @@ spec:
testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_webui_brand.py \
testing/tests/test_hermes_webui_release.py \
testing/tests/test_hermes_webui_hux_bff.py \
testing/tests/test_hermes_webui_hux_context.py \
testing/tests/test_hermes_webui_hux_integration.py \
testing/tests/test_hermes_webui_hux_backend_e2e.py \
testing/tests/test_hermes_hux_ui_runtime_wave_a.py \
testing/tests/test_hermes_hux_runtime_autonomy_privacy.py \
testing/tests/test_hermes_hux_runtime_stop.py \
testing/tests/test_hermes_hux_ui_runtime_wave_b.py \
testing/tests/test_hermes_hux_runtime_wave_c.py \
testing/tests/test_hermes_hux_runtime_plugin.py \
testing/tests/test_hermes_hux_runtime_vendor_parity.py \
testing/tests/test_hermes_hux_delivery.py \
testing/tests/test_hermes_oci_promote.py \
testing/tests/test_hermes_multiarch_combine.py \
testing/tests/test_hermes_image_automation.py
HUX_BACKEND_TESTS="$(find testing/tests -maxdepth 1 -type f \
-name 'test_hermes_hux_*.py' \
! -name '*_ui_*' \
! -name '*runtime*' \
! -name '*delivery*' \
| sort)"
test -n "${HUX_BACKEND_TESTS}"
PYTHONPATH=/tmp/hermes-webui-release-test-deps \
python3 -m pytest -q ${HUX_BACKEND_TESTS}
'''
}
}
@ -240,7 +218,7 @@ spec:
}
}
}
stage('Build arm64 leg without a daemon') {
stage('Build and publish without a daemon') {
steps {
container('kaniko') {
withCredentials([usernamePassword(
@ -252,7 +230,7 @@ spec:
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-webui.destination)-arm64"
destination="$(cat build/hermes-webui.destination)"
source_revision="$(cat build/hermes-webui.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
@ -268,206 +246,19 @@ spec:
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
--destination="${destination}" \
--build-arg="HERMES_WEBUI_RELEASE_ID=git-${source_revision}-build-${BUILD_NUMBER}" \
--digest-file="${WORKSPACE}/build/hermes-webui-arm64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui-arm64.image" \
--digest-file="${WORKSPACE}/build/hermes-webui.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/titan/atlas-iac" \
--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-arm64.digest build/hermes-webui-arm64.image
/busybox/chmod 644 build/hermes-webui.digest build/hermes-webui.image
'''
}
}
}
}
stage('Build amd64 leg without a daemon') {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
atlas.bstein.dev/workload: hermes-webui-image-builder-amd64
spec:
serviceAccountName: hermes-image-builder
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
# titan-24 is an accelerator node (not a general worker) that co-hosts the
# out-of-cluster Sui validator. Pin the disposable amd64 build to it by
# hostname + arch ONLY — do NOT require node-role worker, so titan-24 is never
# opened to general cluster scheduling. The toleration + tight caps below keep
# this off the validator's back.
nodeSelector:
kubernetes.io/arch: amd64
kubernetes.io/hostname: titan-24
tolerations:
# titan-24 co-hosts the out-of-cluster Sui validator; tolerate whatever
# PreferNoSchedule/NoSchedule guard taint the node carries so the pinned
# build lands, and rely on the tight resource caps below (not scheduling
# priority) to keep the disposable build from starving the validator.
- operator: Exists
imagePullSecrets:
- name: harbor-bstein-robot
containers:
- name: jnlp
image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
cpu: 250m
memory: 384Mi
- name: kaniko
image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
command: ["/busybox/sh", "-c"]
args: ["/busybox/sleep 99d"]
tty: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
privileged: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 100m
memory: 512Mi
ephemeral-storage: 10Gi
limits:
cpu: "1500m"
memory: 3Gi
ephemeral-storage: 20Gi
"""
}
}
steps {
// This amd64 leg runs on its own fresh pod (titan-24), so it must check
// out the SCM itself before the reviewed-revision git boundary check —
// otherwise `git rev-parse origin/main` fails with "not a git repository".
checkout scm
container('jnlp') {
sh '''
set -eu
mkdir -p build
test "${PUBLISH_IMAGE}" = "true"
test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"
case "${EXPECTED_SOURCE_REVISION}" in
*[!0-9a-f]*|'')
echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
exit 2
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-webui
case "${BUILD_NUMBER}" in
''|0*|*[!0-9]*)
echo "BUILD_NUMBER must be a positive decimal integer" >&2
exit 2
;;
esac
printf '%s\n' \
"${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
> build/hermes-webui.destination
printf '%s\n' "${actual_revision}" > build/hermes-webui.source-revision
'''
}
container('kaniko') {
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''#!/busybox/sh
set -eu
set +x
config_path=/kaniko/.docker/config.json
destination="$(cat build/hermes-webui.destination)-amd64"
source_revision="$(cat build/hermes-webui.source-revision)"
umask 077
auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
/busybox/mkdir -p /kaniko/.docker
/busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
unset HARBOR_USER HARBOR_PASSWORD auth
trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
umask 022
/kaniko/executor \
--registry-mirror=harbor-core.harbor.svc.cluster.local \
--insecure-registry=harbor-core.harbor.svc.cluster.local \
--context="dir://${WORKSPACE}" \
--dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
--destination="${destination}" \
--build-arg="HERMES_WEBUI_RELEASE_ID=git-${source_revision}-build-${BUILD_NUMBER}" \
--digest-file="${WORKSPACE}/build/hermes-webui-amd64.digest" \
--image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui-amd64.image" \
--label="org.opencontainers.image.revision=${source_revision}" \
--label="org.opencontainers.image.source=https://scm.bstein.dev/titan/atlas-iac" \
--label="org.opencontainers.image.title=hermes-webui" \
--cleanup \
--push-retry=3
/busybox/chmod 644 build/hermes-webui-amd64.digest build/hermes-webui-amd64.image
'''
}
}
stash(
name: 'hermes-webui-amd64-evidence',
includes: 'build/hermes-webui-amd64.digest,build/hermes-webui-amd64.image'
)
}
}
stage('Combine multi-arch index') {
steps {
unstash 'hermes-webui-amd64-evidence'
withCredentials([usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -eu
set +x
destination="$(cat build/hermes-webui.destination)"
source_revision="$(cat build/hermes-webui.source-revision)"
python3 ci/scripts/hermes_multiarch_combine.py \
--destination "${destination}" \
--source-revision "${source_revision}" \
--build-number "${BUILD_NUMBER}" \
--arm64-digest-file build/hermes-webui-arm64.digest \
--arm64-image-file build/hermes-webui-arm64.image \
--amd64-digest-file build/hermes-webui-amd64.digest \
--amd64-image-file build/hermes-webui-amd64.image \
--digest-file build/hermes-webui.digest \
--image-file build/hermes-webui.image
test -s build/hermes-webui.digest
test -s build/hermes-webui.image
'''
}
}
}
stage('Render reviewed Flux handoff') {
steps {
withCredentials([usernamePassword(
@ -504,10 +295,6 @@ spec:
build/hermes-webui.digest \
build/hermes-webui.image \
build/hermes-webui.source-revision \
build/hermes-webui-arm64.digest \
build/hermes-webui-arm64.image \
build/hermes-webui-amd64.digest \
build/hermes-webui-amd64.image \
build/hermes-webui-release/hermes-chat-statefulset.yaml \
build/hermes-webui-release/hermes-dashboard-deployment.yaml \
build/hermes-webui-release/hermes-webui-image.json \
@ -528,7 +315,7 @@ spec:
--output-dir build/hermes-webui-release
'''
archiveArtifacts(
artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui.source-revision,build/hermes-webui-arm64.digest,build/hermes-webui-arm64.image,build/hermes-webui-amd64.digest,build/hermes-webui-amd64.image,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch',
artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui.source-revision,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch',
allowEmptyArchive: false,
fingerprint: true
)

View File

@ -1,16 +1,11 @@
pipeline {
agent {
kubernetes {
// Keep build I/O off the node's runtime USB drive.
workspaceVolume dynamicPVC(accessModes: 'ReadWriteOnce', requestsSize: '20Gi', storageClassName: 'ci-scratch')
defaultContainer 'python'
yaml """
apiVersion: v1
kind: Pod
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
@ -19,9 +14,12 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- {key: hardware, operator: In, values: [rpi5, rpi4]}
- {key: node-role.kubernetes.io/worker, operator: In, values: ["true"]}
- {key: kubernetes.io/hostname, operator: NotIn, values: [titan-04, titan-05, titan-06, titan-08, titan-11, titan-12, titan-13, titan-14, titan-15, titan-17, titan-18, titan-19]}
- key: kubernetes.io/hostname
operator: NotIn
values:
- titan-04
- titan-06
- titan-11
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
@ -66,10 +64,6 @@ spec:
}
}
environment {
PIP_CACHE_DIR = '/home/jenkins/agent/.cache/pip'
NPM_CONFIG_CACHE = '/home/jenkins/agent/.cache/npm'
SONAR_USER_HOME = '/home/jenkins/agent/.cache/sonar'
TMPDIR = '/home/jenkins/agent/.cache/tmp'
PIP_DISABLE_PIP_VERSION_CHECK = '1'
PYTHONUNBUFFERED = '1'
SUITE_NAME = 'titan_iac'
@ -91,11 +85,6 @@ spec:
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '200', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '120'))
}
stages {
stage('Prepare build scratch') {
steps {
sh 'mkdir -p /home/jenkins/agent/.cache/tmp /home/jenkins/agent/.cache/go-tmp'
}
}
stage('Checkout') {
steps {
checkout scm
@ -490,7 +479,7 @@ PY
set +x
git config user.email "jenkins@bstein.dev"
git config user.name "jenkins"
git remote set-url origin https://${GIT_USER}:${GIT_TOKEN}@scm.bstein.dev/titan/atlas-iac.git
git remote set-url origin https://${GIT_USER}:${GIT_TOKEN}@scm.bstein.dev/atlas/titan-iac.git
git push origin HEAD:${FLUX_BRANCH}
'''
}

View File

@ -1,479 +0,0 @@
#!/usr/bin/env python3
"""Verify and render a reviewable Hermes chat-router image release."""
from __future__ import annotations
import argparse
import base64
import difflib
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable
DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-chat-router"
HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
HARBOR_PROJECT = "bstein"
HARBOR_REPOSITORY = "hermes-chat-router"
IMMUTABLE_REPOSITORY_PATTERN = "hermes-chat-router"
IMMUTABLE_TAG_PATTERN = "git-*-build-*"
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/hermes-chat-router:"
r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
)
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never forward registry credentials to a redirect target."""
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
return None
def _validated(value: str, pattern: re.Pattern[str], label: str) -> str:
"""Return a normalized value only when it matches the release contract."""
normalized = value.strip()
if not pattern.fullmatch(normalized):
raise ValueError(f"invalid {label}: expected {pattern.pattern}")
return normalized
def validate_destination(
destination: str, source_revision: str, build_number: str
) -> tuple[str, str]:
"""Bind the unique build tag to one reviewed source and Jenkins build."""
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
build = _validated(build_number, BUILD_PATTERN, "build number")
match = DESTINATION_PATTERN.fullmatch(destination.strip())
if not match or match.groups() != (revision, build):
raise ValueError("destination does not match the reviewed revision and build")
return revision, build
def validate_kaniko_evidence(
*, digest_text: str, image_text: str, destination: str
) -> str:
"""Cross-check Kaniko's two independent output files."""
digests = digest_text.splitlines()
images = image_text.splitlines()
if len(digests) != 1 or len(images) != 1:
raise ValueError("Kaniko evidence must contain exactly one line per file")
digest = _validated(digests[0], DIGEST_PATTERN, "image digest")
if images[0].strip() != f"{destination}@{digest}":
raise ValueError("Kaniko image evidence does not match destination and digest")
return digest
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
"""Return same-origin registry responses without following redirects."""
opener = urllib.request.build_opener(_NoRedirect())
try:
return opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
return exc
def _authorization(username: str, password: str) -> str:
"""Build a Basic header without placing credentials in a URL."""
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
def _artifact_response(
destination: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> tuple[int, bytes]:
"""Read one exact Harbor artifact by candidate tag."""
if not DESTINATION_PATTERN.fullmatch(destination):
raise ValueError("invalid destination")
tag = urllib.parse.quote(destination.rsplit(":", 1)[1], safe="")
request = urllib.request.Request(
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
f"{HARBOR_REPOSITORY}/artifacts/{tag}?with_immutable_status=true",
headers={
"Accept": "application/json",
"Authorization": _authorization(username, password),
},
method="GET",
)
with opener(request, 20) as response:
body = response.read(1_048_577)
if len(body) > 1_048_576:
raise RuntimeError("Harbor artifact response exceeded the size limit")
return int(response.status), body
def _rules_response(
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> tuple[int, bytes, dict[str, str]]:
"""Read the complete bounded Harbor immutable-rule page."""
request = urllib.request.Request(
f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
"?page=1&page_size=100",
headers={
"Accept": "application/json",
"Authorization": _authorization(username, password),
},
method="GET",
)
with opener(request, 20) as response:
body = response.read(1_048_577)
if len(body) > 1_048_576:
raise RuntimeError("Harbor immutable rule response exceeded the size limit")
return int(response.status), body, dict(response.headers)
def _normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
"""Select only immutable-policy fields used by this lane."""
return {
"disabled": bool(rule.get("disabled", False)),
"action": rule.get("action"),
"template": rule.get("template"),
"tag_selectors": [
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
for item in rule.get("tag_selectors") or []
if isinstance(item, dict)
],
"scope_selectors": {
"repository": [
{key: item.get(key) for key in ("kind", "decoration", "pattern")}
for item in (rule.get("scope_selectors") or {}).get("repository", [])
if isinstance(item, dict)
]
},
}
def verify_immutable_policy(
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Fail closed unless one exact active router immutability rule exists."""
status, body, headers = _rules_response(
username=username, password=password, opener=opener
)
if status != 200:
raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
try:
rules = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
raise RuntimeError("Harbor immutable rule list has an invalid shape")
total = next(
(value for key, value in headers.items() if key.lower() == "x-total-count"),
None,
)
if total is None or not str(total).isdecimal() or int(total) != len(rules):
raise RuntimeError("Harbor immutable rule page is incomplete")
expected = {
"disabled": False,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": IMMUTABLE_TAG_PATTERN,
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": IMMUTABLE_REPOSITORY_PATTERN,
}
]
},
}
matches = [
value
for value in map(_normalized_rule, rules)
if value["tag_selectors"] == expected["tag_selectors"]
and value["scope_selectors"] == expected["scope_selectors"]
]
if matches != [expected]:
raise RuntimeError("Harbor router immutable build-tag policy is not exact")
def assert_tag_absent(
destination: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Reject replay before Kaniko can target an already-used build tag."""
status, _ = _artifact_response(
destination, username=username, password=password, opener=opener
)
if status == 404:
return
if status == 200:
raise RuntimeError("Harbor destination tag already exists")
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
def verify_registry_digest(
destination: str,
digest: str,
source_revision: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> None:
"""Verify Harbor's digest, immutable tag, and persisted source label."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision = _validated(source_revision, REVISION_PATTERN, "source revision")
status, body = _artifact_response(
destination, username=username, password=password, opener=opener
)
if status != 200:
raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
try:
artifact = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Harbor returned invalid artifact JSON") from exc
tag = destination.rsplit(":", 1)[1]
matching = [
item
for item in artifact.get("tags") or []
if isinstance(item, dict) and item.get("name") == tag
]
labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
if artifact.get("digest") != digest:
raise RuntimeError("Harbor digest does not match Kaniko evidence")
if len(matching) != 1 or matching[0].get("immutable") is not True:
raise RuntimeError("Harbor did not enforce the candidate tag as immutable")
if not isinstance(labels, dict) or labels.get(
"org.opencontainers.image.revision"
) != revision:
raise RuntimeError("Harbor OCI source-revision label does not match")
def render_workload(source: str, digest: str) -> str:
"""Replace the single exact router image while preserving the Flux marker."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
identity = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: hermes-chat-router\n"
if not source.startswith("# services/hermes/chat-router.yaml\n" + identity):
raise ValueError("Flux target identity changed")
lines = source.splitlines(keepends=True)
matches: list[int] = []
for index, line in enumerate(lines):
stripped = line.strip()
if not stripped.startswith("image: "):
continue
value = stripped.removeprefix("image: ").split(" #", 1)[0]
image, separator, current_digest = value.rpartition("@")
if separator and re.fullmatch(
rf"{re.escape(DEFAULT_IMAGE)}(?::[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})?",
image,
):
_validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
matches.append(index)
if len(matches) != 1:
raise ValueError(f"expected exactly one router image; found {len(matches)}")
index = matches[0]
indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
comment = ""
if " #" in lines[index]:
comment = " #" + lines[index].split(" #", 1)[1].rstrip("\n")
newline = "\n" if lines[index].endswith("\n") else ""
lines[index] = f"{indent}image: {DEFAULT_IMAGE}@{digest}{comment}{newline}"
return "".join(lines)
def _metadata(
digest: str, revision: str, build: str, destination: str
) -> dict[str, Any]:
return {
"build_number": build,
"digest": digest,
"flux_image": f"{DEFAULT_IMAGE}@{digest}",
"flux_targets": ["apps/Deployment/hermes/hermes-chat-router"],
"image": DEFAULT_IMAGE,
"published_tag": destination,
"source_revision": revision,
}
def _expected_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
) -> dict[str, str]:
source = manifest.read_text(encoding="utf-8")
rendered = render_workload(source, digest)
patch = "".join(
difflib.unified_diff(
source.splitlines(keepends=True),
rendered.splitlines(keepends=True),
fromfile="a/services/hermes/chat-router.yaml",
tofile="b/services/hermes/chat-router.yaml",
)
)
if not patch:
raise ValueError("published digest already matches the Flux target")
metadata = _metadata(digest, source_revision, build_number, destination)
return {
"hermes-chat-router-deployment.yaml": rendered,
"hermes-chat-router-image-update.patch": patch,
"hermes-chat-router-image.json": json.dumps(
metadata, indent=2, sort_keys=True
)
+ "\n",
}
def write_release_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
output_dir: Path,
) -> None:
"""Write deterministic, credential-free Flux handoff evidence."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision, build = validate_destination(destination, source_revision, build_number)
expected = _expected_artifacts(
digest=digest,
source_revision=revision,
build_number=build,
destination=destination,
manifest=manifest,
)
output_dir.mkdir(parents=True, exist_ok=True)
for name, content in expected.items():
(output_dir / name).write_text(content, encoding="utf-8")
def validate_release_artifacts(
*,
digest: str,
source_revision: str,
build_number: str,
destination: str,
manifest: Path,
output_dir: Path,
) -> None:
"""Recompute and compare every archived handoff byte."""
digest = _validated(digest, DIGEST_PATTERN, "image digest")
revision, build = validate_destination(destination, source_revision, build_number)
expected = _expected_artifacts(
digest=digest,
source_revision=revision,
build_number=build,
destination=destination,
manifest=manifest,
)
entries = list(output_dir.iterdir())
if {entry.name for entry in entries} != set(expected) or not all(
entry.is_file() and not entry.is_symlink() for entry in entries
):
raise ValueError("release output must contain exactly three evidence files")
for name, content in expected.items():
if (output_dir / name).read_text(encoding="utf-8") != content:
raise ValueError(f"release evidence is incomplete or mismatched: {name}")
def _credentials() -> tuple[str, str]:
username = os.environ.get("HARBOR_USER", "")
password = os.environ.get("HARBOR_PASSWORD", "")
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
return username, password
def _add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--source-revision", required=True)
parser.add_argument("--build-number", required=True)
parser.add_argument("--destination", required=True)
def main() -> int:
"""Run one fail-closed candidate or evidence operation."""
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
absent = commands.add_parser("assert-absent")
_add_common(absent)
for name in ("render", "verify-evidence"):
command = commands.add_parser(name)
_add_common(command)
command.add_argument("--digest-file", required=True, type=Path)
command.add_argument("--image-file", required=True, type=Path)
command.add_argument("--manifest", required=True, type=Path)
command.add_argument("--output-dir", required=True, type=Path)
args = parser.parse_args()
validate_destination(args.destination, args.source_revision, args.build_number)
if args.command == "verify-evidence":
digest = validate_kaniko_evidence(
digest_text=args.digest_file.read_text(encoding="utf-8"),
image_text=args.image_file.read_text(encoding="utf-8"),
destination=args.destination,
)
validate_release_artifacts(
digest=digest,
source_revision=args.source_revision,
build_number=args.build_number,
destination=args.destination,
manifest=args.manifest,
output_dir=args.output_dir,
)
return 0
username, password = _credentials()
if args.command == "assert-absent":
verify_immutable_policy(username=username, password=password)
assert_tag_absent(
args.destination, username=username, password=password
)
return 0
digest = validate_kaniko_evidence(
digest_text=args.digest_file.read_text(encoding="utf-8"),
image_text=args.image_file.read_text(encoding="utf-8"),
destination=args.destination,
)
verify_registry_digest(
args.destination,
digest,
args.source_revision,
username=username,
password=password,
)
write_release_artifacts(
digest=digest,
source_revision=args.source_revision,
build_number=args.build_number,
destination=args.destination,
manifest=args.manifest,
output_dir=args.output_dir,
)
return 0
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())

View File

@ -1,393 +0,0 @@
#!/usr/bin/env python3
"""Assemble a fail-closed multi-arch manifest list from two per-arch leaves.
Kaniko builds one native image per architecture (arm64 on an rpi5 pod, amd64 on
titan-24) and pushes each under an arch-suffixed candidate tag
``...-build-<N>-<arch>``. Kaniko cannot combine, so this step:
1. Independently re-reads each per-arch candidate manifest from the registry and
binds it to the exact Kaniko digest evidence.
2. Proves each leaf really is the architecture it claims by reading its image
config (a swapped or cross-built leaf fails closed here).
3. Builds a Docker manifest *list* (not an OCI index) from the two verified
leaves -- Docker manifest lists are already inside the promotion allow-list,
so this keeps the security surface of ``hermes_oci_promote.py`` unchanged.
4. Refuses to overwrite an existing final tag, PUTs the list to the final
``...-build-<N>`` tag, and re-reads it to confirm the registry resolved the
exact index digest referencing exactly the two expected leaves.
The output ``--digest-file``/``--image-file`` deliberately use the SAME format
the single-arch Kaniko step produced (``<digest>`` and ``<destination>@<digest>``
for the final, arch-less tag). The whole downstream evidence chain --
``hermes_image_release.py`` render/verify-evidence and ``hermes_oci_promote.py``
-- therefore promotes the multi-arch INDEX with no further change.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable
REGISTRY_ORIGIN = "https://registry.bstein.dev"
# The final (arch-less) Flux-visible tag; identical contract to the promoter.
# Both Hermes images that the multi-arch pipelines publish share the exact same
# arch-less final-tag contract; the repository name is the only difference and is
# captured here so the combiner stays fail-closed to just these two components.
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/(?P<component>hermes-agent|hermes-webui):"
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
)
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
DOCKER_MANIFEST_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"
# A per-arch leaf must be a single-image manifest, never itself a list/index.
LEAF_MANIFEST_TYPES = {
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.manifest.v1+json",
}
IMAGE_CONFIG_TYPES = {
"application/vnd.docker.container.image.v1+json",
"application/vnd.oci.image.config.v1+json",
}
# Deterministic architecture order -> deterministic manifest-list bytes/digest.
ARCHITECTURES = ("amd64", "arm64")
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
MAX_CONFIG_BYTES = 1024 * 1024
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never forward registry credentials to another origin."""
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
return None
def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
"""Return normal and HTTP error responses without following redirects."""
opener = urllib.request.build_opener(_NoRedirect())
try:
return opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
return exc
def _status(response: Any) -> int:
"""Normalize urllib response and HTTPError status fields."""
return int(getattr(response, "status", getattr(response, "code", 0)))
def _authorization(username: str, password: str) -> str:
"""Build a Basic authorization value without placing it in a URL."""
if not username or not password:
raise RuntimeError("Harbor credentials are unavailable")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {encoded}"
def _manifest_url(component: str, reference: str) -> str:
"""Return one same-origin, path-escaped Docker Registry manifest URL."""
encoded = urllib.parse.quote(reference, safe="")
return f"{REGISTRY_ORIGIN}/v2/bstein/{component}/manifests/{encoded}"
def _blob_url(component: str, digest: str) -> str:
"""Return one same-origin, path-escaped Docker Registry blob URL."""
encoded = urllib.parse.quote(digest, safe="")
return f"{REGISTRY_ORIGIN}/v2/bstein/{component}/blobs/{encoded}"
def _read_evidence_pair(
*, digest_text: str, image_text: str, per_arch_tag_ref: str
) -> str:
"""Cross-check both Kaniko output files for one arch against its tag."""
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 = digest_lines[0].strip()
if not DIGEST_PATTERN.fullmatch(digest):
raise ValueError("invalid per-arch image digest")
if image_lines[0].strip() != f"{per_arch_tag_ref}@{digest}":
raise ValueError("per-arch image evidence does not match tag and digest")
return digest
def _verified_leaf(
*,
component: str,
per_arch_tag: str,
architecture: str,
expected_digest: str,
authorization: str,
opener: Callable[[urllib.request.Request, int], Any],
) -> dict[str, Any]:
"""Re-read one per-arch leaf and prove its digest, type, and architecture."""
accept = ", ".join(sorted(LEAF_MANIFEST_TYPES))
request = urllib.request.Request(
_manifest_url(component, per_arch_tag),
headers={"Accept": accept, "Authorization": authorization},
method="GET",
)
with opener(request, 30) as response:
if _status(response) != 200:
raise RuntimeError(
f"{architecture} leaf manifest returned HTTP {_status(response)}"
)
body = response.read(MAX_MANIFEST_BYTES + 1)
if len(body) > MAX_MANIFEST_BYTES:
raise RuntimeError(f"{architecture} leaf manifest exceeded the size limit")
observed_digest = response.headers.get("Docker-Content-Digest", "")
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
if observed_digest != expected_digest:
raise RuntimeError(f"{architecture} leaf digest does not match build evidence")
# Defence in depth: the digest header is registry-asserted; recompute it too.
if f"sha256:{hashlib.sha256(body).hexdigest()}" != expected_digest:
raise RuntimeError(f"{architecture} leaf bytes do not hash to its digest")
if content_type not in LEAF_MANIFEST_TYPES:
raise RuntimeError(f"{architecture} leaf is not a single-image manifest")
try:
manifest = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"{architecture} leaf manifest is not valid JSON") from exc
config = manifest.get("config")
if not isinstance(config, dict):
raise RuntimeError(f"{architecture} leaf manifest omits its config descriptor")
config_digest = str(config.get("digest") or "")
config_type = str(config.get("mediaType") or "")
if not DIGEST_PATTERN.fullmatch(config_digest):
raise RuntimeError(f"{architecture} leaf config digest is invalid")
if config_type not in IMAGE_CONFIG_TYPES:
raise RuntimeError(f"{architecture} leaf config media type is unsupported")
config_request = urllib.request.Request(
_blob_url(component, config_digest),
headers={"Accept": config_type, "Authorization": authorization},
method="GET",
)
with opener(config_request, 30) as response:
if _status(response) != 200:
raise RuntimeError(
f"{architecture} leaf config returned HTTP {_status(response)}"
)
config_body = response.read(MAX_CONFIG_BYTES + 1)
if len(config_body) > MAX_CONFIG_BYTES:
raise RuntimeError(f"{architecture} leaf config exceeded the size limit")
if f"sha256:{hashlib.sha256(config_body).hexdigest()}" != config_digest:
raise RuntimeError(f"{architecture} leaf config bytes do not hash to its digest")
try:
config_json = json.loads(config_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"{architecture} leaf config is not valid JSON") from exc
if config_json.get("architecture") != architecture:
raise RuntimeError(
f"{architecture} leaf config reports architecture "
f"{config_json.get('architecture')!r}"
)
if config_json.get("os") != "linux":
raise RuntimeError(f"{architecture} leaf config reports a non-linux os")
return {
"mediaType": content_type,
"size": len(body),
"digest": expected_digest,
"platform": {"architecture": architecture, "os": "linux"},
}
def _manifest_list_bytes(descriptors: list[dict[str, Any]]) -> bytes:
"""Serialize the manifest list deterministically for a stable index digest."""
document = {
"schemaVersion": 2,
"mediaType": DOCKER_MANIFEST_LIST,
"manifests": descriptors,
}
return json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
def combine_multiarch_index(
*,
destination: str,
arch_digests: dict[str, str],
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> dict[str, str]:
"""Verify both leaves, publish, and re-verify one multi-arch index tag."""
match = DESTINATION_PATTERN.fullmatch(destination.strip())
if not match:
raise ValueError("invalid multi-arch destination")
if set(arch_digests) != set(ARCHITECTURES):
raise ValueError("expected exactly the arm64 and amd64 per-arch digests")
component = match.group("component")
index_tag = destination.rsplit(":", 1)[1]
authorization = _authorization(username, password)
descriptors = [
_verified_leaf(
component=component,
per_arch_tag=f"{index_tag}-{architecture}",
architecture=architecture,
expected_digest=arch_digests[architecture],
authorization=authorization,
opener=opener,
)
for architecture in ARCHITECTURES
]
manifest_list = _manifest_list_bytes(descriptors)
if len(manifest_list) > MAX_MANIFEST_BYTES:
raise RuntimeError("assembled manifest list exceeded the size limit")
index_digest = f"sha256:{hashlib.sha256(manifest_list).hexdigest()}"
index_url = _manifest_url(component, index_tag)
head_request = urllib.request.Request(
index_url,
headers={"Accept": DOCKER_MANIFEST_LIST, "Authorization": authorization},
method="HEAD",
)
with opener(head_request, 20) as response:
head_status = _status(response)
existing_digest = response.headers.get("Docker-Content-Digest", "")
if head_status == 200:
if existing_digest != index_digest:
raise RuntimeError("final tag already exists with another index digest")
result = "already-present"
elif head_status == 404:
put_request = urllib.request.Request(
index_url,
data=manifest_list,
headers={
"Authorization": authorization,
"Content-Type": DOCKER_MANIFEST_LIST,
},
method="PUT",
)
with opener(put_request, 30) as response:
put_status = _status(response)
put_digest = response.headers.get("Docker-Content-Digest", "")
if put_status not in {201, 202}:
raise RuntimeError(f"index manifest returned HTTP {put_status}")
if put_digest and put_digest != index_digest:
raise RuntimeError("index manifest digest changed during publish")
result = "published"
else:
raise RuntimeError(f"final tag preflight returned HTTP {head_status}")
verify_request = urllib.request.Request(
index_url,
headers={"Accept": DOCKER_MANIFEST_LIST, "Authorization": authorization},
method="GET",
)
with opener(verify_request, 30) as response:
if _status(response) != 200:
raise RuntimeError(f"index verification returned HTTP {_status(response)}")
verify_body = response.read(MAX_MANIFEST_BYTES + 1)
if len(verify_body) > MAX_MANIFEST_BYTES:
raise RuntimeError("index verification exceeded the size limit")
verify_digest = response.headers.get("Docker-Content-Digest", "")
verify_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
if verify_digest != index_digest:
raise RuntimeError("registry resolved the final tag to another index digest")
if verify_type != DOCKER_MANIFEST_LIST:
raise RuntimeError("registry did not store a Docker manifest list")
try:
published = json.loads(verify_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("registry returned invalid index JSON") from exc
published_leaves = {
(
str((item or {}).get("platform", {}).get("architecture")),
str((item or {}).get("digest")),
)
for item in published.get("manifests") or []
}
expected_leaves = {
(architecture, arch_digests[architecture]) for architecture in ARCHITECTURES
}
if published_leaves != expected_leaves:
raise RuntimeError("published index does not reference the exact two leaves")
return {
"component": component,
"index_digest": index_digest,
"index_tag": index_tag,
"result": result,
**{f"{architecture}_digest": arch_digests[architecture] for architecture in ARCHITECTURES},
}
def _load_arch_digest(
*, destination: str, architecture: str, digest_file: Path, image_file: Path
) -> str:
"""Bind one arch's two Kaniko evidence files to its arch-suffixed tag."""
per_arch_tag_ref = f"{destination}-{architecture}"
return _read_evidence_pair(
digest_text=digest_file.read_text(encoding="utf-8"),
image_text=image_file.read_text(encoding="utf-8"),
per_arch_tag_ref=per_arch_tag_ref,
)
def main() -> int:
"""Combine two verified per-arch leaves and emit index digest evidence."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--destination", required=True)
parser.add_argument("--source-revision", required=True)
parser.add_argument("--build-number", required=True)
parser.add_argument("--arm64-digest-file", required=True, type=Path)
parser.add_argument("--arm64-image-file", required=True, type=Path)
parser.add_argument("--amd64-digest-file", required=True, type=Path)
parser.add_argument("--amd64-image-file", required=True, type=Path)
parser.add_argument("--digest-file", required=True, type=Path)
parser.add_argument("--image-file", required=True, type=Path)
args = parser.parse_args()
try:
match = DESTINATION_PATTERN.fullmatch(args.destination.strip())
if not match:
raise ValueError("invalid multi-arch destination")
if match.group("revision") != args.source_revision.strip():
raise ValueError("destination revision does not match evidence")
if match.group("build") != args.build_number.strip():
raise ValueError("destination build number does not match evidence")
destination = args.destination.strip()
arch_digests = {
"arm64": _load_arch_digest(
destination=destination,
architecture="arm64",
digest_file=args.arm64_digest_file,
image_file=args.arm64_image_file,
),
"amd64": _load_arch_digest(
destination=destination,
architecture="amd64",
digest_file=args.amd64_digest_file,
image_file=args.amd64_image_file,
),
}
result = combine_multiarch_index(
destination=destination,
arch_digests=arch_digests,
username=os.environ.get("HARBOR_USER", ""),
password=os.environ.get("HARBOR_PASSWORD", ""),
)
args.digest_file.write_text(result["index_digest"] + "\n", encoding="utf-8")
args.image_file.write_text(
f"{destination}@{result['index_digest']}\n", encoding="utf-8"
)
except (OSError, ValueError, RuntimeError, urllib.error.URLError) as exc:
print(json.dumps({"error": str(exc)}, sort_keys=True))
return 1
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())

View File

@ -18,7 +18,7 @@ from typing import Any, Callable
REGISTRY_ORIGIN = "https://registry.bstein.dev"
DESTINATION_PATTERN = re.compile(
r"^registry\.bstein\.dev/bstein/"
r"(?P<component>hermes-(?:agent|webui|chat-router|jetson-(?:stt|tts))):"
r"(?P<component>hermes-(?:agent|webui|jetson-(?:stt|tts))):"
r"git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)$"
)
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")

View File

@ -49,9 +49,8 @@ def render_workload(
kind: str,
name: str,
image: str = DEFAULT_IMAGE,
expected_images: int | tuple[int, ...] = 1,
) -> str:
"""Replace every expected WebUI consumer in one exact Flux workload."""
"""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"
@ -76,59 +75,18 @@ def render_workload(
validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
matches.append(index)
suffixes[index] = f" #{comment}" if separator else ""
allowed = (expected_images,) if isinstance(expected_images, int) else expected_images
if not allowed or any(count < 1 for count in allowed) or len(matches) not in allowed:
if len(matches) != 1:
raise ValueError(
f"expected {allowed} {image!r} image(s) in {kind}/{name}; "
f"expected exactly one {image!r} image in {kind}/{name}; "
f"found {len(matches)}"
)
for index in matches:
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}{suffixes[index]}{newline}"
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}{suffixes[index]}{newline}"
return "".join(lines)
def render_hux_build_metadata(
source: str, digest: str, source_revision: str, build_number: str
) -> str:
"""Bind HUX metadata when the activated sidecar fields are present."""
digest = validated(digest, DIGEST_PATTERN, "image digest")
revision = validated(source_revision, REVISION_PATTERN, "source revision")
build = validated(build_number, BUILD_PATTERN, "build number")
replacements = {
"HUX_IMAGE_TAG": f"git-{revision}-build-{build}-release",
"HUX_IMAGE_DIGEST": digest,
}
rendered = source
# Block style only: Flux setters cannot attach to values inside flow
# mappings, so the manifest keeps these as two-line entries with the
# marker comment on the value scalar. The renderer stays a belt on top
# of the Flux :tag/:digest setters and binds the same values.
present = {
name: rendered.count(f"- name: {name}\n") for name in replacements
}
if set(present.values()) == {0}:
return rendered
if any(count != 1 for count in present.values()):
raise ValueError(f"incomplete HUX build binding fields: {present}")
for name, value in replacements.items():
pattern = re.compile(
rf"^(?P<head>(?P<indent>\s*)- name: {name}\n(?P=indent) value: )"
rf"[^#\n]+(?P<suffix> #[^\n]*)?$",
re.MULTILINE,
)
rendered, count = pattern.subn(
lambda match: (
f"{match.group('head')}{value}{match.group('suffix') or ''}"
),
rendered,
)
if count != 1:
raise ValueError(f"expected exactly one {name} HUX build binding; found {count}")
return rendered
def _targets(chat_manifest: Path, dashboard_manifest: Path):
return (
(
@ -136,14 +94,12 @@ def _targets(chat_manifest: Path, dashboard_manifest: Path):
"StatefulSet",
"hermes-chat-tenant",
"hermes-chat-statefulset.yaml",
(1, 2, 3),
),
(
dashboard_manifest,
"Deployment",
"hermes",
"hermes-dashboard-deployment.yaml",
1,
),
)
@ -166,27 +122,13 @@ def _metadata(
def _rendered_and_patch(
digest: str,
source_revision: str,
build_number: str,
chat_manifest: Path,
dashboard_manifest: Path,
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, expected_images in _targets(chat_manifest, dashboard_manifest):
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,
expected_images=expected_images,
)
if name == "hermes-chat-tenant":
rendered = render_hux_build_metadata(
rendered, digest, source_revision, build_number
)
rendered = render_workload(source, digest, kind=kind, name=name)
rendered_targets[artifact_name] = rendered
patch_parts.append(
"".join(
@ -217,7 +159,7 @@ def write_release_artifacts(
destination, source_revision, build_number
)
rendered_targets, patch = _rendered_and_patch(
digest, source_revision, build_number, chat_manifest, dashboard_manifest
digest, chat_manifest, dashboard_manifest
)
if not patch:
raise ValueError("published digest already matches every Flux target")
@ -259,7 +201,7 @@ def validate_release_artifacts(
):
raise ValueError("release output must contain exactly four evidence files")
rendered_targets, patch = _rendered_and_patch(
digest, source_revision, build_number, chat_manifest, dashboard_manifest
digest, chat_manifest, dashboard_manifest
)
metadata = _metadata(digest, source_revision, build_number, destination)
expected = {

View File

@ -18,7 +18,6 @@ from hermes_webui_flux_release import (
DESTINATION_PATTERN,
DIGEST_PATTERN,
REVISION_PATTERN,
render_hux_build_metadata as render_hux_build_metadata,
render_workload as render_workload,
validate_destination,
validate_release_artifacts as validate_flux_release_artifacts,
@ -231,84 +230,6 @@ def assert_tag_absent(
raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
OCI_REVISION_LABEL = "org.opencontainers.image.revision"
def _child_artifact_response(
child_digest: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
) -> tuple[int, bytes]:
"""Read one per-arch child artifact of a multi-arch index by its digest."""
if not username or not password:
raise RuntimeError("Harbor credentials are empty")
encoded = urllib.parse.quote(child_digest, 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}"
"?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 child artifact response exceeded the size limit")
return int(response.status), body
def _config_labels(artifact: dict[str, Any]) -> Any:
"""Extract the OCI config labels Harbor reports for one artifact, if any."""
return ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
def _verify_source_revision_label(
artifact: dict[str, Any],
source_revision: str,
*,
username: str,
password: str,
opener: Callable[[urllib.request.Request, int], Any],
) -> None:
"""Assert the published image carries the reviewed source revision label.
A single-arch image exposes ``org.opencontainers.image.revision`` on its own
config. A multi-arch manifest list has no top-level config, so Harbor reports
the label on each per-arch child instead; verify every child in that case.
"""
labels = _config_labels(artifact)
if isinstance(labels, dict):
if labels.get(OCI_REVISION_LABEL) != source_revision:
raise RuntimeError("Harbor OCI source-revision label does not match")
return
references = artifact.get("references")
if not isinstance(references, list) or not references:
raise RuntimeError("Harbor artifact omitted OCI image labels")
for reference in references:
child_digest = str((reference or {}).get("child_digest") or "").strip()
if not DIGEST_PATTERN.fullmatch(child_digest):
raise RuntimeError("Harbor index reference omitted a valid child digest")
status, body = _child_artifact_response(
child_digest, username=username, password=password, opener=opener
)
if status != 200:
raise RuntimeError(
f"Harbor child artifact verification returned HTTP {status}"
)
try:
child = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Harbor returned invalid child artifact JSON") from exc
child_labels = _config_labels(child)
if not isinstance(child_labels, dict):
raise RuntimeError("Harbor artifact omitted OCI image labels")
if child_labels.get(OCI_REVISION_LABEL) != source_revision:
raise RuntimeError("Harbor OCI source-revision label does not match")
def verify_registry_digest(
destination: str,
digest: str,
@ -347,13 +268,11 @@ def verify_registry_digest(
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")
_verify_source_revision_label(
artifact,
source_revision,
username=username,
password=password,
opener=opener,
)
labels = ((artifact.get("extra_attrs") or {}).get("config") or {}).get("Labels")
if not isinstance(labels, dict):
raise RuntimeError("Harbor artifact omitted OCI image labels")
if labels.get("org.opencontainers.image.revision") != source_revision:
raise RuntimeError("Harbor OCI source-revision label does not match")
def validate_release_artifacts(

View File

@ -3,7 +3,6 @@
from __future__ import annotations
import hashlib
import json
import os
from glob import glob
@ -28,8 +27,6 @@ _infer_workspace_coverage_percent = _quality_helpers._infer_workspace_coverage_p
_load_optional_json = _quality_helpers._load_optional_json
_normalize_result_status = _quality_helpers._normalize_result_status
TEST_CASE_LABEL_MAX_BYTES = 240
def _escape_label(value: str) -> str:
"""Escape a Prometheus label value without changing its content."""
@ -42,17 +39,6 @@ def _label_str(labels: dict[str, str]) -> str:
return "{" + ",".join(parts) + "}" if parts else ""
def _bounded_test_name(value: str) -> str:
"""Keep test labels readable, unique, and safely below scraper limits."""
encoded = value.encode("utf-8")
if len(encoded) <= TEST_CASE_LABEL_MAX_BYTES:
return value
digest = hashlib.sha256(encoded).hexdigest()[:16]
suffix = f"...[sha256:{digest}]"
prefix = encoded[: TEST_CASE_LABEL_MAX_BYTES - len(suffix)]
return prefix.decode("utf-8", errors="ignore") + suffix
def _read_text(url: str) -> str:
"""Fetch a plain-text response body from the given URL."""
with urllib.request.urlopen(url, timeout=10) as response:
@ -135,7 +121,7 @@ def _collect_junit_cases(pattern: str) -> list[tuple[str, str]]:
status = "failed"
elif test_case.find("skipped") is not None:
status = "skipped"
cases.append((_bounded_test_name(full_name), status))
cases.append((full_name, status))
return cases
@ -260,7 +246,7 @@ def _build_payload(
for test_name, test_status in test_cases:
labels = {
**test_case_base_labels,
"test": _bounded_test_name(test_name),
"test": test_name,
"status": test_status,
}
lines.append(

View File

@ -5,6 +5,7 @@ metadata:
name: bstein-dev-home-migrations
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Migration jobs run only during portal schema changes."
spec:
interval: 10m

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: bstein-dev-home
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/bstein-dev-home

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: cassandra-auth
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/cassandra-auth

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: cassandra
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/cassandra

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: comms
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: crypto
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/crypto

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: finance
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Finance apps are parked until the next storage and SSO pass."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/finance
prune: true
sourceRef:

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: game-stream
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Game streaming is optional and resumes only for planned use."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/game-stream
targetNamespace: game-stream
prune: true

View File

@ -1,9 +1,11 @@
# clusters/atlas/flux-system/applications/gitea/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: gitea
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/gitea

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: harbor
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/harbor
@ -13,20 +15,16 @@ spec:
kind: GitRepository
name: flux-system
namespace: flux-system
wait: false
wait: true
timeout: 10m
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: harbor-core
- apiVersion: batch/v1
kind: Job
name: harbor-hermes-agent-immutability-ensure-1
namespace: harbor
- apiVersion: apps/v1
kind: Deployment
name: harbor-registry
namespace: harbor
- apiVersion: apps/v1
kind: Deployment
name: harbor-jobservice
- apiVersion: batch/v1
kind: Job
name: harbor-hermes-webui-immutability-ensure-1
namespace: harbor
dependsOn:
- name: core

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: health
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Health stack is staged pending account and mail flows."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/health
prune: true
sourceRef:

View File

@ -0,0 +1,19 @@
# clusters/atlas/flux-system/applications/hermes-scm-broker-code/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: hermes-scm-broker-code
namespace: flux-system
spec:
interval: 10m
path: ./services/hermes/scm-common
targetNamespace: hermes-scm
prune: true
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
wait: true
timeout: 5m
dependsOn:
- name: hermes-scm-namespace

View File

@ -24,3 +24,4 @@ spec:
- name: vault
- name: gitea
- name: hermes-scm-namespace
- name: hermes-scm-broker-code

View File

@ -26,10 +26,6 @@ spec:
kind: Deployment
name: hermes-switchyard
namespace: hermes
- apiVersion: apps/v1
kind: Deployment
name: hermes-suite-planner
namespace: hermes
- apiVersion: apps/v1
kind: Deployment
name: hermes-agent
@ -69,5 +65,6 @@ spec:
- name: keycloak
- name: longhorn
- name: vault
# CI availability must not block recovery of the inference services.
- name: jenkins
- name: hermes-scm-broker
- name: hermes-observer-rbac

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: jellyfin
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Media stack stays paused while auth and storage changes are staged."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/jellyfin
targetNamespace: jellyfin
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: keycloak
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
prune: true

View File

@ -29,6 +29,7 @@ resources:
- ai-llm/kustomization.yaml
- openclaw/kustomization.yaml
- hermes-scm-namespace/kustomization.yaml
- hermes-scm-broker-code/kustomization.yaml
- hermes/kustomization.yaml
- hermes/image-automation.yaml
- hermes-observer-rbac/kustomization.yaml

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: mailu
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Mail stack is staged and resumes only during mail rollout work."
spec:
interval: 10m
suspend: false
suspend: true
sourceRef:
kind: GitRepository
name: flux-system

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: monerod
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/crypto/monerod

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: nextcloud-mail-sync
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Mail sync resumes after Nextcloud and Mailu are active."
spec:
interval: 10m
suspend: false
suspend: true
prune: true
sourceRef:
kind: GitRepository

View File

@ -1,12 +1,15 @@
# clusters/atlas/flux-system/applications/nextcloud/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: nextcloud
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Nextcloud is staged pending storage, SSO, and mail validation."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/nextcloud
targetNamespace: nextcloud
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: oauth2-proxy
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: openldap
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
prune: true

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: outline
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Outline is staged until shared auth and mail are ready."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/outline
prune: true
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: pegasus
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/pegasus

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: planka
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Planka is staged until shared auth and mail are ready."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/planka
prune: true
sourceRef:

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: quality
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Quality stack changes resume only during quality-gate rollout work."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/quality
prune: true
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: sui-metrics
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/sui-metrics/overlays/atlas

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: typhon
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Climate automation is staged until sensor and control loops are verified."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/typhon
prune: true
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: vault-hermes-jenkins-token-seed
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: vault
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
sourceRef:
@ -13,15 +15,11 @@ spec:
path: ./services/vault
targetNamespace: vault
prune: true
wait: false
wait: true
healthChecks:
- apiVersion: apps/v1
kind: StatefulSet
name: vault
namespace: vault
- apiVersion: apps/v1
kind: Deployment
name: vault-injector-agent-injector
- apiVersion: batch/v1
kind: Job
name: vault-k8s-auth-hermes-10
namespace: vault
dependsOn:
- name: longhorn

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: vaultwarden
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Vaultwarden stays separate from SSO rollout and resumes by hand."
spec:
interval: 10m
suspend: false
suspend: true
sourceRef:
kind: GitRepository
name: flux-system

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: veles
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/veles

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: wallet-monero-temp
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Temporary wallet RPC stack is opt-in for maintenance windows."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/crypto/wallet-monero-temp
targetNamespace: crypto
prune: true

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: xmr-miner
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Mining workloads are disabled unless explicitly enabled for a short run."
spec:
interval: 10m
suspend: false
suspend: true
path: ./services/crypto/xmr-miner
targetNamespace: crypto
prune: true

View File

@ -12,7 +12,7 @@ spec:
branch: main
secretRef:
name: flux-system-gitea
url: ssh://git@scm.bstein.dev:2242/titan/atlas-iac.git
url: ssh://git@scm.bstein.dev:2242/atlas/titan-iac.git
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: cert-manager-cleanup
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
path: ./infrastructure/cert-manager/cleanup

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: cert-manager
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
path: ./infrastructure/cert-manager

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: core
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./infrastructure/core

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: descheduler
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Descheduler is paused during node recovery and placement stabilization."
spec:
interval: 30m
suspend: false
suspend: true
path: ./infrastructure/descheduler
prune: true
sourceRef:

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: gitops-ui
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "GitOps UI is optional and resumes only for operator access work."
spec:
interval: 10m
suspend: false
suspend: true
timeout: 10m
path: ./services/gitops-ui
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: helm
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: logging
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/logging

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: longhorn-adopt
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
path: ./infrastructure/longhorn/adopt

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: longhorn-ui
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Longhorn UI ingress is optional and opened only for storage work."
spec:
interval: 10m
suspend: false
suspend: true
path: ./infrastructure/longhorn/ui-ingress
targetNamespace: longhorn-system
prune: true

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: longhorn
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
path: ./infrastructure/longhorn/core

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: maintenance
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/maintenance

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: metallb
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: monitoring
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./services/monitoring

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: postgres
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./infrastructure/postgres

View File

@ -4,9 +4,12 @@ kind: Kustomization
metadata:
name: resource-guardrails
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
atlas.bstein.dev/suspend-reason: "Guardrail rollout is paused until service resource requests are normalized."
spec:
interval: 10m
suspend: false
suspend: true
path: ./infrastructure/resource-guardrails
prune: true
sourceRef:

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: traefik
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 10m
path: ./infrastructure/traefik

View File

@ -5,7 +5,7 @@ metadata:
name: vault-csi
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: Override
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
sourceRef:
@ -14,23 +14,5 @@ spec:
namespace: flux-system
path: ./infrastructure/vault-csi
prune: true
# A cordoned, unavailable node must not block unrelated workload rollouts.
# Keep the CSI driver gate and require at least one observed healthy provider.
wait: false
healthChecks:
- apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
name: secrets-store-csi-driver
namespace: kube-system
- apiVersion: apps/v1
kind: DaemonSet
name: vault-csi-provider
namespace: kube-system
healthCheckExprs:
- apiVersion: apps/v1
kind: DaemonSet
current: >-
has(status.observedGeneration) && has(metadata.generation) &&
status.observedGeneration >= metadata.generation &&
has(status.numberAvailable) && status.numberAvailable > 0
wait: true
targetNamespace: kube-system

View File

@ -4,6 +4,8 @@ kind: Kustomization
metadata:
name: vault-injector
namespace: flux-system
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
spec:
interval: 30m
path: ./infrastructure/vault-injector

View File

@ -1,68 +1,6 @@
# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-agent
#
# Multi-arch base: this digest is the upstream OCI image INDEX for tag
# v2026.7.7.2 (revision 9de9c25f620ff7f1ce0fd5457d596052d5159596). The index
# fans out to both native leaves of the same reviewed upstream version:
# linux/arm64 -> sha256:47d4bd4cc420b70e40ed75efdade373e45b86b7382d4013a054208982bb6ba08
# linux/amd64 -> sha256:3db34ce19adfa080736a2a3feb0316dbcccc588faa9afe7fd8ae1c03b4f1a53a
# The arm64 leaf is byte-for-byte the previously pinned single-arch base, so the
# arm64 build is unchanged; Kaniko/containerd auto-selects the matching leaf per
# build platform (arm64 rpi5 pod vs amd64 titan-24 pod). Do NOT replace this with
# a per-arch leaf digest -- that would break the amd64 build leg.
#
# The reference below points at the in-cluster Harbor "mirror" project, NOT
# docker.io. The identical upstream index (same content-addressed digest
# 9c841866..., both leaves) is mirrored into Harbor with
# skopeo copy --all docker://nousresearch/hermes-agent@sha256:9c841866... \
# docker://harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866...
# by the Flux-managed one-shot Job services/harbor/hermes-agent-base-mirror-job.yaml.
# Because the digest is content-addressed, mirroring reproduces the exact index
# and both leaf digests, so this remains fully digest-pinned and multi-arch while
# Kaniko pulls it over the internal insecure registry (see the pipeline's
# --insecure-registry/--registry-mirror flags) with no docker.io fallback.
# Re-run that Job whenever this digest is bumped, before publishing the image.
ARG HERMES_AGENT_BASE=harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973
# Python 3.13 dynamically links libsqlite3.so.0, so a checked native library
# replaces the affected Debian 3.46.1 runtime without rebuilding Python. This
# target is also an emergency, small multi-arch layer when built with
# ``--target sqlite-runtime`` and HERMES_AGENT_BASE set to the current agent.
FROM ${HERMES_AGENT_BASE} AS sqlite-build
USER root
ARG SQLITE_AUTOCONF_VERSION=3510300
ARG SQLITE_AUTOCONF_SHA256=81f5be397049b0cae1b167f2225af7646fc0f82e4a9b3c48c9ea3a533e21d77a
ARG SQLITE_AMALGAMATION_SHA3=32d5424f97e0a7fc5ed2f6335afbb58be4e0298bd7117a34e39d345ff13d859e
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates curl \
&& curl --fail --location --proto '=https' --tlsv1.2 \
--output /tmp/sqlite.tar.gz \
"https://www.sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" \
&& echo "${SQLITE_AUTOCONF_SHA256} /tmp/sqlite.tar.gz" | sha256sum --check --status \
&& tar -xzf /tmp/sqlite.tar.gz -C /tmp \
&& /opt/hermes/.venv/bin/python -c 'import hashlib, pathlib, sys; expected=sys.argv[1]; actual=hashlib.sha3_256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest(); raise SystemExit(actual != expected)' "${SQLITE_AMALGAMATION_SHA3}" "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}/sqlite3.c" \
&& cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" \
&& CFLAGS='-DSQLITE_ENABLE_COLUMN_METADATA -DSQLITE_ENABLE_PREUPDATE_HOOK -DSQLITE_ENABLE_UNLOCK_NOTIFY -DSQLITE_ENABLE_UPDATE_DELETE_LIMIT -DSQLITE_SOUNDEX' \
./configure --prefix=/opt/sqlite --disable-static --all --scanstatus \
&& make -j"$(nproc)" \
&& make install \
&& test -L /opt/sqlite/lib/libsqlite3.so.0 \
&& /opt/sqlite/bin/sqlite3 ':memory:' 'select sqlite_version();' | grep -Fx '3.51.3'
FROM ${HERMES_AGENT_BASE} AS sqlite-runtime
USER root
COPY --from=sqlite-build /opt/sqlite/lib/ /usr/local/lib/
# Debian's arm64 multiarch path sorts ahead of libc.conf, which otherwise picks
# the older system SQLite before /usr/local/lib. Keep the checked library first.
RUN printf '%s\n' /usr/local/lib > /etc/ld.so.conf.d/00-hermes-sqlite.conf \
&& ldconfig \
&& /opt/hermes/.venv/bin/python -c 'import sqlite3; connection=sqlite3.connect(":memory:"); connection.execute("CREATE VIRTUAL TABLE documents USING fts5(body)"); connection.execute("CREATE VIRTUAL TABLE ranges USING rtree(id, low, high)"); assert connection.execute("SELECT json_valid(?), sqrt(9)", ("[]",)).fetchone() == (1, 3.0); options={row[0] for row in connection.execute("PRAGMA compile_options")}; required={"ENABLE_COLUMN_METADATA", "ENABLE_FTS4", "ENABLE_FTS5", "ENABLE_MATH_FUNCTIONS", "ENABLE_PREUPDATE_HOOK", "ENABLE_RTREE", "ENABLE_SESSION", "ENABLE_UNLOCK_NOTIFY", "ENABLE_UPDATE_DELETE_LIMIT"}; assert required <= options, sorted(required - options); assert sqlite3.sqlite_version_info >= (3, 51, 3), sqlite3.sqlite_version'
FROM sqlite-runtime
FROM nousresearch/hermes-agent@sha256:47d4bd4cc420b70e40ed75efdade373e45b86b7382d4013a054208982bb6ba08
USER root
@ -1619,24 +1557,6 @@ function RootRedirect() {
NODE
RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko-heredoc-runner.py --dockerfile /tmp/hermes-agent.Dockerfile --block-index 9 ;; *) exit 2 ;; esac
# Reattachment reuses a live PTY, so a dashboard-only SIGCONT restores its
# alternate screen and current viewport after a bounded replay.
COPY dockerfiles/patch-hermes-terminal-replay.py /tmp/patch-hermes-terminal-replay.py
COPY dockerfiles/hermes-terminal-replay-regression.py /tmp/hermes-terminal-replay-regression.py
COPY dockerfiles/hermes-terminal-resume-regression.js /tmp/hermes-terminal-resume-regression.js
RUN /opt/hermes/.venv/bin/python /tmp/patch-hermes-terminal-replay.py \
&& HERMES_SOURCE_ROOT=/opt/hermes /opt/hermes/.venv/bin/python \
/tmp/hermes-terminal-replay-regression.py \
&& node /tmp/hermes-terminal-resume-regression.js \
&& node --check /opt/hermes/ui-tui/dist/entry.js
# The dashboard keeps keyboard and paste handling unchanged, but forwards only
# bounded SGR wheel reports when the TUI has explicitly enabled mouse tracking.
COPY dockerfiles/patch-hermes-dashboard-wheel.js /tmp/patch-hermes-dashboard-wheel.js
COPY dockerfiles/hermes-dashboard-terminal-input.ts /opt/hermes/web/src/lib/dashboard-terminal-input.ts
COPY dockerfiles/hermes-dashboard-terminal-input.test.ts /opt/hermes/web/src/lib/dashboard-terminal-input.test.ts
RUN node /tmp/patch-hermes-dashboard-wheel.js
COPY dockerfiles/patch-hermes-execution-safety.py /tmp/patch-hermes-execution-safety.py
COPY dockerfiles/hermes-execution-safety-regression.py /tmp/hermes-execution-safety-regression.py
COPY dockerfiles/hermes_execution_patch_support.py /tmp/hermes_execution_patch_support.py
@ -1646,7 +1566,7 @@ COPY dockerfiles/hermes_execution_regression_support.py /tmp/hermes_execution_re
COPY dockerfiles/hermes_run_safety_regression.py /tmp/hermes_run_safety_regression.py
COPY dockerfiles/hermes_decomposition_safety_regression.py /tmp/hermes_decomposition_safety_regression.py
COPY dockerfiles/hermes_lane_compatibility_regression.py /tmp/hermes_lane_compatibility_regression.py
COPY services/hermes/scripts/*.py /tmp/hermes-lane-regression/
COPY services/hermes/scripts/cli_lane_*.py /tmp/hermes-lane-regression/
RUN HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
HERMES_COMPATIBILITY_MODE=legacy \
/opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \
@ -1672,7 +1592,6 @@ COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrat
RUN rm -f /tmp/hermes-agent.Dockerfile /tmp/hermes-kaniko-heredoc-runner.py
RUN cd /opt/hermes/web \
&& npm test -- src/lib/dashboard-terminal-input.test.ts \
&& npm run build \
&& grep -Fq 'await api.getSessions(1, 0' src/pages/ChatPage.tsx \
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
@ -1704,8 +1623,6 @@ RUN cd /opt/hermes/web \
/opt/hermes/hermes_cli/web_server.py \
&& ! grep -Fq 'WebglAddon' src/pages/ChatPage.tsx \
&& grep -Fq 'scheduleTerminalPaint' src/pages/ChatPage.tsx \
&& ! grep -Fq 'attachCustomWheelEventHandler' src/pages/ChatPage.tsx \
&& grep -Fq 'isSgrWheelReport(data)' src/pages/ChatPage.tsx \
&& grep -Fq 'sessionTree.childrenByParent' \
src/components/ChatSessionList.tsx \
&& grep -Fq 'getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)' \

View File

@ -5,12 +5,6 @@
!dockerfiles/hermes-public-extract/**
!dockerfiles/hermes-session-activity-panel.tsx
!dockerfiles/hermes-session-migrate.py
!dockerfiles/patch-hermes-terminal-replay.py
!dockerfiles/hermes-terminal-replay-regression.py
!dockerfiles/hermes-terminal-resume-regression.js
!dockerfiles/patch-hermes-dashboard-wheel.js
!dockerfiles/hermes-dashboard-terminal-input.ts
!dockerfiles/hermes-dashboard-terminal-input.test.ts
!dockerfiles/Dockerfile.hermes-agent
!dockerfiles/hermes-kaniko-heredoc-runner.py
!dockerfiles/patch-hermes-execution-safety.py
@ -25,4 +19,4 @@
!services/
!services/hermes/
!services/hermes/scripts/
!services/hermes/scripts/*.py
!services/hermes/scripts/cli_lane_*.py

View File

@ -35,7 +35,7 @@ RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=
ENV HERMES_STT_HOST=0.0.0.0 \
HERMES_STT_PORT=9000 \
HERMES_STT_MODEL=large-v3-turbo \
HERMES_STT_MODEL=small \
HERMES_STT_ROLLING_MODEL=tiny \
HERMES_STT_CACHE=/opt/models/whisper \
PYTHONDONTWRITEBYTECODE=1 \

View File

@ -1,57 +1,21 @@
# syntax=docker/dockerfile:1.7
# dockerfiles/Dockerfile.hermes-switchyard
FROM --platform=$BUILDPLATFORM rust:1.96.1-slim-bookworm@sha256:e18a79fc84dfcfc3ab5ba72290398a644c135c97eaa881447fddc354ee4701a3 AS build
ARG SWITCHYARD_LIBSY_SHA256=85f12e1ffefa168604044ccf78535a34dcde38eb59653ac27465e44113cd848c
ARG SWITCHYARD_SERVER_SHA256=95946e3df637143dcdd2a34c153bcaa57b7d4bc3dee53ed6c5497ddd996e16bc
ARG TARGETARCH
FROM rust:1.96.1-slim-bookworm@sha256:e18a79fc84dfcfc3ab5ba72290398a644c135c97eaa881447fddc354ee4701a3 AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
curl \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
linux-libc-dev-arm64-cross \
patch \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY dockerfiles/switchyard-capability-fallback.patch /tmp/switchyard-capability-fallback.patch
RUN cargo install \
--locked \
--version 0.2.0 \
--root /opt/switchyard \
switchyard-server
RUN rustup target add aarch64-unknown-linux-gnu
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
RUN --mount=type=cache,id=switchyard-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=switchyard-cargo-target-aarch64,target=/opt/switchyard-target,sharing=locked \
set -eux; \
test "$TARGETARCH" = arm64; \
mkdir -p /opt/switchyard-source; \
curl --fail --location --retry 3 --output /tmp/libsy.crate \
https://static.crates.io/crates/switchyard-libsy/switchyard-libsy-0.2.0.crate; \
echo "${SWITCHYARD_LIBSY_SHA256} /tmp/libsy.crate" | sha256sum --check --status -; \
tar --extract --gzip --file /tmp/libsy.crate --directory /opt/switchyard-source; \
patch --strip=1 --directory /opt/switchyard-source/switchyard-libsy-0.2.0 \
--input /tmp/switchyard-capability-fallback.patch; \
curl --fail --location --retry 3 --output /tmp/server.crate \
https://static.crates.io/crates/switchyard-server/switchyard-server-0.2.0.crate; \
echo "${SWITCHYARD_SERVER_SHA256} /tmp/server.crate" | sha256sum --check --status -; \
tar --extract --gzip --file /tmp/server.crate --directory /opt/switchyard-source; \
CARGO_TARGET_DIR=/opt/switchyard-target cargo test --locked \
--manifest-path /opt/switchyard-source/switchyard-libsy-0.2.0/Cargo.toml \
automatic_fallback --lib; \
printf '\n[patch.crates-io]\nswitchyard-libsy = { path = "/opt/switchyard-source/switchyard-libsy-0.2.0" }\n' \
>> /opt/switchyard-source/switchyard-server-0.2.0/Cargo.toml; \
CARGO_TARGET_DIR=/opt/switchyard-target cargo install --locked --target aarch64-unknown-linux-gnu \
--path /opt/switchyard-source/switchyard-server-0.2.0 \
--root /opt/switchyard; \
rm -rf /opt/switchyard-source /tmp/libsy.crate /tmp/server.crate /tmp/switchyard-capability-fallback.patch
FROM --platform=$TARGETPLATFORM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241
FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /opt/switchyard/bin/switchyard-server /usr/local/bin/switchyard-server

View File

@ -1,25 +1,8 @@
# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-webui
#
# Both FROM bases below are multi-arch (linux/amd64 + linux/arm64). Kaniko builds
# one native leaf per node arch (arm64 on titan-20, amd64 on titan-24) and each
# leaf selects the matching arch from these indexes; ci/scripts/hermes_multiarch_combine.py
# then binds the two leaves into one manifest list. This is what lets the agent
# pod's `hux` sidecar (which runs this image) schedule onto the amd64 node titan-22.
#
# The upstream WebUI base is a multi-arch OCI index
# (sha256:a83a3893... -> amd64 sha256:54fd4990..., arm64 sha256:9094ae6a...). It is
# mirrored digest-for-digest into the in-cluster Harbor `mirror` project by
# services/harbor/hermes-webui-base-mirror-job.yaml so the build never depends on
# ghcr.io egress (flaky from build pods). Bump this digest and BOTH args in that
# Job together, then an operator re-runs the (suspended) Job once.
FROM harbor-core.harbor.svc.cluster.local/mirror/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
# Layer each arch's WebUI on the matching leaf of the multi-arch Hermes agent index
# (sha256:a68d1c4d... -> amd64 sha256:c89ac4bc..., arm64 sha256:572854cb...), a Docker
# manifest list already in Harbor's bstein project. Kaniko's --registry-mirror pulls
# it internally through harbor-core.
FROM registry.bstein.dev/bstein/hermes-agent@sha256:a68d1c4d5517cc5e6719661e77be4f18d4964b07a85dcebaf6f62368646e4e6c
FROM registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
ARG HERMES_WEBUI_RELEASE_ID
@ -28,10 +11,6 @@ USER root
# Keep WebUI and Hermes pinned together. The WebUI imports Hermes internals,
# while the gateway remains the only process that owns an agent conversation.
COPY --from=webui /apptoo /opt/hermes-webui
COPY dockerfiles/hermes-hux-foundation/hux /opt/hermes-hux/hux
COPY dockerfiles/hermes-hux-foundation/hux_producer /opt/hermes-hux/hux_producer
COPY services/hermes/contracts/hux /opt/hermes-hux/contracts
ENV HUX_CONTRACT_DIR=/opt/hermes-hux/contracts
# 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.
@ -44,27 +23,6 @@ COPY dockerfiles/hermes-webui-atlas-voice-worklet.js /opt/hermes-webui/static/at
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-hux-bff-patch.py /tmp/hermes-webui-hux-bff-patch.py
COPY dockerfiles/hermes-webui-hux-context.py /tmp/hermes-webui-hux-context.py
COPY dockerfiles/hermes-webui-hux-context-patch.py /tmp/hermes-webui-hux-context-patch.py
COPY dockerfiles/hermes-webui-hux-patch.py /tmp/hermes-webui-hux-patch.py
COPY dockerfiles/hermes-webui-hux/foundation.css /opt/hermes-webui/static/hux/foundation.css
COPY dockerfiles/hermes-webui-hux/foundation.js /opt/hermes-webui/static/hux/foundation.js
COPY dockerfiles/hermes-webui-hux/shell.js /opt/hermes-webui/static/hux/shell.js
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.css /opt/hermes-webui/static/hux/runtime/autonomy-privacy.css
COPY dockerfiles/hermes-webui-hux/runtime/autonomy-privacy.js /opt/hermes-webui/static/hux/runtime/autonomy-privacy.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.css /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_activity_memory.js /opt/hermes-webui/static/hux/runtime/wave_a_activity_memory.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_a_contract.js /opt/hermes-webui/static/hux/runtime/wave_a_contract.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b.css /opt/hermes-webui/static/hux/runtime/wave_b.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_artifacts_research.js /opt/hermes-webui/static/hux/runtime/wave_b_artifacts_research.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_contract.js /opt/hermes-webui/static/hux/runtime/wave_b_contract.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_projects_modes.js /opt/hermes-webui/static/hux/runtime/wave_b_projects_modes.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_b_runtime.js /opt/hermes-webui/static/hux/runtime/wave_b_runtime.js
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.css /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.css
COPY dockerfiles/hermes-webui-hux/runtime/wave_c_multimodal_onboarding_release.js /opt/hermes-webui/static/hux/runtime/wave_c_multimodal_onboarding_release.js
COPY dockerfiles/hermes-webui-hux/bootstrap.css /opt/hermes-webui/static/hux/bootstrap.css
COPY dockerfiles/hermes-webui-hux/bootstrap.js /opt/hermes-webui/static/hux/bootstrap.js
COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py
COPY dockerfiles/hermes-webui-manifest-patch.py /tmp/hermes-webui-manifest-patch.py
COPY dockerfiles/hermes-webui-release-patch.py /tmp/hermes-webui-release-patch.py
@ -79,19 +37,12 @@ RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-bff-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-context-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-manifest-patch.py \
&& /opt/hermes/.venv/bin/python /tmp/hermes-webui-hux-patch.py \
&& HERMES_WEBUI_RELEASE_ID="${HERMES_WEBUI_RELEASE_ID}" \
/opt/hermes/.venv/bin/python /tmp/hermes-webui-release-patch.py
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& test -f /opt/hermes-hux/contracts/flags.json \
&& test -f /opt/hermes-hux/contracts/release-ledger.schema.json \
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -m compileall -q /opt/hermes-hux/hux /opt/hermes-hux/hux_producer \
&& PYTHONPATH=/opt/hermes-hux /opt/hermes/.venv/bin/python -c 'from pathlib import Path; from tempfile import TemporaryDirectory; from hux.server import build_router; root = TemporaryDirectory(); router = build_router(Path(root.name), {"HUX_FLAGS": ""}); assert router.routes; root.cleanup()' \
&& 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 \
@ -101,7 +52,6 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'user-scalable=no, viewport-fit=cover' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings["webui_bundle_version"]' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings.webui_bundle_version||settings.webui_version' /opt/hermes-webui/static/panels.js \
@ -113,18 +63,6 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
&& grep -Fq 'from api.hux_bff import proxy_hux' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'from api.hux_context import attach_hux_context' /opt/hermes-webui/api/routes.py \
&& grep -Fq '"schema": "hux.webui_context.v1"' /opt/hermes-webui/api/hux_context.py \
&& grep -Fq '"project_source": raw_project' /opt/hermes-webui/api/hux_context.py \
&& grep -Fq 'static/hux/bootstrap.js' /opt/hermes-webui/static/index.html \
&& grep -Fq "'./static/hux/bootstrap.js' + VQ" /opt/hermes-webui/static/sw.js \
&& grep -Fq 'value.hux_context' /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq "'/hux/v1/capabilities'" /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq "'/hux/v1/context/bootstrap'" /opt/hermes-webui/static/hux/bootstrap.js \
&& grep -Fq 'HermesHuxBootstrap.mergeTrustedContext(S.session, startData)' /opt/hermes-webui/static/messages.js \
&& grep -Fq 'respBody.cancelled===true && respBody.stream_id===streamId' /opt/hermes-webui/static/boot.js \
&& grep -Fq 'prefers-reduced-motion: no-preference' /opt/hermes-webui/static/hux/bootstrap.css \
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
@ -155,8 +93,6 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
/opt/hermes-webui/api/routes.py \
/opt/hermes-webui/api/upload.py \
/opt/hermes-webui/api/gateway_chat.py \
/opt/hermes-webui/api/hux_bff.py \
/opt/hermes-webui/api/hux_context.py \
/opt/hermes/tools/transcription_tools.py
# Exercise branded responses from the real upstream server process in the
@ -202,7 +138,6 @@ ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
HERMES_WEBUI_SKIP_ONBOARDING=1 \
HERMES_WEBUI_SECURE=1 \
PYTHONPATH=/opt/hermes-hux \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

View File

@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { isSgrWheelReport } from "./dashboard-terminal-input";
describe("isSgrWheelReport", () => {
it("accepts xterm SGR wheel reports, including modifiers", () => {
expect(isSgrWheelReport("\x1b[<64;80;25M")).toBe(true);
expect(isSgrWheelReport("\x1b[<65;80;25M")).toBe(true);
expect(isSgrWheelReport("\x1b[<84;80;25M")).toBe(true);
});
it("rejects clicks, releases, malformed reports, and arbitrary input", () => {
expect(isSgrWheelReport("\x1b[<0;80;25M")).toBe(false);
expect(isSgrWheelReport("\x1b[<64;80;25m")).toBe(false);
expect(isSgrWheelReport("\x1b[<66;80;25M")).toBe(false);
expect(isSgrWheelReport("\x1b[<64;10000;25M")).toBe(false);
expect(isSgrWheelReport("\x1b[<64;80;25M\rmalicious")).toBe(false);
expect(isSgrWheelReport("/submit\r")).toBe(false);
});
});

View File

@ -1,27 +0,0 @@
/** Validate the only mouse reports the dashboard may relay to its PTY. */
// SGR wheel reports use button codes 64/65 plus optional Shift/Alt/Ctrl bits.
// Keep coordinates bounded so arbitrary control text never reaches the PTY.
const SGR_WHEEL_REPORT = /^\x1b\[<(\d{1,2});(\d{1,4});(\d{1,4})M$/;
const MIN_WHEEL_BUTTON = 64;
const MAX_WHEEL_BUTTON = 95;
const MAX_TERMINAL_COORDINATE = 9_999;
/** Return whether ``data`` is one bounded SGR wheel report from xterm. */
export function isSgrWheelReport(data: string): boolean {
const match = SGR_WHEEL_REPORT.exec(data);
if (!match) return false;
const button = Number(match[1]);
const column = Number(match[2]);
const row = Number(match[3]);
return (
button >= MIN_WHEEL_BUTTON &&
button <= MAX_WHEEL_BUTTON &&
(button & 3) <= 1 &&
column >= 1 &&
column <= MAX_TERMINAL_COORDINATE &&
row >= 1 &&
row <= MAX_TERMINAL_COORDINATE
);
}

View File

@ -1 +0,0 @@
"""Hermes User Experience foundation service package."""

View File

@ -9,7 +9,6 @@ actually happened to the run and its side effects (SO-41).
from __future__ import annotations
import re
from typing import Any
from hux import policy, rules
@ -25,19 +24,14 @@ LIMIT_OF = {
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
}
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
def checked_run_id(value: Any) -> str:
"""Return a canonical run id accepted by both body and path APIs."""
if not isinstance(value, str) or not RUN_ID_RE.fullmatch(value):
raise Invalid("run id is malformed or too long")
return value
def run_id_from(request: Request) -> str:
"""The run id in the path; the router already bounded its alphabet."""
return checked_run_id(request.params["id"])
run_id = request.params["id"]
if len(run_id) > 120:
raise Invalid("run id too long")
return run_id
# -- budgets -------------------------------------------------------------------
@ -48,30 +42,16 @@ def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
"""Current state for a run, enforced against its conversation's policy epoch aggregate."""
run_id = checked_run_id(run_id)
"""Current ``hux.budget_state.v1`` for a run measured against the effective policy."""
doc_id = f"bud_{policy.run_key(run_id)}"
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id)
if known_conversation and conversation_id and known_conversation != conversation_id:
raise Invalid("run is already bound to another conversation")
conversation_id = known_conversation or conversation_id
conversation_id = conversation_id or stored.get("_conversation_id")
level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None)
effective = policy.effective_policy(store, identity, level, scope_id)
limits = {k: v for k, v in effective["budgets"].items() if k != "scope"}
epoch = str(effective.get("_budget_epoch") or f"{effective['id']}:{effective['revision']}")
same_epoch = stored.get("_budget_epoch") == epoch
run_spent = {k: int(stored["spent"].get(k, 0)) if same_epoch else 0 for k in SPEND_KEYS}
aggregate = {k: 0 for k in SPEND_KEYS}
for record in store.scan(BUDGETS):
if record.get("_conversation_id") != conversation_id or record.get("_budget_epoch") != epoch:
continue
for key in SPEND_KEYS:
aggregate[key] += int(record.get("spent", {}).get(key, 0))
limits = {k: v for k, v in policy.effective_policy(store, identity, level, scope_id)["budgets"].items() if k != "scope"}
spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS}
return policy.checked({
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": aggregate, "limits": limits,
"exhausted": exhausted(aggregate, limits), "_conversation_id": conversation_id, "_id": doc_id,
"_run_spent": run_spent, "_budget_epoch": epoch,
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": spent, "limits": limits,
"exhausted": exhausted(spent, limits), "_conversation_id": conversation_id, "_id": doc_id,
})
@ -84,7 +64,6 @@ def get_budget(request: Request) -> Response:
def post_budget(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
policy.require_worker(request.identity, "budget reports")
body = policy.body_dict(request)
run_id = run_id_from(request)
conversation_id = body.get("conversation_id")
@ -99,11 +78,8 @@ def post_budget(request: Request) -> Response:
with request.store.lock(BUDGETS):
before = budget_state(request.store, request.identity, run_id, conversation_id)
known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else []
spent = {k: before["_run_spent"][k] + increments[k] for k in SPEND_KEYS}
doc = {
"id": before["_id"], "run_id": run_id, "spent": spent,
"_conversation_id": before["_conversation_id"], "_budget_epoch": before["_budget_epoch"],
}
spent = {k: before["spent"][k] + increments[k] for k in SPEND_KEYS}
doc = {"id": before["_id"], "run_id": run_id, "spent": spent, "_conversation_id": before["_conversation_id"]}
request.store.put(BUDGETS, doc)
after = budget_state(request.store, request.identity, run_id, conversation_id)
request.audit("budgets.write", after["_id"])
@ -122,21 +98,24 @@ def hashes_of(approval: dict[str, Any]) -> set[str]:
def run_conversation(store: TenantStore, run_id: str) -> str | None:
"""The conversation a trusted Worker bound to this run in its budget document (F4)."""
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
doc_id = f"bud_{policy.run_key(run_id)}"
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
return store.get(BUDGETS, doc_id)["_conversation_id"]
for record in store.scan(policy.APPROVALS):
if record["run_id"] == run_id:
return record["conversation_id"]
return None
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
"""The approval that releases this side effect, or why none does, plus the conversation the gate settled on.
Every release uses an approval record created for this exact run. A
session/always grant may let a later run create a new approved record,
but the old record itself never crosses run ids. External effects also
require the same argument hash; ``once`` names exactly one hash (SO-36,
SO-37, SO-39).
External effects release only against an approval for the same run and
the same argument hash, whatever the choice (F4, SO-39). Non-external
session/always approvals stay reusable inside their conversation, which
is the run's own conversation when the run is known and otherwise the
approval's. A ``once`` approval names exactly one hash (SO-36, SO-37).
"""
reason = "no approval for this run and capability"
for record in store.scan(policy.APPROVALS):
@ -145,7 +124,9 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
continue
same_run = record["run_id"] == run_id
choice = record.get("decision", {}).get("choice")
if not same_run:
reusable = choice in ("session", "always") and not external and not record["request"]["external"]
same_conv = record["conversation_id"] == (conversation_id or record["conversation_id"])
if not (same_run or (reusable and same_conv)):
continue
if record["status"] != "approved":
reason = f"approval {record['id']} is {record['status']}"
@ -168,7 +149,6 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
def gate(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
policy.require_worker(request.identity, "gate checks")
body = policy.body_dict(request)
run_id = run_id_from(request)
capability = body.get("capability")
@ -179,10 +159,6 @@ def gate(request: Request) -> Response:
raise Invalid("argument_hash must be sha256:<hex>")
external = bool(body.get("external", False))
conversation_id = run_conversation(request.store, run_id)
if policy.private_mode_denies(request.store, conversation_id, capability):
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="private_mode")
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: private mode", run_id=run_id)
return Response(200, {"proceed": False, "reason": "private_mode"})
state = budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
@ -204,12 +180,14 @@ def gate(request: Request) -> Response:
# -- stop ----------------------------------------------------------------------
def stop_outcome(body: dict[str, Any]) -> tuple[str, str]:
"""Return the cancellation outcome proven by the gateway's process registry (F8, SO-41)."""
def stop_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
if body.get("already_complete"):
return "already_complete", "already_complete"
if body.get("process_registry_empty") is not True:
return "failed_to_cancel", "process_registry_not_empty"
if request.identity.trust != "worker":
return "failed_to_cancel", "registry_state_not_from_gateway"
return "cancelled", "cancelled"
@ -220,14 +198,13 @@ def stop(request: Request) -> Response:
stop that really cancels or finds the run complete supersedes it with a
revision bump; an identical repeat still replays (F8).
"""
policy.require_worker(request.identity, "stop receipts")
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
outcome, reason = stop_outcome(body)
outcome, reason = stop_outcome(request, body)
with request.store.lock(RECEIPTS):
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
@ -256,3 +233,4 @@ def register_routes(router: Router) -> None:
router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget)
router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate)
router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop)

View File

@ -28,12 +28,10 @@ SCHEMA_FILES = (
"artifact.schema.json",
"permission.schema.json",
"mode.schema.json",
"multimodal.schema.json",
"citation.schema.json",
"suggestion.schema.json",
"privacy.schema.json",
"release.schema.json",
"release-ledger.schema.json",
)
SUPPORTED_KEYWORDS = frozenset(
{
@ -77,7 +75,7 @@ def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]:
def _walk(node: Any, path: str, problems: list[str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
if path.endswith(("/properties", "/$defs")):
if path.endswith("/properties") or path.endswith("/$defs"):
_walk(value, f"{path}/{key}", problems)
continue
if key not in SUPPORTED_KEYWORDS:

View File

@ -70,16 +70,6 @@ class TooLarge(HuxError):
status, code = 413, "too_large"
class RateLimited(HuxError):
"""The caller exceeded the bounded per-subject request rate."""
status, code = 429, "rate_limited"
def __init__(self, retry_after: int) -> None:
super().__init__("request rate limit exceeded")
self.retry_after = max(1, int(retry_after))
class ApprovalRequired(HuxError):
"""An action needs an approval record before it may proceed."""

View File

@ -11,7 +11,6 @@ reach at all.
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from hux.errors import FlagOff
@ -19,34 +18,21 @@ from hux.identity import Identity
from hux.rules import flag_enabled, flag_registry
CONTRACT_VERSION = "1.1.0"
RELEASE_TAG = re.compile(r"^git-([0-9a-f]{40})-build-[1-9][0-9]*-release$")
CARD_ROUTES: dict[str, list[str]] = {
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest", "/hux/v1/context/bootstrap"],
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest"],
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
"HUX-06": ["/hux/v1/modes", "/hux/v1/projects/{project_id}/conversations/{id}/mode"],
"HUX-07": [
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items",
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}",
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections",
"/hux/v1/projects/{project_id}/conversations/{id}/capture-intents",
],
"HUX-06": [],
"HUX-07": [],
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
"HUX-09": [
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate",
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions",
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states",
],
"HUX-09": [],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"],
"HUX-12": [
"/hux/v1/projects/{project_id}/conversations/{id}/releases",
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}",
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions",
],
"HUX-12": [],
}
# Cards whose routes are not shipped yet declare [] above until they land (F12).
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
# Everything else is 403 before the flag check. These are the agent-hook
@ -55,8 +41,7 @@ CARD_ROUTES: dict[str, list[str]] = {
# artifact writes. The conversation read is there so the hook can honour
# private mode (SO-28) before proposing a memory.
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"),
("POST", "/hux/v1/context/bootstrap"),
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("GET", "/hux/v1/releases"),
("POST", "/hux/v1/approvals"),
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
("GET", "/hux/v1/runs/{id}/budget"),
@ -65,11 +50,6 @@ WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
("GET", "/hux/v1/modes"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"),
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
})
@ -84,41 +64,17 @@ class Flags:
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
self._environ = dict(os.environ if environ is None else environ)
self._registry = flag_registry()
self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes)
def bind_routes(self, routes: Mapping[str, set[str]]) -> None:
"""Bind capability flags to the route templates actually registered by this process."""
unknown = set(routes) - set(self._registry)
if unknown:
raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}")
for card, actual in routes.items():
undeclared = actual - set(CARD_ROUTES.get(card, []))
if undeclared:
raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}")
self._route_cards = frozenset(
card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set())
)
def enabled(self, card: str) -> bool:
"""True only for a route-backed card whose configured flag chain is on."""
"""True when the card and its whole dependency chain are on."""
entry = self._registry.get(card)
enabled = bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ)
if enabled and card == "HUX-12":
from hux.release_security import configured
return configured(self._environ)
return enabled
return bool(entry) and flag_enabled(entry["flag"], self._environ)
def require(self, card: str) -> None:
"""Raise FlagOff unless the card is enabled."""
if not self.enabled(card):
raise FlagOff(f"{card} is not enabled")
@property
def environ(self) -> Mapping[str, str]:
"""Read-only process configuration for route-family policy checks."""
return self._environ
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
"""Serialise ``hux.capabilities.v1`` for one caller."""
cards = [
@ -138,13 +94,4 @@ class Flags:
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
"""Commit and image digest the pod was started with, when the operator set them."""
environ = os.environ if environ is None else environ
tag = environ.get("HUX_IMAGE_TAG", "")
commit = environ.get("HUX_BUILD_COMMIT", "")
if tag:
match = RELEASE_TAG.fullmatch(tag)
if not match:
raise ValueError("HUX_IMAGE_TAG is not an immutable WebUI release tag")
if commit and commit != match.group(1):
raise ValueError("HUX build commit conflicts with the immutable image tag")
commit = match.group(1)
return {"commit": commit, "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}
return {"commit": environ.get("HUX_BUILD_COMMIT", ""), "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}

View File

@ -1,169 +1,9 @@
"""HUX-11 routes: capabilities, manifest, and trusted context bootstrap."""
"""HUX-11 routes: capability negotiation and the data manifest."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import stat
from pathlib import Path
from typing import Any
from hux import contracts, organization
from hux.errors import Conflict, Forbidden, Invalid
from hux.flags import CONTRACT_VERSION
from hux.http import Request, Response, Router
from hux.store import check_id, now_iso
CONTEXT_FAMILY = "context_bindings"
CONTEXT_SCHEMA = "hux.context_bootstrap.v1"
CONTEXT_KEY_BYTES = 32
CONTEXT_MESSAGE = b"hux.context.id.v1"
RAW_RE = re.compile(r"^[A-Za-z0-9._:/@+\-]{1,240}$")
CONTEXT_ID_RE = re.compile(r"^(ses|conv|prj)_[0-9a-f]{32}$")
SCHEMAS = contracts.load_all()
def _context_key(environ: dict[str, str]) -> bytes:
"""Read the exact 0600 regular key file; inline keys and links fail closed."""
raw_path = environ.get("HUX_CONTEXT_KEY_FILE", "")
if not raw_path:
raise Forbidden("context identity key is unavailable")
path = Path(raw_path)
try:
metadata = path.lstat()
except OSError as error:
raise Forbidden("context identity key is unavailable") from error
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_uid != os.geteuid():
raise Forbidden("context identity key is unsafe")
try:
value = path.read_bytes()
except OSError as error:
raise Forbidden("context identity key is unavailable") from error
if len(value) != CONTEXT_KEY_BYTES:
raise Forbidden("context identity key is invalid")
return value
def _source(body: dict[str, Any], name: str) -> str:
value = body.get(name)
if not isinstance(value, str) or not RAW_RE.fullmatch(value):
raise Invalid(f"{name} is malformed")
return value
def derive_context_id(key: bytes, purpose: str, identity: Any, raw: str) -> str:
"""Derive one frozen HMAC-bound context id."""
prefix = {"session": "ses", "conversation": "conv", "project": "prj"}[purpose]
message = b"\0".join(
(CONTEXT_MESSAGE, purpose.encode(), identity.tenant_slot.encode(), identity.subject.encode(), raw.encode())
)
return f"{prefix}_{hmac.new(key, message, hashlib.sha256).hexdigest()[:32]}"
def _exact_body(request: Request) -> tuple[dict[str, Any], str, str]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
expected = {"raw_session_id", "project_source", "session_id", "conversation_id", "project_id"}
if set(request.body) != expected:
raise Invalid("context bootstrap fields are not exact")
raw_session = _source(request.body, "raw_session_id")
project_source = _source(request.body, "project_source")
for name, prefix in (("session_id", "ses"), ("conversation_id", "conv"), ("project_id", "prj")):
value = request.body.get(name)
if not isinstance(value, str) or not CONTEXT_ID_RE.fullmatch(value) or not value.startswith(f"{prefix}_"):
raise Invalid(f"{name} is malformed")
check_id(value)
return request.body, raw_session, project_source
def _verify_record(record: dict[str, Any], schema: str, owner: str, project_id: str | None = None) -> None:
if record.get("schema") != schema or record.get("owner") != owner:
raise Conflict("context identity does not match existing record")
if project_id is not None and record.get("project_id") != project_id:
raise Conflict("context linkage does not match existing record")
if contracts.validate_record(record, SCHEMAS):
raise Conflict("existing context record is invalid")
def _response(request: Request, project: dict[str, Any], conversation: dict[str, Any], created: dict[str, bool]) -> Response:
body = {
"schema": CONTEXT_SCHEMA,
"contract_version": CONTRACT_VERSION,
"identity": request.identity.record(),
"session_id": request.body["session_id"],
"conversation_id": conversation["id"],
"project_id": project["id"],
"created": created,
"revisions": {"project": project["revision"], "conversation": conversation["revision"]},
}
return Response(201 if any(created.values()) else 200, body)
def bootstrap_context(request: Request) -> Response:
"""Create deterministic server-owned project and conversation records for trusted runtimes."""
if request.identity.trust not in {"relay", "worker"}:
raise Forbidden("context bootstrap requires relay or worker trust")
body, raw_session, project_source = _exact_body(request)
idem = request.idempotency_key()
if not idem:
raise Invalid("Idempotency-Key is required")
key = _context_key(request.flags._environ)
expected = {
"session_id": derive_context_id(key, "session", request.identity, raw_session),
"conversation_id": derive_context_id(key, "conversation", request.identity, raw_session),
"project_id": derive_context_id(key, "project", request.identity, project_source),
}
if any(not hmac.compare_digest(body[name], value) for name, value in expected.items()):
raise Invalid("context identifiers do not match authenticated inputs")
with request.store.lock(CONTEXT_FAMILY):
binding = request.store.get(CONTEXT_FAMILY, body["session_id"]) if request.store.exists(CONTEXT_FAMILY, body["session_id"]) else None
if binding:
valid_binding = (
binding.get("schema") == "hux.context_binding.v1"
and binding.get("id") == body["session_id"]
and binding.get("owner") == request.identity.subject
and all(binding.get(name) == body[name] for name in expected)
)
if not valid_binding:
raise Conflict("context binding does not match existing linkage")
for row in request.store.read(CONTEXT_FAMILY, "idempotency"):
if row.get("key") == idem and any(row.get(name) != body[name] for name in expected):
raise Conflict("Idempotency-Key was used for another context")
project = request.store.get(organization.PROJECTS, body["project_id"]) if request.store.exists(organization.PROJECTS, body["project_id"]) else None
conversation = request.store.get(organization.CONVERSATIONS, body["conversation_id"]) if request.store.exists(organization.CONVERSATIONS, body["conversation_id"]) else None
if project:
_verify_record(project, "hux.project.v1", request.identity.subject)
if conversation:
_verify_record(conversation, "hux.conversation.v1", request.identity.subject, body["project_id"])
if project is None and request.store.count(organization.PROJECTS) >= organization.MAX_PROJECTS:
raise Conflict("projects cap reached")
if conversation is None and request.store.count(organization.CONVERSATIONS) >= organization.MAX_CONVERSATIONS:
raise Conflict("conversations cap reached")
stamp = now_iso()
created = {"project": project is None, "conversation": conversation is None}
project = project or request.store.put(organization.PROJECTS, organization.checked({
"schema": "hux.project.v1", "id": body["project_id"], "owner": request.identity.subject,
"name": "Hermes", "tags": [], "pinned": False, "archived": False, "default_mode": "fast",
"created_at": stamp, "updated_at": stamp, "revision": 1,
}))
conversation = conversation or request.store.put(organization.CONVERSATIONS, organization.checked({
"schema": "hux.conversation.v1", "id": body["conversation_id"], "owner": request.identity.subject,
"project_id": body["project_id"], "title": "Hermes conversation", "tags": [], "pinned": False,
"archived": False, "mode": "fast", "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
"revision": 1,
}))
if binding is None:
request.store.put(CONTEXT_FAMILY, {
"schema": "hux.context_binding.v1", "id": body["session_id"], "owner": request.identity.subject,
"session_id": body["session_id"], "conversation_id": body["conversation_id"],
"project_id": body["project_id"], "created_at": stamp,
})
if not any(row.get("key") == idem for row in request.store.read(CONTEXT_FAMILY, "idempotency")):
request.store.append(CONTEXT_FAMILY, "idempotency", {"key": idem, **expected})
request.audit("foundation.bootstrap", body["session_id"], reason="created" if any(created.values()) else "replayed")
return _response(request, project, conversation, created)
def capabilities(request: Request) -> Response:
@ -182,4 +22,3 @@ def register(router: Router) -> None:
"""Attach HUX-11 routes."""
router.add("GET", "/hux/v1/capabilities", "HUX-11", "foundation.capabilities", capabilities)
router.add("GET", "/hux/v1/manifest", "HUX-11", "foundation.manifest", manifest)
router.add("POST", "/hux/v1/context/bootstrap", "HUX-11", "foundation.bootstrap", bootstrap_context, 4096)

View File

@ -9,69 +9,24 @@ Errors always leave as ``hux.error.v1``.
from __future__ import annotations
import json
import math
import os
import re
import threading
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from typing import Any, Callable
from collections.abc import Mapping
from urllib.parse import parse_qs, urlsplit
from hux import audit
from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, TooLarge
from hux.errors import Forbidden, HuxError, Invalid, NotFound, TooLarge
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
from hux.identity import Identity, resolve
from hux.store import TenantStore
MAX_BODY_BYTES = 1024 * 1024
MAX_QUERY_FIELDS = 64
DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
DEFAULT_READS_PER_MINUTE = 300
DEFAULT_WRITES_PER_MINUTE = 30
MAX_RATE_BUCKETS = 4096
Handler = Callable[["Request"], "Response"]
def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float:
"""Read one numeric setting, using its safe default when malformed or outside bounds."""
try:
value = float(environ.get(name, default))
except (TypeError, ValueError):
return default
return value if math.isfinite(value) and low <= value <= high else default
class RateLimiter:
"""Small fixed-window limiter bounded by active subjects in the current minute."""
def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None:
self.limits = {"read": reads, "write": writes}
self.clock = clock
self._windows: dict[tuple[str, str], tuple[float, int]] = {}
self._lock = threading.Lock()
def check(self, subject: str, method: str) -> int | None:
"""Consume one request and return Retry-After seconds when its bucket is full."""
now = self.clock()
bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write"
key = (subject, bucket)
with self._lock:
# Each accepted identity can leave at most two current entries;
# expired identities disappear whenever any request arrives.
self._windows = {item: value for item, value in self._windows.items() if value[0] > now}
reset, used = self._windows.get(key, (now + 60.0, 0))
if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS:
return 60
if used >= self.limits[bucket]:
return max(1, math.ceil(reset - now))
self._windows[key] = (reset, used + 1)
return None
@dataclass
class Request:
"""Everything a handler needs; no raw socket access."""
@ -147,28 +102,15 @@ class Router:
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
self.data_root = Path(data_root)
self.environ = dict(os.environ if environ is None else environ)
self.flags = Flags(self.environ)
self.build = build_from_environ(self.environ)
self.environ = environ
self.flags = Flags(environ)
self.build = build_from_environ(environ)
self.routes: list[Route] = []
reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000))
writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000))
self.request_timeout = _bounded_number(
self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0
)
self.rate_limiter = RateLimiter(reads, writes)
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
self.routes.append(Route(method, template, card, action, handler, max_body))
def bind_capability_routes(self) -> None:
"""Make feature negotiation depend on the route templates this process really registered."""
routes: dict[str, set[str]] = {}
for route in self.routes:
routes.setdefault(route.card, set()).add(route.template)
self.flags.bind_routes(routes)
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
"""Return (route, params, path_known)."""
known = False
@ -180,61 +122,36 @@ class Router:
return route, found.groupdict(), True
return None, {}, known
def body_limit(self, method: str, raw_path: str) -> int:
"""Maximum declared body for a matching route, before a socket read allocates it."""
route, _, _ = self.match(method, urlsplit(raw_path).path)
return route.max_body if route is not None else MAX_BODY_BYTES
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
"""Run the full pipeline and never raise."""
parts = urlsplit(raw_path)
route: Route | None = None
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
try:
identity = resolve(headers, self.environ)
store = TenantStore(self.data_root, identity)
except HuxError as error:
return Response(error.status, error.record())
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
try:
retry_after = self.rate_limiter.check(identity.subject, method)
if retry_after is not None:
raise RateLimited(retry_after)
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
# SO-08: the worker allowlist is checked before the flag so a
# worker cannot even learn which cards are on.
if identity.trust == "worker" and not worker_may_call(method, route.template):
raise Forbidden("route is not available to worker trust")
if identity.trust == "evidence":
allowed = {
("GET", "/hux/v1/capabilities"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"),
}
if (method, route.template) not in allowed:
raise Forbidden("route is not available to evidence trust")
self.flags.require(route.card)
try:
parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS)
except ValueError as error:
raise Invalid("query has too many fields") from error
query = {key: values[-1] for key, values in parsed_query.items()}
payload = self._decode(body, route.max_body)
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
response = route.handler(request)
except HuxError as error:
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, outcome, error.message)
headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {}
return Response(error.status, error.record(), headers)
audit.record(store, identity, route.action, parts.path, outcome, error.message)
return Response(error.status, error.record())
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
error = HuxError("internal error")
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, "deny", error.message)
audit.record(store, identity, route.action, parts.path, "deny", error.message)
return Response(error.status, error.record())
return response
@ -261,48 +178,22 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
class HuxHandler(BaseHTTPRequestHandler):
server_version = "hux-foundation/1.0"
def log_message(self, fmt: str, *args: Any) -> None:
def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102 - quiet by design; audit ledger is the log
return
def _run(self) -> None:
try:
if self.headers.get("Transfer-Encoding"):
raise Invalid("Transfer-Encoding is not supported")
lengths = self.headers.get_all("Content-Length", [])
if len(lengths) > 1:
raise Invalid("duplicate Content-Length is not supported")
raw_length = lengths[0] if lengths else "0"
if not raw_length.isascii() or not raw_length.isdigit():
raise Invalid("Content-Length must be a non-negative integer")
if len(raw_length) > 10:
raise TooLarge("declared body length is too large")
length = int(raw_length)
limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path)
if length > limit:
raise TooLarge(f"body exceeds {limit} bytes")
body = self.rfile.read(length) if length else b""
if len(body) != length:
raise Invalid("request body is incomplete or timed out")
if self.path == "/healthz":
body = {"status": "ok", "contract_version": CONTRACT_VERSION}
scheduler = getattr(router, "retention_scheduler", None)
if scheduler is not None:
body["retention"] = scheduler.health()
self._send(Response(200, body))
return
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
except HuxError as error:
self._send(Response(error.status, error.record()))
except (OSError, TimeoutError):
error = Invalid("request body is incomplete or timed out")
self._send(Response(error.status, error.record()))
if self.path == "/healthz":
self._send(Response(200, {"status": "ok", "contract_version": CONTRACT_VERSION}))
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
def _send(self, response: Response) -> None:
if response.stream is not None:
self.send_response(response.status)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Cache-Control", "no-cache")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
@ -314,8 +205,6 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
self.send_response(response.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
@ -326,34 +215,8 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
return HuxHandler
class BoundedHTTPServer(ThreadingHTTPServer):
"""Threading server that bounds header and body socket reads from accept onward."""
def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float, scheduler: Any = None) -> None:
self.request_timeout = timeout
self.scheduler = scheduler
super().__init__(address, handler)
def get_request(self) -> tuple[Any, Any]:
"""Accept one connection and apply its per-request socket deadline."""
request, address = super().get_request()
request.settimeout(self.request_timeout)
return request, address
def server_close(self) -> None:
"""Stop background work before closing the listening socket."""
if self.scheduler is not None:
self.scheduler.stop()
super().server_close()
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
"""Create (but do not start) the server; callers call serve_forever()."""
from hux.retention_scheduler import RetentionScheduler
scheduler = RetentionScheduler(router)
router.retention_scheduler = scheduler
server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout, scheduler)
server = ThreadingHTTPServer((host, port), make_handler(router))
server.daemon_threads = True
scheduler.start()
return server

View File

@ -2,8 +2,8 @@
The chat router, the Telegram relay and the Worker are the only callers. Each
asserts identity in headers; the request body is never trusted for identity.
Router, relay, and worker callers must each present their distinct shared key,
compared in constant time against an inline or projected secret value.
Relay and worker callers must also present their shared key, compared in
constant time against the value the pod was started with.
"""
from __future__ import annotations
@ -11,11 +11,8 @@ from __future__ import annotations
import hmac
import os
import re
import stat
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from collections.abc import Mapping
from hux.errors import Unauthorized
@ -28,14 +25,8 @@ HEADER_KEY = "X-Hux-Relay-Key"
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
SURFACES = ("chat", "worker", "telegram", "voice", "api")
TRUSTS = ("router", "relay", "worker", "evidence")
KEY_ENV = {
"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY",
"worker": "HUX_WORKER_KEY", "evidence": "HUX_RELEASE_EVIDENCE_KEY",
}
MAX_KEY_BYTES = 4096
MAX_SUBJECT_BYTES = 128
SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE"
TRUSTS = ("router", "relay", "worker")
KEY_ENV = {"relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"}
@dataclass(frozen=True)
@ -59,116 +50,11 @@ def _header(headers: Mapping[str, str], name: str) -> str:
return ""
def _expected_key(environ: Mapping[str, str], trust: str) -> str:
"""Read a caller key, preferring a bounded Kubernetes secret file."""
if trust == "evidence":
from hux.release_security import evidence_key
try:
return evidence_key(environ)
except Exception: # noqa: BLE001 - authentication fails closed without revealing configuration detail
return ""
name = KEY_ENV[trust]
key_file = environ.get(f"{name}_FILE", "")
if key_file:
try:
path = Path(key_file)
mode = path.stat().st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
return ""
data = path.read_bytes()
except OSError:
return ""
if len(data) > MAX_KEY_BYTES:
return ""
try:
return data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError:
return ""
return environ.get(name, "")
def _read_subject_binding(path: Path) -> str | None:
"""Read a complete, permission-constrained binding without following links."""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
return None
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
try:
mode = os.fstat(descriptor).st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
raise Unauthorized("subject binding is invalid")
with os.fdopen(descriptor, "rb", closefd=False) as binding:
data = binding.read(MAX_SUBJECT_BYTES + 1)
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
os.close(descriptor)
if not data or len(data) > MAX_SUBJECT_BYTES:
raise Unauthorized("subject binding is invalid")
try:
value = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise Unauthorized("subject binding is invalid") from error
if not SUBJECT_RE.fullmatch(value):
raise Unauthorized("subject binding is invalid")
return value
def _publish_subject_binding(path: Path, subject: str) -> None:
"""Publish an immutable first-writer binding; concurrent writers never see partial data."""
descriptor = -1
temporary = ""
try:
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
os.fchmod(descriptor, 0o440)
with os.fdopen(descriptor, "wb", closefd=False) as binding:
binding.write((subject + "\n").encode("utf-8"))
binding.flush()
os.fsync(descriptor)
os.close(descriptor)
descriptor = -1
try:
os.link(temporary, path, follow_symlinks=False)
except FileExistsError:
pass
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if temporary:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
except OSError:
# The binding decision is still verified by a fresh read below.
pass
def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None:
"""Bind from an authenticated edge hop, then require every caller to match."""
raw_path = environ.get(SUBJECT_BINDING_ENV, "")
if not raw_path:
return
path = Path(raw_path)
bound = _read_subject_binding(path)
if bound is None:
if trust == "worker":
raise Unauthorized("worker subject is not bound")
_publish_subject_binding(path, subject)
bound = _read_subject_binding(path)
if bound is None or not hmac.compare_digest(bound, subject):
raise Unauthorized("subject does not match trusted binding")
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
"""Build an Identity from request headers or raise Unauthorized.
``trust`` defaults to ``router``; every trusted hop requires its distinct
shared key to be configured and presented.
``trust`` defaults to ``router``; relay and worker trust require the
matching shared key to be configured and presented.
"""
environ = os.environ if environ is None else environ
slot = _header(headers, HEADER_SLOT)
@ -184,13 +70,11 @@ def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None
raise Unauthorized("missing or malformed subject")
if surface not in SURFACES or trust not in TRUSTS:
raise Unauthorized("unknown surface or trust")
expected = _expected_key(environ, trust)
presented = _header(headers, HEADER_KEY)
if not expected or not presented or not hmac.compare_digest(expected, presented):
raise Unauthorized(f"{trust} key missing or wrong")
if trust in KEY_ENV:
expected = environ.get(KEY_ENV[trust], "")
presented = _header(headers, HEADER_KEY)
if not expected or not presented or not hmac.compare_digest(expected, presented):
raise Unauthorized(f"{trust} key missing or wrong")
if trust == "router" and surface == "worker":
raise Unauthorized("worker surface needs worker trust")
if trust == "evidence" and surface != "api":
raise Unauthorized("evidence trust needs api surface")
_enforce_subject_binding(environ, trust, subject)
return Identity(slot, subject, surface, trust)

View File

@ -1,169 +0,0 @@
"""HUX-06 provider-neutral friendly modes and scoped route selection.
Automatic modes express intent to Switchyard and never name a provider. An
exact route can only be selected through the explicit advanced path and must
exist in the operator-provided route catalog. Private mode is local-only.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from typing import Any
from hux import contracts, rules
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import now_iso
CARD = "HUX-06"
FAMILY = "mode_selections"
MODE_NAMES = ("fast", "thoughtful", "research", "create", "private")
MANUAL_ROUTE = re.compile(r"^atlas/manual/(codex|claude|local)/[a-z0-9][a-z0-9/-]{0,100}$")
MAX_CATALOG_ROUTES = 256
SCHEMAS = contracts.load_all()
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - {"project_id", "mode", "advanced", "override_route_id"}
if extra:
raise Invalid("unexpected mode fields", sorted(extra))
return request.body
def _scope(request: Request, project_id: Any) -> tuple[dict[str, Any], dict[str, Any]]:
"""Resolve a conversation and its mandatory owning project."""
from hux import organization
try:
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
except (Invalid, NotFound) as error:
raise NotFound("conversation not found") from error
path_project = request.params.get("project_id")
actual = conversation.get("project_id")
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
raise NotFound("project or conversation not found")
try:
project = request.store.get(organization.PROJECTS, project_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
return project, conversation
def _record_id(conversation_id: str) -> str:
digest = hashlib.sha256(conversation_id.encode()).hexdigest()[:24]
return f"mode_{digest}"
def _catalog() -> set[str]:
raw = os.environ.get("HUX_SWITCHYARD_ROUTE_CATALOG", "")
items = [item.strip() for item in raw.split(",") if item.strip()]
if len(items) > MAX_CATALOG_ROUTES:
return set()
return {item for item in items if MANUAL_ROUTE.fullmatch(item)}
def _contract(mode: str, advanced: bool, override: Any) -> dict[str, Any]:
if not isinstance(mode, str) or mode not in MODE_NAMES:
raise Invalid("unknown friendly mode")
if override is not None:
if not advanced or not isinstance(override, str) or not MANUAL_ROUTE.fullmatch(override):
raise Invalid("an exact route requires a valid advanced manual route")
if override not in _catalog():
raise Invalid("advanced route is not in the Switchyard catalog")
if mode == "private" and not override.startswith("atlas/manual/local/"):
raise Invalid("private mode is local-only")
elif advanced:
raise Invalid("advanced selection requires override_route_id")
try:
result = rules.mode_contract(mode, override)
except ValueError as error:
raise Invalid(str(error)) from error
problems = contracts.validate("mode.schema.json", result, SCHEMAS)
if problems:
raise Invalid("mode failed contract validation", problems)
if mode != "private" and result["switchyard"]["route_id"].startswith("atlas/manual/"):
raise Invalid("automatic modes may not pin a provider")
return result
def _fingerprint(body: dict[str, Any]) -> str:
allowed = {key: body.get(key) for key in ("project_id", "mode", "advanced", "override_route_id")}
return hashlib.sha256(json.dumps(allowed, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different selection")
return request.store.get(FAMILY, row["id"])
return None
def list_modes(request: Request) -> Response:
"""Return all provider-neutral mode contracts."""
items = [_contract(name, False, None) for name in MODE_NAMES]
request.audit("modes.list", "modes")
return page(items)
def get_selection(request: Request) -> Response:
"""Return the selected mode for one bound conversation."""
project_id = request.params["project_id"]
_, conversation = _scope(request, project_id)
record = request.store.get(FAMILY, _record_id(conversation["id"]))
request.audit("modes.read", record["id"])
return Response(200, record, {"ETag": str(record["revision"]), "Cache-Control": "no-store"})
def put_selection(request: Request) -> Response:
"""Select a mode using If-Match and Idempotency-Key."""
body = _body(request)
_, conversation = _scope(request, body.get("project_id"))
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required; use 0 for the first selection")
fingerprint = _fingerprint(body)
mode = _contract(body.get("mode"), body.get("advanced") is True, body.get("override_route_id"))
record_id = _record_id(conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
request.audit("modes.select", replayed["id"], reason="idempotent_replay")
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
exists = request.store.exists(FAMILY, record_id)
if expected != (request.store.get(FAMILY, record_id)["revision"] if exists else 0):
raise Conflict("mode selection revision does not match If-Match")
stamp = now_iso()
record = {
"id": record_id,
"schema": "hux.mode_selection.v1",
"owner": request.identity.subject,
"project_id": body["project_id"],
"conversation_id": conversation["id"],
"mode": mode,
"updated_at": stamp,
}
stored = request.store.put(FAMILY, record, expected_revision=expected)
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "id": record_id, "at": stamp})
with request.store.lock("conversations"):
current = request.store.get("conversations", conversation["id"])
request.store.put("conversations", {**current, "mode": mode["mode"], "updated_at": stamp}, current["revision"])
request.audit("modes.select", record_id)
return Response(200, stored, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
def register(router: Router) -> None:
"""Attach HUX-06 routes."""
router.add("GET", "/hux/v1/modes", CARD, "modes.list", list_modes)
router.add("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.read", get_selection)
router.add("PUT", "/hux/v1/projects/{project_id}/conversations/{id}/mode", CARD, "modes.select", put_selection)

View File

@ -1,276 +0,0 @@
"""HUX-07 scoped multimodal metadata, lineage and transcript corrections.
This service never accepts media bytes and has no capture endpoint. It only
records metadata after an approved autonomy action, plus inert camera/screen
intents that a human-facing client may send through the HUX-05 approval lane.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from hux import contracts, redaction
from hux.errors import Conflict, Forbidden, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.store import check_id, new_id, now_iso
CARD = "HUX-07"
ITEMS = "multimodal_items"
CORRECTIONS = "transcript_corrections"
INTENTS = "capture_intents"
MAX_ITEMS = 2000
MAX_CORRECTIONS = 10000
MAX_INTENTS = 1000
EXECUTABLE_MIMES = frozenset({"text/html", "application/xhtml+xml", "image/svg+xml", "application/xml", "text/xml"})
SAFE_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".wav", ".webm", ".ogg", ".mp4", ".pdf", ".txt")
KIND_MIMES = {
"image": frozenset({"image/jpeg", "image/png", "image/webp"}),
"audio": frozenset({"audio/wav", "audio/webm", "audio/ogg"}),
"video": frozenset({"video/webm", "video/mp4"}),
"document": frozenset({"application/pdf", "text/plain"}),
}
ITEM_FIELDS = frozenset({"project_id", "kind", "source", "filename", "mime", "bytes", "hash", "approval_id", "lineage"})
SCHEMAS = contracts.load_all()
SCHEMAS["multimodal.schema.json"] = contracts.load_schema("multimodal.schema.json")
def _body(request: Request, allowed: frozenset[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected multimodal fields", sorted(extra))
return request.body
def _scope(request: Request, project_id: Any) -> dict[str, Any]:
from hux import organization
try:
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
path_project = request.params.get("project_id")
actual = conversation.get("project_id")
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
raise NotFound("project or conversation not found")
if not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return conversation
def _if_match(request: Request, current: int) -> None:
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
if expected != current:
raise Conflict("revision does not match If-Match")
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _digest(body: dict[str, Any]) -> str:
return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, family: str, key: str, digest: str) -> dict[str, Any] | None:
for row in request.store.read(family, "idempotency"):
if row.get("key") != key:
continue
if row.get("digest") != digest:
raise Conflict("Idempotency-Key was already used with different metadata")
return request.store.get(family, row["id"])
return None
def _remember(request: Request, family: str, key: str, digest: str, record_id: str) -> None:
request.store.append(family, "idempotency", {"key": key, "digest": digest, "id": record_id, "at": now_iso()})
def _validate(record: dict[str, Any], pointer: str) -> None:
problems = contracts.validate("multimodal.schema.json", record, SCHEMAS, pointer)
if problems:
raise Invalid("multimodal record failed contract validation", problems)
def _approval(request: Request, approval_id: Any, source: Any) -> str:
from hux import policy
try:
approval = policy.load_approval(request.store, check_id(approval_id))
except (Invalid, NotFound) as error:
raise Forbidden("approved autonomy action is required") from error
capability = "external_side_effect" if source in {"camera", "screen"} else "artifact_write"
if approval.get("status") != "approved" or approval.get("conversation_id") != request.params["id"]:
raise Forbidden("approved autonomy action is required")
if approval.get("capability") != capability:
raise Forbidden("approval does not cover this multimodal action")
return approval["id"]
def _lineage(request: Request, body: Any, project_id: str) -> dict[str, Any] | None:
if body is None:
return None
if not isinstance(body, dict) or set(body) - {"parent_item_id", "artifact_id", "artifact_version"}:
raise Invalid("lineage must contain only a parent item or artifact version")
if "parent_item_id" in body:
if len(body) != 1:
raise Invalid("parent-item lineage cannot also name an artifact")
parent = request.store.get(ITEMS, check_id(body["parent_item_id"]))
if parent["conversation_id"] != request.params["id"] or parent["project_id"] != project_id:
raise NotFound("lineage item not found")
return {"parent_item_id": parent["id"]}
if set(body) != {"artifact_id", "artifact_version"} or not isinstance(body["artifact_version"], int):
raise Invalid("artifact lineage needs artifact_id and artifact_version")
from hux import artifacts
artifact = request.store.get(artifacts.FAMILY, check_id(body["artifact_id"]))
if artifact.get("project_id") != project_id or artifact.get("conversation_id") != request.params["id"]:
raise NotFound("lineage artifact not found")
version = next((row for row in artifact["versions"] if row["version"] == body["artifact_version"]), None)
if version is None:
raise NotFound("lineage artifact version not found")
if version["content_ref"]["mime"].lower() in EXECUTABLE_MIMES or artifact.get("type") in {"html", "svg"}:
raise Invalid("executable HTML and SVG lineage is not accepted")
return {"artifact_id": artifact["id"], "artifact_version": version["version"]}
def create_item(request: Request) -> Response:
"""Register metadata for approved media; bytes are never accepted here."""
body = _body(request, ITEM_FIELDS)
conversation = _scope(request, body.get("project_id"))
key, digest = _key(request), _digest(body)
with request.store.lock(ITEMS):
replayed = _replay(request, ITEMS, key, digest)
if replayed is not None:
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
_if_match(request, 0)
if request.store.count(ITEMS) >= MAX_ITEMS:
raise TooLarge("multimodal item limit reached")
filename, mime = body.get("filename"), body.get("mime")
if not isinstance(filename, str) or not filename.lower().endswith(SAFE_SUFFIXES):
raise Invalid("filename is missing or executable")
if not isinstance(mime, str) or mime.lower() in EXECUTABLE_MIMES:
raise Invalid("executable HTML, XML and SVG are not accepted")
if mime.lower() not in KIND_MIMES.get(body.get("kind"), frozenset()):
raise Invalid("kind and MIME type do not agree")
approval_id = _approval(request, body.get("approval_id"), body.get("source"))
lineage = _lineage(request, body.get("lineage"), body["project_id"])
record = {
"schema": "hux.multimodal_item.v1", "id": new_id("mmi"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": conversation["id"], "kind": body.get("kind"),
"source": body.get("source"), "filename": filename, "mime": mime.lower(), "bytes": body.get("bytes"),
"hash": body.get("hash"), "approval_id": approval_id, "status": "metadata_only", "created_at": now_iso(),
}
if lineage:
record["lineage"] = lineage
_validate({**record, "revision": 1}, "/$defs/item")
stored = request.store.put(ITEMS, record, expected_revision=0)
_remember(request, ITEMS, key, digest, stored["id"])
request.audit("multimodal.create", stored["id"])
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
def list_items(request: Request) -> Response:
"""List metadata in one project/conversation scope."""
project_id = request.params["project_id"]
_scope(request, project_id)
items = [row for row in request.store.scan(ITEMS) if row["project_id"] == project_id and row["conversation_id"] == request.params["id"]]
request.audit("multimodal.list", request.params["id"])
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def get_item(request: Request) -> Response:
"""Read one metadata record only within its path scope."""
project_id = request.params["project_id"]
_scope(request, project_id)
try:
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
except (Invalid, NotFound) as error:
raise NotFound("multimodal item not found") from error
if item["project_id"] != project_id or item["conversation_id"] != request.params["id"]:
raise NotFound("multimodal item not found")
request.audit("multimodal.read", item["id"])
return Response(200, item, {"ETag": str(item["revision"]), "Cache-Control": "no-store"})
def correct_transcript(request: Request) -> Response:
"""Append an immutable transcript correction and point the media head to it."""
body = _body(request, frozenset({"project_id", "replacement_text"}))
_scope(request, body.get("project_id"))
key, digest = _key(request), _digest({**body, "item_id": request.params["item_id"]})
with request.store.lock(CORRECTIONS):
replayed = _replay(request, CORRECTIONS, key, digest)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
if item["project_id"] != body["project_id"] or item["conversation_id"] != request.params["id"]:
raise NotFound("multimodal item not found")
if item["kind"] not in {"audio", "video"}:
raise Invalid("only audio or video transcripts can be corrected")
_if_match(request, item["revision"])
text = body.get("replacement_text")
if not isinstance(text, str) or not text.strip() or len(text) > 10000:
raise Invalid("replacement_text must contain 1 to 10000 characters")
if request.store.count(CORRECTIONS) >= MAX_CORRECTIONS:
raise TooLarge("transcript correction limit reached")
record = {
"schema": "hux.transcript_correction.v1", "id": new_id("trc"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": request.params["id"], "item_id": item["id"],
"replacement_text": text, "created_at": now_iso(),
}
_validate({**record, "revision": 1}, "/$defs/correction")
stored = request.store.put(CORRECTIONS, record, expected_revision=0)
with request.store.lock(ITEMS):
updated = request.store.put(ITEMS, {**item, "latest_correction_id": stored["id"]}, item["revision"])
_remember(request, CORRECTIONS, key, digest, stored["id"])
request.audit("multimodal.correct", stored["id"])
return Response(201, stored, {"ETag": str(updated["revision"]), "Cache-Control": "no-store"})
def create_capture_intent(request: Request) -> Response:
"""Record an inert camera/screen proposal; it never grants execution."""
body = _body(request, frozenset({"project_id", "source", "purpose"}))
_scope(request, body.get("project_id"))
key, digest = _key(request), _digest(body)
with request.store.lock(INTENTS):
replayed = _replay(request, INTENTS, key, digest)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
_if_match(request, 0)
if request.store.count(INTENTS) >= MAX_INTENTS:
raise TooLarge("capture intent limit reached")
purpose = body.get("purpose")
if body.get("source") not in {"camera", "screen"} or not isinstance(purpose, str) or not purpose.strip():
raise Invalid("source and purpose are required")
purpose = redaction.scrub_text(purpose[:280])[0]
record = {
"schema": "hux.capture_intent.v1", "id": new_id("cap"), "owner": request.identity.subject,
"project_id": body["project_id"], "conversation_id": request.params["id"], "source": body["source"],
"purpose": purpose, "status": "proposed", "requires_approval": True, "execution_allowed": False,
"created_at": now_iso(),
}
_validate({**record, "revision": 1}, "/$defs/capture_intent")
stored = request.store.put(INTENTS, record, expected_revision=0)
_remember(request, INTENTS, key, digest, stored["id"])
request.audit("multimodal.intent", stored["id"])
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
def register(router: Router) -> None:
"""Attach metadata-only HUX-07 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}"
router.add("POST", base + "/multimodal/items", CARD, "multimodal.create", create_item)
router.add("GET", base + "/multimodal/items", CARD, "multimodal.list", list_items)
router.add("GET", base + "/multimodal/items/{item_id}", CARD, "multimodal.read", get_item)
router.add("POST", base + "/multimodal/items/{item_id}/transcript-corrections", CARD, "multimodal.correct", correct_transcript)
router.add("POST", base + "/capture-intents", CARD, "multimodal.intent", create_capture_intent)

View File

@ -26,10 +26,6 @@ PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
INDEXED = ("title", "tags", "project_name", "artifact_titles")
NOT_INDEXED = ("message_text",)
MESSAGE_KINDS = ("message.user", "message.assistant")
MESSAGE_SCAN_CONVERSATIONS = 100
MESSAGE_SCAN_EVENTS = 300
SEARCH_PAGE = 50
SCHEMAS = contracts.load_all()
TOKEN_RE = re.compile(r"[a-z0-9]+")
@ -291,82 +287,25 @@ def score(record: dict[str, Any], terms: list[str], project_name: str, titles: l
return total
def message_text_score(store: TenantStore, conversation: dict[str, Any], terms: list[str]) -> int:
"""Bounded match over this conversation's stored message events.
Privacy wins: forgotten conversations were already excluded, private-mode
conversations are never scanned, and restricted or fully redacted events
stay invisible to search exactly as they are on the timeline.
"""
from hux import events
if events.is_private(store, conversation["id"]):
return 0
rows = store.read(events.FAMILY, conversation["id"])[-MESSAGE_SCAN_EVENTS:]
total = 0
for row in rows:
if row.get("kind") not in MESSAGE_KINDS or row.get("sensitivity") == "restricted":
continue
if (row.get("redaction") or {}).get("level") == "full":
continue
words = tokens(str(row.get("summary", "")))
total += sum(words.count(term) for term in terms)
return total
def search(request: Request) -> Response:
"""``GET /hux/v1/search?q=&project_id=&include=&cursor=``: best match first.
The default scope is the indexed fields only. ``include=message_text``
additionally scans the stored message events of the most recently active
``MESSAGE_SCAN_CONVERSATIONS`` candidates (bounded, privacy-enforced) and
pages deterministically: rank order is (score desc, updated_at desc, id),
the cursor is the offset into that total order.
"""
terms = tokens(request.query.get("q", "")[:200])
"""``GET /hux/v1/search?q=&project_id=``: token match over the indexed fields, best first."""
terms = tokens(request.query.get("q", ""))
if not terms:
raise Invalid("q is required")
include = request.query.get("include")
if include not in (None, "message_text"):
raise Invalid("include supports only message_text")
cursor = request.query.get("cursor", "0")
if not cursor.isdigit():
raise Invalid("cursor must be a non-negative integer")
project_filter = request.query.get("project_id")
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
index = artifact_index(request.store)
candidates = [
record for record in request.store.scan(CONVERSATIONS)
if not project_filter or record.get("project_id") == project_filter
]
scanned = set()
if include:
recent = sorted(candidates, key=lambda r: (r["updated_at"], r["id"]), reverse=True)
scanned = {record["id"] for record in recent[:MESSAGE_SCAN_CONVERSATIONS]}
ranked = []
for record in candidates:
for record in request.store.scan(CONVERSATIONS):
if project_filter and record.get("project_id") != project_filter:
continue
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
if record["id"] in scanned and not record.get("archived"):
points += message_text_score(request.store, record, terms)
if points:
ranked.append((points, record))
if include:
# Paginated mode needs one total order; ids break updated_at ties.
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"], pair[1]["id"]))
else:
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
request.audit("search.query", "conversations")
if not include:
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
start = int(cursor)
window = ranked[start : start + SEARCH_PAGE]
return Response(200, {
"items": [r for _, r in window], "scores": {r["id"]: s for s, r in window},
"next": str(start + SEARCH_PAGE) if len(ranked) > start + SEARCH_PAGE else None,
"indexed": list(INDEXED) + ["message_text"], "not_indexed": [],
"message_scan_limit": MESSAGE_SCAN_CONVERSATIONS,
})
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
def register(router: Router) -> None:

View File

@ -11,7 +11,6 @@ this module registers their routes so the family stays one card.
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import Any
@ -73,12 +72,6 @@ def require_human(identity: Identity, what: str) -> None:
raise Forbidden(f"{what} only from a human surface")
def require_worker(identity: Identity, what: str) -> None:
"""Forbidden unless the separately keyed Worker gateway is making the call."""
if identity.trust != "worker" or identity.surface != "worker":
raise Forbidden(f"{what} only from worker trust")
def actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if is_human(identity):
@ -144,7 +137,7 @@ def default_policy(identity: Identity) -> dict[str, Any]:
return {
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
"provenance": provenance(identity), "updated_at": iso(now()), "_budget_epoch": new_id("bep"),
"provenance": provenance(identity), "updated_at": iso(now()),
}
@ -217,7 +210,6 @@ def put_policy(request: Request) -> Response:
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
"_budget_epoch": new_id("bep"),
}
expected = request.if_match()
with request.store.lock(FAMILY):
@ -240,13 +232,9 @@ def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
record.pop("revision")
expected = record.pop("revision", None)
budget_epoch = record.get("_budget_epoch") or f"{record['id']}:{expected or 1}"
grants = [g for g in record["grants"] if g["capability"] != capability]
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
record = {
**record, "grants": grants[-64:], "provenance": provenance(identity),
"updated_at": iso(now()), "_budget_epoch": budget_epoch,
}
record = {**record, "grants": grants[-64:], "provenance": provenance(identity), "updated_at": iso(now())}
store.put(FAMILY, checked(record), expected_revision=expected)
@ -265,20 +253,11 @@ def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
def request_fingerprint(body: dict[str, Any]) -> str:
"""Canonical digest that binds an Idempotency-Key to exactly one approval request."""
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
return "sha256:" + hashlib.sha256(canonical).hexdigest()
def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, rejecting a changed request body."""
def replay(store: TenantStore, key: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, if any."""
for row in store.read(APPROVALS, "idempotency"):
if row["key"] == key:
record = load_approval(store, row["id"])
if record.get("_request_fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different approval request")
return record
return load_approval(store, row["id"])
return None
@ -290,62 +269,34 @@ def resolve_request(policy: dict[str, Any], capability: str, external: bool) ->
return decision
PRIVATE_DENIED = frozenset({"network", "web_search", "send_message", "shell", "delegate"})
def private_mode_denies(store: TenantStore, conversation_id: str | None, capability: str) -> bool:
"""Private conversations never release web, shell, messaging or delegation.
The mode catalog says so (``rules.MODE_CATALOG['private']['tools']``);
memory writes are already refused by the privacy state. The conversation
record's ``mode`` field is written only by the HUX-06 selection route.
"""
if capability not in PRIVATE_DENIED or not conversation_id:
return False
try:
record = store.get("conversations", conversation_id)
except Exception:
return False
return record.get("mode") == "private"
def create_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
require_worker(request.identity, "approval requests")
body = body_dict(request)
key = request.idempotency_key()
if key:
existing = replay(request.store, key)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
conversation_id = check_id(body.get("conversation_id"))
capability = body.get("capability")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
from hux import budgets # lazy: budgets imports this module
run_id = budgets.checked_run_id(body.get("run_id"))
bound_conversation = budgets.run_conversation(request.store, run_id)
if bound_conversation != conversation_id:
raise Invalid("run is not authoritatively bound to this conversation")
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") != 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
fingerprint = request_fingerprint(body)
if key:
with request.store.lock(APPROVALS):
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
state = budgets.budget_state(request.store, request.identity, run_id, conversation_id)
state = budgets.budget_state(request.store, request.identity, str(body.get("run_id", "")), conversation_id)
if state["exhausted"]:
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
raise BudgetExhausted("run budget exhausted", state["exhausted"])
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
decision = resolve_request(policy, capability, external)
if private_mode_denies(request.store, conversation_id, capability):
decision = "deny"
stamp = now()
record: dict[str, Any] = {
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id,
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": body.get("run_id"), "conversation_id": conversation_id,
"capability": capability, "request": {**req, "external": external},
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
@ -354,13 +305,7 @@ def create_approval(request: Request) -> Response:
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
if key:
record["idempotency_key"] = key
record["_request_fingerprint"] = fingerprint
with request.store.lock(APPROVALS):
if key:
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
stored = request.store.put(APPROVALS, checked(record))
if key:
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
@ -426,3 +371,4 @@ def register(router: Router) -> None:
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
budgets.register_routes(router)

View File

@ -267,11 +267,7 @@ PAGE_LIMIT = 200
def get_conversation_privacy(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
from hux import events
conversation_id = check_id(request.params["id"])
if not events.conversation_known(request.store, conversation_id):
raise NotFound("conversation not found")
state = conversation_state(request.store, conversation_id)
mode = None
try:

View File

@ -81,13 +81,7 @@ DETAIL_ALLOWLIST: dict[str, frozenset[str]] = {
def canaries(environ: dict[str, str] | None = None) -> list[str]:
"""Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears."""
environ = os.environ if environ is None else environ
names = ("HUX_ROUTER_KEY", "HUX_RELAY_KEY", "HUX_WORKER_KEY")
values = [environ.get(name, "") for name in names]
for name in names:
key_file = environ.get(f"{name}_FILE", "")
if key_file and os.path.exists(key_file):
with open(key_file, encoding="utf-8", errors="replace") as handle:
values.append(handle.read(4097).strip())
values = [environ.get(name, "") for name in ("HUX_RELAY_KEY", "HUX_WORKER_KEY")]
path = environ.get("HUX_CANARY_FILE", "")
if path and os.path.exists(path):
with open(path, encoding="utf-8", errors="replace") as handle:

View File

@ -1,134 +0,0 @@
"""Fail-closed authentication and policy checks for HUX-12 evidence.
The evidence producer is a loopback-only companion process. It receives a
dedicated Vault key and a non-secret policy file; neither the browser relay nor
the agent worker can use its trust class. This module deliberately performs no
network discovery: the producer supplies observations and this boundary binds
them to the configured Jenkins, Harbor, Flux, workload, pod and health facts.
"""
from __future__ import annotations
import json
import os
import re
import stat
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from hux.errors import Forbidden, Invalid
KEY_FILE_ENV = "HUX_RELEASE_EVIDENCE_KEY_FILE"
POLICY_FILE_ENV = "HUX_RELEASE_EVIDENCE_POLICY_FILE"
MAX_KEY_BYTES = 4096
MAX_POLICY_BYTES = 64 * 1024
POLICY_FIELDS = {"schema", "max_evidence_age_seconds", "workloads"}
WORKLOAD_FIELDS = {
"review_url_prefix", "jenkins_job_url", "image_repository",
"flux_kustomization", "health_url",
}
def _regular_file(path: str, modes: set[int], limit: int) -> bytes:
"""Read one bounded regular file without accepting a symlink or loose mode."""
if not path:
raise Invalid("HUX-12 evidence configuration is missing")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
descriptor = -1
try:
descriptor = os.open(Path(path), flags)
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) not in modes:
raise Invalid("HUX-12 evidence file permissions are invalid")
data = os.read(descriptor, limit + 1)
except OSError as error:
raise Invalid("HUX-12 evidence configuration is unavailable") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if not data or len(data) > limit:
raise Invalid("HUX-12 evidence file is empty or too large")
return data
def evidence_key(environ: Mapping[str, str]) -> str:
"""Return the file-only producer key; inline environment secrets are ignored."""
data = _regular_file(environ.get(KEY_FILE_ENV, ""), {0o400}, MAX_KEY_BYTES)
try:
key = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise Invalid("HUX-12 evidence key is invalid") from error
if len(key) < 32:
raise Invalid("HUX-12 evidence key is too short")
return key
def _https_url(value: Any, field: str, *, prefix: bool = False) -> str:
if not isinstance(value, str) or not value or len(value) > 500:
raise Invalid(f"{field} is missing or too long")
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise Invalid(f"{field} must be a credential-free HTTPS URL")
if prefix and not value.endswith("/"):
raise Invalid(f"{field} must end with /")
return value
def load_policy(environ: Mapping[str, str]) -> dict[str, Any]:
"""Load and strictly validate the non-secret producer policy."""
raw = _regular_file(environ.get(POLICY_FILE_ENV, ""), {0o400, 0o440, 0o444}, MAX_POLICY_BYTES)
try:
policy = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise Invalid("HUX-12 evidence policy is not valid JSON") from error
if not isinstance(policy, dict) or set(policy) != POLICY_FIELDS or policy.get("schema") != "hux.release_evidence_policy.v1":
raise Invalid("HUX-12 evidence policy shape is invalid")
age = policy.get("max_evidence_age_seconds")
workloads = policy.get("workloads")
if not isinstance(age, int) or isinstance(age, bool) or not 60 <= age <= 86400:
raise Invalid("HUX-12 evidence age bound is invalid")
if not isinstance(workloads, dict) or not workloads or len(workloads) > 16:
raise Invalid("HUX-12 workload policy is invalid")
for name, item in workloads.items():
if not isinstance(name, str) or not isinstance(item, dict) or set(item) != WORKLOAD_FIELDS:
raise Invalid("HUX-12 workload policy shape is invalid")
_https_url(item["review_url_prefix"], "review_url_prefix", prefix=True)
_https_url(item["jenkins_job_url"], "jenkins_job_url")
_https_url(item["health_url"], "health_url")
if not re.fullmatch(r"[a-z0-9./_-]+", str(item["image_repository"])):
raise Invalid("HUX-12 image repository is invalid")
if not re.fullmatch(r"[a-z0-9-]{1,63}", str(item["flux_kustomization"])):
raise Invalid("HUX-12 Flux kustomization is invalid")
return policy
def configured(environ: Mapping[str, str]) -> bool:
"""Whether producer authentication and policy are both healthy."""
try:
evidence_key(environ)
load_policy(environ)
except Invalid:
return False
return True
def workload_policy(environ: Mapping[str, str], workload: str) -> dict[str, Any]:
"""Return the exact allowlist for a workload or fail closed."""
item = load_policy(environ)["workloads"].get(workload)
if item is None:
raise Invalid("workload is not enabled for release evidence")
return item
def require_review_creator(trust: str) -> None:
"""Only the authenticated browser path may record the human review decision."""
if trust != "router":
raise Forbidden("reviewed releases require router trust")
def require_evidence_producer(trust: str) -> None:
"""Only the dedicated producer may advance a reviewed release."""
if trust != "evidence":
raise Forbidden("release transitions require evidence trust")

View File

@ -1,337 +0,0 @@
"""HUX-12 immutable, hash-chained deployment follow-through.
Each release is bound to one project and conversation. The current view is
reconstructed from append-only entries; every read verifies sequence, hash
chain and contract before returning anything. ``live_verified`` is reachable
only from a digest-converged pod with a passing health check.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
from urllib.parse import urlsplit
from hux import contracts, redaction, release_security
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.store import check_id, new_id, now_iso
CARD = "HUX-12"
FAMILY = "release_ledger"
MAX_RELEASES = 500
ZERO_HASH = "sha256:" + "0" * 64
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
WORKLOADS = frozenset({"hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"})
EVIDENCE_FIELDS = frozenset({"review_url", "merge_commit", "ci_build_url", "image_ref", "image_digest", "harbor_digest", "flux_revision", "pod_digest", "health_check", "rollback_target"})
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
COMMIT = re.compile(r"^[0-9a-f]{40}$")
IMAGE = re.compile(r"^[a-z0-9./-]+:[A-Za-z0-9._-]+@(sha256:[0-9a-f]{64})$")
SCHEMAS = contracts.load_all()
SCHEMAS["release-ledger.schema.json"] = contracts.load_schema("release-ledger.schema.json")
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected release fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, str]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation_id
def _actor(request: Request) -> dict[str, str]:
if request.identity.trust == "evidence":
return {"type": "system", "id": "release-evidence-producer"}
if request.identity.trust == "worker":
return {"type": "system", "id": "release-worker"}
return {"type": "user", "id": request.identity.subject}
def _safe_url(value: Any, *, relative: bool = False) -> str:
if not isinstance(value, str) or not value or len(value) > 500:
raise Invalid("evidence URL is missing or too long")
parsed = urlsplit(value)
if parsed.query or parsed.fragment or parsed.username or parsed.password:
raise Invalid("evidence URLs may not contain credentials, queries or fragments")
if relative:
if not ((not parsed.scheme and value.startswith("/")) or parsed.scheme == "https"):
raise Invalid("health URL must be HTTPS or a local absolute path")
elif parsed.scheme != "https" or not parsed.netloc:
raise Invalid("evidence URL must use HTTPS")
if redaction.scrub_text(value)[1]:
raise Invalid("evidence URL appears to contain a secret")
return value
def _canonical(data: Any) -> bytes:
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
def _entry_hash(entry: dict[str, Any]) -> str:
unsigned = {key: value for key, value in entry.items() if key != "entry_hash"}
return "sha256:" + hashlib.sha256(_canonical(unsigned)).hexdigest()
def _validate_release(release: dict[str, Any]) -> None:
problems = contracts.validate("release.schema.json", release, SCHEMAS)
if problems:
raise Invalid("release failed contract validation", problems)
def _validate_entry(entry: dict[str, Any]) -> None:
problems = contracts.validate("release-ledger.schema.json", entry, SCHEMAS)
if problems:
raise Invalid("release ledger entry failed contract validation", problems)
def _rows(request: Request, release_id: str) -> list[dict[str, Any]]:
try:
check_id(release_id)
except Invalid as error:
raise NotFound("release not found") from error
rows = request.store.read(FAMILY, release_id)
if not rows:
raise NotFound("release not found")
previous = ZERO_HASH
for sequence, row in enumerate(rows, 1):
if row.get("sequence") != sequence or row.get("previous_hash") != previous or row.get("entry_hash") != _entry_hash(row):
raise Invalid("release ledger integrity check failed")
_validate_entry(row)
previous = row["entry_hash"]
return rows
def _append(request: Request, release: dict[str, Any], project_id: str, conversation_id: str, previous: list[dict[str, Any]]) -> dict[str, Any]:
entry = {
"schema": "hux.release_ledger_entry.v1", "release_id": release["id"], "owner": request.identity.subject,
"project_id": project_id, "conversation_id": conversation_id, "sequence": len(previous) + 1,
"at": now_iso(), "previous_hash": previous[-1]["entry_hash"] if previous else ZERO_HASH, "snapshot": release,
}
entry["entry_hash"] = _entry_hash(entry)
_validate_entry(entry)
request.store.append(FAMILY, release["id"], entry)
return entry
def _view(entry: dict[str, Any]) -> dict[str, Any]:
return {
"release": entry["snapshot"],
"scope": {"project_id": entry["project_id"], "conversation_id": entry["conversation_id"]},
"revision": entry["sequence"],
"ledger_hash": entry["entry_hash"],
}
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(_canonical({"body": body, "path": path})).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for different release evidence")
rows = _rows(request, row["release_id"])
if row["sequence"] > len(rows):
raise Invalid("release idempotency ledger is inconsistent")
return _view(rows[row["sequence"] - 1])
return None
def _remember(request: Request, key: str, fingerprint: str, release_id: str, sequence: int) -> None:
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "release_id": release_id, "sequence": sequence, "at": now_iso()})
def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]:
evidence = body.get("evidence")
if not isinstance(evidence, dict) or set(evidence) != {"review_url"}:
raise Invalid("reviewed release requires exactly evidence.review_url")
return {"review_url": _safe_url(evidence["review_url"])}
def create_release(request: Request) -> Response:
"""Create the sole reviewed ledger for one scoped workload commit."""
release_security.require_review_creator(request.identity.trust)
body = _body(request, {"workload", "commit", "feature_flags", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
if request.if_match() != 0:
raise Invalid("If-Match: 0 is required when creating a release")
workload, commit = body.get("workload"), body.get("commit")
if workload not in WORKLOADS or not isinstance(commit, str) or not COMMIT.fullmatch(commit):
raise Invalid("workload and exact 40-character commit are required")
flags = body.get("feature_flags", [])
if not isinstance(flags, list) or len(flags) > 32:
raise Invalid("feature_flags must be a bounded list")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
releases = [name for name in request.store.ledgers(FAMILY) if name.startswith("rel_")]
if len(releases) >= MAX_RELEASES:
raise TooLarge("release ledger limit reached")
for name in releases:
entry = _rows(request, name)[0]
release = entry["snapshot"]
if entry["project_id"] == project_id and entry["conversation_id"] == conversation_id and release["workload"] == workload and release["commit"] == commit:
raise Conflict("a release already exists for this scoped workload commit")
release = {
"schema": "hux.release.v1", "id": new_id("rel"), "workload": workload, "feature_flags": flags,
"commit": commit, "state": "reviewed", "evidence": _initial_evidence(body), "transitions": [],
}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, [])
result = _view(entry)
_remember(request, key, fingerprint, release["id"], 1)
request.audit("releases.create", release["id"])
return Response(201, result, {"ETag": "1", "Cache-Control": "no-store"})
def _transition_evidence(current: dict[str, Any], target: str, supplied: Any) -> dict[str, Any]:
if not isinstance(supplied, dict) or set(supplied) - EVIDENCE_FIELDS:
raise Invalid("evidence must be a bounded object of known fields")
expected = {
"merged": {"merge_commit"},
"built": {"ci_build_url", "image_ref", "image_digest", "harbor_digest"},
"verified": set(),
"deployed": {"flux_revision"},
"converged": {"pod_digest"},
"live_verified": {"health_check"},
"rolled_back": {"rollback_target"},
}[target]
if set(supplied) != expected:
raise Invalid(f"{target} requires exactly: {', '.join(sorted(expected)) or 'no new evidence'}")
evidence = {**current.get("evidence", {}), **supplied}
if target == "merged" and not COMMIT.fullmatch(str(evidence.get("merge_commit", ""))):
raise Invalid("merged requires an exact merge commit")
if target == "built":
match = IMAGE.fullmatch(str(evidence.get("image_ref", "")))
digest = evidence.get("image_digest")
if not match or not SHA.fullmatch(str(digest)) or evidence.get("harbor_digest") != digest or match.group(1) != digest:
raise Invalid("image ref, image digest and Harbor digest must match exactly")
evidence["ci_build_url"] = _safe_url(evidence.get("ci_build_url"))
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
raise Invalid("verified image does not match Harbor")
if target == "deployed" and not re.fullmatch(r"main@sha1:[0-9a-f]{40}", str(evidence.get("flux_revision", ""))):
raise Invalid("deployed requires an exact Flux revision")
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
raise Invalid("pod digest does not equal the built image digest")
if target == "live_verified":
health = evidence.get("health_check")
if not isinstance(health, dict) or set(health) != {"url", "status", "at"} or health.get("status") != "pass":
raise Invalid("live verification requires one passing health check")
health["url"] = _safe_url(health["url"], relative=True)
if target == "rolled_back" and not SHA.fullmatch(str(evidence.get("rollback_target", ""))):
raise Invalid("rollback requires an exact target digest")
return evidence
def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str]]:
mapping = {
"merged": ("build", "merge_commit"), "built": ("build", "image_digest"),
"verified": ("build", "harbor_digest"), "deployed": ("flux", "flux_revision"),
"converged": ("pod", "pod_digest"), "live_verified": ("receipt", "health_check"),
"rolled_back": ("pod", "rollback_target"),
}
kind, key = mapping[target]
value = evidence[key]
identifier = value["at"] if key == "health_check" else value
return [{"kind": kind, "id": str(identifier)[:200]}]
def transition_release(request: Request) -> Response:
"""Append exactly one legal, evidence-backed release transition."""
release_security.require_evidence_producer(request.identity.trust)
body = _body(request, {"to", "evidence"})
project_id, conversation_id = _scope(request)
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
rows = _rows(request, request.params["release_id"])
head, current = rows[-1], rows[-1]["snapshot"]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
if expected != len(rows):
raise Conflict("release revision does not match If-Match")
target = body.get("to")
if target == "rolled_back":
if current["state"] not in {"built", "verified", "deployed", "converged", "live_verified"}:
raise Conflict("there is no deployed image to roll back")
elif current["state"] == "rolled_back" or target not in ORDER or ORDER.index(target) != ORDER.index(current["state"]) + 1:
raise Conflict("release transition skips, reverses, or follows a terminal state")
evidence = _transition_evidence(current, target, body.get("evidence"))
stamp = now_iso()
transition = {"from": current["state"], "to": target, "at": stamp, "by": _actor(request), "evidence": _evidence_refs(target, evidence)}
release = {**current, "state": target, "evidence": evidence, "transitions": [*current["transitions"], transition]}
_validate_release(release)
entry = _append(request, release, project_id, conversation_id, rows)
result = _view(entry)
_remember(request, key, fingerprint, release["id"], entry["sequence"])
request.audit("releases.transition", release["id"], reason=target)
return Response(200, result, {"ETag": str(entry["sequence"]), "Cache-Control": "no-store"})
def get_release(request: Request) -> Response:
"""Read one verified release view within its project/conversation scope."""
project_id, conversation_id = _scope(request)
rows = _rows(request, request.params["release_id"])
head = rows[-1]
if head["project_id"] != project_id or head["conversation_id"] != conversation_id:
raise NotFound("release not found")
request.audit("releases.read", request.params["release_id"])
return Response(200, _view(head), {"ETag": str(len(rows)), "Cache-Control": "no-store"})
def list_releases(request: Request) -> Response:
"""List verified heads for one scope, newest ledger first."""
project_id, conversation_id = _scope(request)
items = []
for name in request.store.ledgers(FAMILY):
if not name.startswith("rel_"):
continue
head = _rows(request, name)[-1]
if head["project_id"] == project_id and head["conversation_id"] == conversation_id:
items.append(_view(head))
items.sort(key=lambda row: row["release"]["transitions"][-1]["at"] if row["release"]["transitions"] else "", reverse=True)
request.audit("releases.list", conversation_id)
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-12 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/releases"
router.add("POST", base, CARD, "releases.create", create_release)
router.add("GET", base, CARD, "releases.list", list_releases)
router.add("GET", base + "/{release_id}", CARD, "releases.read", get_release)
router.add("POST", base + "/{release_id}/transitions", CARD, "releases.transition", transition_release)

View File

@ -1,138 +0,0 @@
"""Bounded in-process HUX-10 retention scheduling for one tenant pod."""
from __future__ import annotations
import hashlib
import threading
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from hux import privacy
from hux.errors import Unauthorized
from hux.identity import SLOT_RE, Identity, _read_subject_binding
from hux.store import TenantStore
DAY_SECONDS = 86_400.0
MAX_JITTER_SECONDS = 3_600
MAX_BACKOFF_SECONDS = 900.0
class BindingUnavailable(ValueError):
"""The projected subject binding is absent or unsafe."""
def subject_binding(environ: Mapping[str, str]) -> Identity:
"""Read the canonical edge-published binding and combine it with the configured tenant slot."""
slot = environ.get("HUX_TENANT_SLOT", "")
raw_path = environ.get("HUX_SUBJECT_BINDING_FILE", "")
if not SLOT_RE.fullmatch(slot) or not raw_path:
raise BindingUnavailable("binding configuration unavailable")
try:
subject = _read_subject_binding(Path(raw_path))
except Unauthorized as error:
raise BindingUnavailable("binding file unavailable") from error
if subject is None:
raise BindingUnavailable("binding file unavailable")
return Identity(slot, subject, "worker", "worker")
class RetentionScheduler:
"""Run privacy retention without leaving the API process or tenant lock domain."""
def __init__(
self,
router: Any,
*,
clock: Callable[[], datetime] | None = None,
runner: Callable[..., dict[str, Any]] | None = None,
waiter: Callable[[float], bool] | None = None,
daily_seconds: float = DAY_SECONDS,
) -> None:
self.router = router
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.runner = runner or privacy.run_retention
self.daily_seconds = max(1.0, daily_seconds)
self._stop = threading.Event()
self.waiter = waiter or self._stop.wait
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._health: dict[str, Any] = {
"enabled": bool(router.flags.enabled("HUX-10")),
"status": "starting" if router.flags.enabled("HUX-10") else "disabled",
"last_run": None,
"last_success": None,
"errors": 0,
}
def health(self) -> dict[str, Any]:
"""Return bounded, credential-free scheduler evidence."""
with self._lock:
return dict(self._health)
def _set(self, **changes: Any) -> None:
with self._lock:
self._health.update(changes)
def _jitter(self, identity: Identity) -> float:
try:
cap = int(self.router.environ.get("HUX_RETENTION_JITTER_SECONDS", "900"))
except ValueError:
cap = 900
cap = min(MAX_JITTER_SECONDS, max(0, cap))
if cap == 0:
return 0.0
digest = hashlib.sha256(f"{identity.tenant_slot}\0{identity.subject}".encode()).digest()
return float(int.from_bytes(digest[:4], "big") % (cap + 1))
@staticmethod
def _backoff(failures: int) -> float:
return min(MAX_BACKOFF_SECONDS, float(5 * (2 ** min(max(failures - 1, 0), 7))))
def run(self) -> None:
"""Wait for a binding, run stale startup work, then run once per day."""
if not self.health()["enabled"]:
return
failures = 0
first = True
while not self._stop.is_set():
try:
identity = subject_binding(self.router.environ)
tenant = TenantStore(self.router.data_root, identity)
now = self.clock()
ran = not first or privacy.audit_stale(tenant, now)
if ran:
self.runner(tenant, now, identity)
stamp = now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self._set(last_run=stamp, last_success=stamp)
failures = 0
first = False
self._set(status="healthy", errors=0)
if self.waiter(self.daily_seconds + self._jitter(identity)):
break
except BindingUnavailable:
failures += 1
self._set(status="waiting_for_binding", errors=failures)
if self.waiter(self._backoff(failures)):
break
except Exception: # noqa: BLE001 - background failure cannot take down the API
failures += 1
self._set(status="error", errors=failures)
if self.waiter(self._backoff(failures)):
break
self._set(status="stopped")
def start(self) -> None:
"""Start one daemon thread when HUX-10 is enabled."""
if not self.health()["enabled"] or self._thread is not None:
return
self._thread = threading.Thread(target=self.run, name="hux-retention", daemon=True)
self._thread.start()
def stop(self) -> None:
"""Wake and join the scheduler during server shutdown."""
self._stop.set()
if self._thread is not None and self._thread is not threading.current_thread():
self._thread.join(timeout=2.0)
self._set(status="stopped")

View File

@ -46,9 +46,9 @@ CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
)
_READ_ONLY = frozenset({"read_files", "spend_tokens"})
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_NETWORK = frozenset({"network", "web_search"})
_EXTERNAL = frozenset({"network", "deploy"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
@ -142,13 +142,7 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask. Deploy and external side effects always ask (SO-39).
Network access (``network``/``web_search``) asks below ``autonomous`` instead of
denying: for a chat assistant whose core value includes browsing, the right
default is a visible approval prompt, never a silent block. No default is
``deny``; denials only come from explicit grants or private mode.
"""
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
@ -157,9 +151,10 @@ def default_capability_matrix() -> dict[str, dict[str, str]]:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
else:
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
elif capability in _MUTATING:
row[capability] = "ask" if level != "autonomous" else "allow"
elif capability in _EXTERNAL:
row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level]
matrix[level] = row
return matrix

View File

@ -13,19 +13,18 @@ from pathlib import Path
from hux.http import Router, serve
FAMILIES = (
"foundation", "events", "memory", "privacy", "organization", "artifacts",
"modes", "research", "policy", "multimodal", "suggestions", "releases",
)
FAMILIES = ("foundation", "events", "memory", "privacy", "organization", "artifacts", "research", "policy")
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
"""Create a router with every required family registered and route-bound to its flag."""
"""Create a router with every family module that is present registered."""
router = Router(data_root, environ)
for name in FAMILIES:
module = importlib.import_module(f"hux.{name}")
try:
module = importlib.import_module(f"hux.{name}")
except ModuleNotFoundError:
continue
module.register(router)
router.bind_capability_routes()
return router
@ -42,10 +41,7 @@ def main() -> None:
env = dict(os.environ)
router = build_router(Path(env.get("HUX_DATA_ROOT", "/opt/data")), env)
server = serve(router, bind_address(env), int(env.get("HUX_PORT", "8790")))
try:
server.serve_forever()
finally:
server.server_close()
server.serve_forever()
if __name__ == "__main__": # pragma: no cover

View File

@ -1,237 +0,0 @@
"""HUX-09 restrained contextual suggestions with server-owned suppression.
Clients submit a bounded context signal; they cannot create suggestion text,
change cooldowns, or claim that a suggestion was clicked. Private and
sensitive conversations return an explicit no-store result before any
suggestion state or idempotency record is written.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import now_iso
CARD = "HUX-09"
FAMILY = "suggestion_states"
MAX_STATES = 1000
CONTEXTS = ("first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle")
CATALOG: dict[str, dict[str, Any]] = {
"first_session": {"id": "sug_first_session", "kind": "tip", "title": "Choose how Hermes works", "body": "Pick Fast, Thoughtful, Research, Create, or Private/local for this conversation.", "action": {"type": "open_mode"}, "priority": 80},
"empty_project": {"id": "sug_empty_project", "kind": "project", "title": "Give this project a starting point", "body": "Add a goal or move a related conversation here when it is useful.", "action": {"type": "create_project"}, "priority": 50},
"after_artifact": {"id": "sug_after_artifact", "kind": "feature", "title": "Keep working on this artifact", "body": "Open the artifact workspace to compare versions or continue editing.", "action": {"type": "open_artifacts"}, "priority": 70},
"after_research": {"id": "sug_after_research", "kind": "workflow", "title": "Keep the research together", "body": "Save the sources, assumptions, and open questions as a reusable workflow.", "action": {"type": "start_workflow"}, "priority": 65},
"after_approval": {"id": "sug_after_approval", "kind": "tip", "title": "Review autonomy controls", "body": "You can adjust what Hermes asks before doing for this conversation.", "action": {"type": "none"}, "priority": 40},
"idle": {"id": "sug_idle_workflow", "kind": "workflow", "title": "Make repeated work reusable", "body": "If this repeats, Hermes can turn it into a workflow without running it now.", "action": {"type": "start_workflow"}, "priority": 20},
}
SCHEMAS = contracts.load_all()
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
def _body(request: Request, allowed: set[str]) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
extra = set(request.body) - allowed
if extra:
raise Invalid("unexpected suggestion fields", sorted(extra))
return request.body
def _scope(request: Request) -> tuple[str, dict[str, Any]]:
from hux import organization
project_id, conversation_id = request.params["project_id"], request.params["id"]
try:
conversation = request.store.get(organization.CONVERSATIONS, conversation_id)
except (Invalid, NotFound) as error:
raise NotFound("project or conversation not found") from error
if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id):
raise NotFound("project or conversation not found")
return project_id, conversation
def _privacy_gate(request: Request, conversation: dict[str, Any], body: dict[str, Any]) -> str | None:
from hux import privacy
if conversation.get("mode") == "private":
return "private_mode"
state = privacy.conversation_state(request.store, conversation["id"])
if state.get("forgotten") or state.get("memory_disabled"):
return "conversation_no_store"
if state.get("topics"):
return "sensitive_topic"
if body.get("no_store") is True or body.get("sensitivity") in {"sensitive", "restricted"}:
return "client_no_store"
return None
def _suggestion(context: str, surface: str) -> dict[str, Any]:
base = CATALOG[context]
record = {
"schema": "hux.suggestion.v1", **base,
"trigger": {"surface": surface, "context": context},
"suppression": {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True},
}
problems = contracts.validate("suggestion.schema.json", record, SCHEMAS, "/$defs/suggestion")
if problems:
raise Invalid("suggestion failed contract validation", problems)
return record
def _state_id(suggestion_id: str, project_id: str, conversation_id: str) -> str:
value = f"{suggestion_id}:{project_id}:{conversation_id}".encode()
return f"sugs_{hashlib.sha256(value).hexdigest()[:28]}"
def _public(state: dict[str, Any]) -> dict[str, Any]:
result = {key: state[key] for key in ("schema", "owner", "suggestion_id", "shows", "never_again")}
for key in ("last_shown_at", "dismissed_at", "acted_at"):
if key in state:
result[key] = state[key]
problems = contracts.validate("suggestion.schema.json", result, SCHEMAS, "/$defs/state")
if problems:
raise Invalid("suggestion state failed contract validation", problems)
return result
def _eligible(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> str | None:
if state is None:
return None
if state.get("never_again"):
return "never_again"
rules = suggestion["suppression"]
if state.get("shows", 0) >= rules["max_shows"]:
return "max_shows"
last = state.get("last_shown_at")
if last and now - datetime.fromisoformat(last.replace("Z", "+00:00")) < timedelta(seconds=rules["cooldown_seconds"]):
return "cooldown"
return None
def _key(request: Request) -> str:
key = request.idempotency_key()
if not key:
raise Invalid("Idempotency-Key is required")
return key
def _fingerprint(body: dict[str, Any], path: str) -> str:
return hashlib.sha256(json.dumps({"body": body, "path": path}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None:
for row in request.store.read(FAMILY, "idempotency"):
if row.get("key") != key:
continue
if row.get("fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different suggestion action")
return row["result"]
return None
def evaluate(request: Request) -> Response:
"""Evaluate and atomically count one eligible suggestion display."""
body = _body(request, {"context", "no_store", "sensitivity"})
project_id, conversation = _scope(request)
context = body.get("context")
if context not in CONTEXTS:
raise Invalid("unknown suggestion context")
gated = _privacy_gate(request, conversation, body)
if gated:
request.audit("suggestions.evaluate", conversation["id"], reason=gated)
return Response(200, {"suggestion": None, "reason": gated, "stored": False}, {"Cache-Control": "no-store"})
key, fingerprint = _key(request), _fingerprint(body, request.path)
suggestion = _suggestion(context, request.identity.surface)
state_id = _state_id(suggestion["id"], project_id, conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
state = request.store.get(FAMILY, state_id) if request.store.exists(FAMILY, state_id) else None
reason = _eligible(suggestion, state, clock())
if reason:
result = {"suggestion": None, "reason": reason, "stored": False}
else:
if state is None and request.store.count(FAMILY) >= MAX_STATES:
result = {"suggestion": None, "reason": "state_limit", "stored": False}
else:
stamp = now_iso()
record = {
"id": state_id, "schema": "hux.suggestion_state.v1", "owner": request.identity.subject,
"suggestion_id": suggestion["id"], "shows": (state or {}).get("shows", 0) + 1,
"last_shown_at": stamp, "never_again": False,
}
stored = request.store.put(FAMILY, record, expected_revision=state["revision"] if state else 0)
result = {"suggestion": suggestion, "state": _public(stored), "revision": stored["revision"], "stored": True}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": now_iso()})
request.audit("suggestions.evaluate", suggestion["id"], reason=result.get("reason", "shown"))
headers = {"Cache-Control": "no-store"}
if result.get("revision"):
headers["ETag"] = str(result["revision"])
return Response(200, result, headers)
def decide(request: Request) -> Response:
"""Record only an explicit user click, guarded by If-Match and idempotency."""
body = _body(request, {"decision", "clicked"})
_, conversation = _scope(request)
gated = _privacy_gate(request, conversation, body)
if gated:
raise NotFound("suggestion state not found")
if body.get("clicked") is not True or body.get("decision") not in {"dismissed", "acted", "never_again"}:
raise Invalid("an explicit clicked decision is required")
key, fingerprint = _key(request), _fingerprint(body, request.path)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
state_id = _state_id(request.params["suggestion_id"], request.params["project_id"], conversation["id"])
with request.store.lock(FAMILY):
replayed = _replay(request, key, fingerprint)
if replayed is not None:
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
if not request.store.exists(FAMILY, state_id):
raise NotFound("suggestion state not found")
current = request.store.get(FAMILY, state_id)
if current["revision"] != expected:
raise Conflict("suggestion state revision does not match If-Match")
stamp, decision = now_iso(), body["decision"]
updated = {**current}
if decision in {"dismissed", "never_again"}:
updated["dismissed_at"] = stamp
if decision == "acted":
updated["acted_at"] = stamp
if decision == "never_again":
updated["never_again"] = True
stored = request.store.put(FAMILY, updated, expected_revision=expected)
result = {"state": _public(stored), "revision": stored["revision"], "decision": decision}
request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": stamp})
request.audit("suggestions.decide", request.params["suggestion_id"], reason=decision)
return Response(200, result, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"})
def list_states(request: Request) -> Response:
"""Inspect suppression state for exactly one project/conversation scope."""
project_id, conversation = _scope(request)
if _privacy_gate(request, conversation, {}):
return Response(200, {"items": [], "next": None}, {"Cache-Control": "no-store"})
prefix = lambda row: row["id"] == _state_id(row["suggestion_id"], project_id, conversation["id"])
items = [_public(row) | {"revision": row["revision"]} for row in request.store.scan(FAMILY) if prefix(row)]
request.audit("suggestions.list", conversation["id"])
response = page(items)
response.headers["Cache-Control"] = "no-store"
return response
def register(router: Router) -> None:
"""Attach HUX-09 routes."""
base = "/hux/v1/projects/{project_id}/conversations/{id}/suggestions"
router.add("POST", base + "/evaluate", CARD, "suggestions.evaluate", evaluate)
router.add("POST", base + "/{suggestion_id}/decisions", CARD, "suggestions.decide", decide)
router.add("GET", base + "/states", CARD, "suggestions.list", list_states)

View File

@ -1,478 +0,0 @@
"""HUX-12 release follow-through evidence producer (companion process).
This package lives OUTSIDE the network-free ``hux`` service package (SO-29):
it is the only component allowed to open outbound connections. It may import
``hux.errors`` and ``hux.release_security``; ``hux`` never imports it.
A pure observation -> verdict core (``evidence_for``) binds every observation
to the release being advanced and the workload policy, returning the exact
evidence payload for the next legal transition or raising ``Invalid``. A thin
``Collector`` gathers observations from Jenkins, Harbor, the Kubernetes API
and the health URL over HTTPS with an injectable transport. The driver
(``advance``/``run_once``) posts at most one verified transition per pass with
If-Match and a deterministic Idempotency-Key; verdicts derive only from passed
observations and the injected clock, and nothing here logs the evidence key.
"""
from __future__ import annotations
import json
import re
import ssl
import urllib.error
import urllib.request
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
from hux import release_security
from hux.errors import Invalid
ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
TERMINAL = frozenset({"live_verified", "rolled_back"})
ROLLBACK_STATES = frozenset({"built", "verified", "deployed", "converged", "live_verified"})
COMMIT = re.compile(r"^[0-9a-f]{40}$")
SHA = re.compile(r"^sha256:[0-9a-f]{64}$")
SHA_ANY = re.compile(r"sha256:[0-9a-f]{64}")
TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
FLUX_REV = re.compile(r"^main@sha1:[0-9a-f]{40}$")
NAMESPACE = re.compile(r"^[a-z0-9-]{1,63}$")
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
DEFAULT_KUBE_API = "https://kubernetes.default.svc"
DEFAULT_KUBE_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token" # noqa: S105
MAX_RESPONSE_BYTES = 1024 * 1024
Opener = Callable[[urllib.request.Request, float, ssl.SSLContext | None], Any]
def _check(condition: Any, message: str) -> None:
"""Fail closed with a precise reason unless ``condition`` holds."""
if not condition:
raise Invalid(message)
def parse_time(value: Any, label: str) -> datetime:
"""Parse one strict RFC 3339 UTC second-precision timestamp."""
_check(isinstance(value, str) and TIME.fullmatch(value), f"{label} must be an RFC 3339 UTC timestamp")
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
def _fresh(value: Any, now: datetime, max_age: int, label: str) -> str:
age = (now - parse_time(value, label)).total_seconds()
_check(0 <= age <= max_age, f"stale evidence: {label} is outside the freshness window")
return str(value)
def _observation(observations: Mapping[str, Any], name: str, workload: str, now: datetime, max_age: int) -> dict[str, Any]:
obs = observations.get(name)
_check(isinstance(obs, dict), f"incomplete observations: {name} is missing")
_check(obs.get("workload") == workload, f"{name} observation is bound to a different workload")
_fresh(obs.get("observed_at"), now, max_age, f"{name}.observed_at")
return obs
def _need(obs: Mapping[str, Any], name: str, field: str, kind: type = str) -> Any:
value = obs.get(field)
_check(not isinstance(value, bool) and isinstance(value, kind), f"incomplete observations: {name}.{field} is missing or malformed")
return value
def _verify_merged(release, observations, wpolicy, max_age, now):
git = _observation(observations, "git", release["workload"], now, max_age)
commit = _need(git, "git", "commit")
_check(COMMIT.fullmatch(commit), "git commit must be an exact 40-hex SHA")
_check(parse_time(_need(git, "git", "committed_at"), "git.committed_at") <= now, "git commit timestamp is in the future")
review_url = _need(git, "git", "review_url")
_check(review_url == release.get("evidence", {}).get("review_url"), "git observation is for a different review than this release")
_check(review_url.startswith(wpolicy["review_url_prefix"]), "review URL is outside the policy review prefix")
return {"merge_commit": commit}
def _verify_built(release, observations, wpolicy, max_age, now):
jenkins = _observation(observations, "jenkins", release["workload"], now, max_age)
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
sha = str(release.get("evidence", {}).get("merge_commit", ""))
build_url, job = _need(jenkins, "jenkins", "build_url"), wpolicy["jenkins_job_url"]
_check(build_url == job or build_url.startswith(job + "/"), "Jenkins build URL is outside the policy job")
_check(_need(jenkins, "jenkins", "result") == "SUCCESS", "Jenkins build result is not SUCCESS")
_check(COMMIT.fullmatch(sha) and _need(jenkins, "jenkins", "revision") == sha, "Jenkins build revision does not equal the merged commit")
number = _need(jenkins, "jenkins", "build_number", int)
_check(number > 0, "Jenkins build number is invalid")
tag = f"git-{sha}-build-{number}-release"
_check(_need(harbor, "harbor", "tag") == tag, "Harbor tag does not bind the merged commit and Jenkins build")
_check(_need(harbor, "harbor", "repository") == wpolicy["image_repository"], "Harbor repository does not match the policy image repository")
digest = _need(harbor, "harbor", "digest")
_check(SHA.fullmatch(digest), "Harbor digest is malformed")
_check(_need(jenkins, "jenkins", "image_digest") == digest, "Harbor digest does not equal the advertised image digest")
ref = f"{wpolicy['image_repository']}:{tag}@{digest}"
return {"ci_build_url": build_url, "image_ref": ref, "image_digest": digest, "harbor_digest": digest}
def _verify_verified(release, observations, wpolicy, max_age, now):
harbor = _observation(observations, "harbor", release["workload"], now, max_age)
evidence = release.get("evidence", {})
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
same = (_need(harbor, "harbor", "repository") == wpolicy["image_repository"]
and tag and _need(harbor, "harbor", "tag") == tag
and _need(harbor, "harbor", "digest") == evidence.get("image_digest"))
_check(same, "Harbor no longer serves the built tag at the recorded digest")
return {}
def _verify_deployed(release, observations, wpolicy, max_age, now):
flux = _observation(observations, "flux", release["workload"], now, max_age)
_check(_need(flux, "flux", "name") == wpolicy["flux_kustomization"], "Flux kustomization name does not match policy")
revision = _need(flux, "flux", "applied_revision")
_check(FLUX_REV.fullmatch(revision), "Flux applied revision must look like main@sha1:<40-hex>")
_check(flux.get("pin_in_revision") is True, "manifest pin is not proven to be contained in the applied revision")
return {"flux_revision": revision}
def _verify_converged(release, observations, wpolicy, max_age, now):
spec = _observation(observations, "workload_spec", release["workload"], now, max_age)
pods = _observation(observations, "pods", release["workload"], now, max_age)
evidence = release.get("evidence", {})
ref, digest = str(evidence.get("image_ref", "")), str(evidence.get("image_digest", ""))
_check(ref and _need(spec, "workload_spec", "image") == ref, "desired workload image does not equal the built release reference")
entries = _need(pods, "pods", "pods", list)
_check(entries, "no Ready pods were observed for the workload")
for pod in entries:
_check(isinstance(pod, dict) and pod.get("ready") is True, "a workload pod is not Ready")
image_id = pod.get("image_id")
_check(isinstance(image_id, str) and image_id.endswith("@" + digest), "a Ready pod is not running the release digest")
return {"pod_digest": digest}
def _verify_live(release, observations, wpolicy, max_age, now):
health = _observation(observations, "health", release["workload"], now, max_age)
_check(_need(health, "health", "url") == wpolicy["health_url"], "health receipt is not for the policy health URL")
status = _need(health, "health", "status", int)
_check(status == 200, f"health check returned HTTP {status}, not 200")
checked = _fresh(health.get("checked_at"), now, max_age, "health.checked_at")
return {"health_check": {"url": health["url"], "status": "pass", "at": checked}}
def _verify_rollback(release, observations, wpolicy, max_age, now):
rollback = _observation(observations, "rollback", release["workload"], now, max_age)
digest = _need(rollback, "rollback", "digest")
_check(SHA.fullmatch(digest), "rollback target digest is malformed")
return {"rollback_target": digest}
_VERIFIERS = {
"merged": _verify_merged, "built": _verify_built, "verified": _verify_verified, "deployed": _verify_deployed,
"converged": _verify_converged, "live_verified": _verify_live, "rolled_back": _verify_rollback,
}
def next_target(state: str) -> str | None:
"""The only legal forward transition from ``state``; None when terminal."""
if state in TERMINAL:
return None
_check(state in ORDER, "release is in an unknown state")
return ORDER[ORDER.index(state) + 1]
def evidence_for(release: Mapping[str, Any], target: str, observations: Mapping[str, Any], wpolicy: Mapping[str, Any], max_age: int, now: datetime) -> dict[str, Any]:
"""Verify observations for exactly one legal transition or raise Invalid."""
_check(now.tzinfo is not None, "now must be a timezone-aware UTC datetime")
state = str(release.get("state", ""))
if target == "rolled_back":
_check(state in ROLLBACK_STATES, "there is no deployed image to roll back")
else:
_check(target == next_target(state), f"transition to {target} would skip, repeat or downgrade from {state}")
return _VERIFIERS[target](dict(release), observations, wpolicy, int(max_age), now)
def _default_opener(request: urllib.request.Request, timeout: float, context: ssl.SSLContext | None):
return urllib.request.urlopen(request, timeout=timeout, context=context) # noqa: S310
def _context(ca_file: str) -> ssl.SSLContext:
"""A verifying TLS context, trusting the given CA bundle when one is mounted."""
try:
return ssl.create_default_context(cafile=ca_file or None)
except (OSError, ssl.SSLError) as error:
raise Invalid("collector CA bundle is unavailable") from error
def _bounded_timeout(environ: Mapping[str, str]) -> float:
try:
value = float(environ.get("HUX_PRODUCER_TIMEOUT_SECONDS", 10.0))
except (TypeError, ValueError):
return 10.0
return value if 0.1 <= value <= 60.0 else 10.0
class Collector:
"""Observation gatherer; every field is observed via the transport, never asserted."""
def __init__(self, environ: Mapping[str, str], workload: str, wpolicy: Mapping[str, Any], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> None:
self.environ = dict(environ)
self.workload = workload
self.wpolicy = dict(wpolicy)
self.opener = opener or _default_opener
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.timeout = _bounded_timeout(environ)
def _stamp(self, fields: dict[str, Any]) -> dict[str, Any]:
observed = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
return {"workload": self.workload, "observed_at": observed, **fields}
def _fetch(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> tuple[int, bytes]:
_check(urlsplit(url).scheme == "https", f"{label} observation URL must be HTTPS")
request = urllib.request.Request(url, headers=dict(headers or {}), method="GET") # noqa: S310
try:
with self.opener(request, self.timeout, context or _context("")) as response:
status, data = int(response.status), response.read(MAX_RESPONSE_BYTES + 1)
except urllib.error.HTTPError as error:
return int(error.code), b""
except (OSError, ValueError) as error:
raise Invalid(f"{label} observation fetch failed") from error
_check(len(data) <= MAX_RESPONSE_BYTES, f"{label} observation response is too large")
return status, data
def _json(self, url: str, label: str, headers: Mapping[str, str] | None = None, context: ssl.SSLContext | None = None) -> dict[str, Any]:
status, data = self._fetch(url, label, headers, context)
_check(status == 200, f"{label} observation returned HTTP {status}")
try:
parsed = json.loads(data)
except ValueError as error:
raise Invalid(f"{label} observation is not JSON") from error
_check(isinstance(parsed, dict), f"{label} observation is not a JSON object")
return parsed
def git(self, review_url: str) -> dict[str, Any]:
"""Observe the merged proposal named by the release's recorded review URL."""
prefix = self.wpolicy["review_url_prefix"]
number = review_url[len(prefix):] if review_url.startswith(prefix) else ""
_check(number.isdigit(), "release review URL is outside the policy review prefix")
parsed = urlsplit(prefix)
parts = parsed.path.strip("/").split("/")
_check(len(parts) == 3 and parts[2] == "pulls", "policy review prefix is not a proposal URL")
body = self._json(f"https://{parsed.netloc}/api/v1/repos/{parts[0]}/{parts[1]}/pulls/{number}", "git")
fields: dict[str, Any] = {"review_url": review_url}
commit, merged_at = body.get("merge_commit_sha"), body.get("merged_at")
if body.get("merged") is True and isinstance(commit, str):
fields["commit"] = commit.lower()
if isinstance(merged_at, str) and TIME.fullmatch(merged_at):
fields["committed_at"] = merged_at
return self._stamp(fields)
def jenkins(self) -> dict[str, Any]:
"""Observe the last successful build of the policy job."""
body = self._json(self.wpolicy["jenkins_job_url"] + "/lastSuccessfulBuild/api/json", "jenkins")
fields: dict[str, Any] = {}
url, result, number = body.get("url"), body.get("result"), body.get("number")
if isinstance(url, str):
fields["build_url"] = url.rstrip("/")
if isinstance(result, str):
fields["result"] = result
if isinstance(number, int) and not isinstance(number, bool):
fields["build_number"] = number
for action in body.get("actions") or []:
revision = action.get("lastBuiltRevision") if isinstance(action, dict) else None
sha = revision.get("SHA1") if isinstance(revision, dict) else None
if isinstance(sha, str) and COMMIT.fullmatch(sha.lower()):
fields["revision"] = sha.lower()
break
advertised = SHA_ANY.search(str(body.get("description") or ""))
if advertised:
fields["image_digest"] = advertised.group(0)
return self._stamp(fields)
def harbor(self, tag: str) -> dict[str, Any]:
"""Observe the immutable Harbor artifact behind one release tag."""
repository = self.wpolicy["image_repository"]
host, _, path = repository.partition("/")
project, _, name = path.partition("/")
_check(host and project and name, "policy image repository is not host/project/name")
body = self._json(f"https://{host}/api/v2.0/projects/{project}/repositories/{quote(name, safe='')}/artifacts/{quote(tag, safe='')}", "harbor")
fields: dict[str, Any] = {"repository": repository}
digest = body.get("digest")
if isinstance(digest, str) and SHA.fullmatch(digest):
fields["digest"] = digest
names = [entry.get("name") for entry in body.get("tags") or [] if isinstance(entry, dict)]
if tag in names:
fields["tag"] = tag
return self._stamp(fields)
def _namespace(self) -> str:
namespace = self.environ.get("HUX_PRODUCER_NAMESPACE", "")
_check(NAMESPACE.fullmatch(namespace), "HUX_PRODUCER_NAMESPACE is missing or malformed")
return namespace
def _kube(self, path: str) -> dict[str, Any]:
base = self.environ.get("HUX_PRODUCER_KUBE_API", DEFAULT_KUBE_API).rstrip("/")
token_file = self.environ.get("HUX_PRODUCER_KUBE_TOKEN_FILE", DEFAULT_KUBE_TOKEN)
try:
token = Path(token_file).read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError) as error:
raise Invalid("kubernetes credentials are unavailable") from error
_check(token, "kubernetes credentials are unavailable")
context = _context(self.environ.get("HUX_PRODUCER_KUBE_CA_FILE", ""))
return self._json(base + path, "kubernetes", {"Authorization": "Bearer " + token}, context)
def _workload_image(self) -> str | None:
kind = self.environ.get("HUX_PRODUCER_WORKLOAD_KIND", "statefulset").lower()
plural = {"deployment": "deployments", "statefulset": "statefulsets"}.get(kind)
_check(plural, "HUX_PRODUCER_WORKLOAD_KIND must be deployment or statefulset")
name = self.environ.get("HUX_PRODUCER_WORKLOAD_NAME", self.workload)
body = self._kube(f"/apis/apps/v1/namespaces/{self._namespace()}/{plural}/{name}")
containers = (((body.get("spec") or {}).get("template") or {}).get("spec") or {}).get("containers") or []
prefix = self.wpolicy["image_repository"] + ":"
for container in containers:
image = container.get("image") if isinstance(container, dict) else None
if isinstance(image, str) and image.startswith(prefix):
return image
return None
def workload_spec(self) -> dict[str, Any]:
"""Observe the live desired image of the policy workload."""
image = self._workload_image()
return self._stamp({"image": image} if image is not None else {})
def flux(self, expected_image: str) -> dict[str, Any]:
"""Observe the policy Kustomization; pin containment is proven, not asserted."""
name = self.wpolicy["flux_kustomization"]
body = self._kube(f"/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/{self._namespace()}/kustomizations/{name}")
status = body.get("status") or {}
applied = status.get("lastAppliedRevision")
fields: dict[str, Any] = {"name": (body.get("metadata") or {}).get("name")}
if isinstance(applied, str):
fields["applied_revision"] = applied
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
settled = isinstance(applied, str) and status.get("lastAttemptedRevision") in (None, applied)
pinned = bool(expected_image) and self._workload_image() == expected_image
fields["pin_in_revision"] = bool(ready and settled and pinned)
return self._stamp(fields)
def pods(self) -> dict[str, Any]:
"""Observe readiness and running imageID for every selected pod."""
selector = self.environ.get("HUX_PRODUCER_POD_SELECTOR", "")
_check(selector, "HUX_PRODUCER_POD_SELECTOR is required to observe pods")
body = self._kube(f"/api/v1/namespaces/{self._namespace()}/pods?labelSelector={quote(selector, safe='=,')}")
prefix = self.wpolicy["image_repository"] + ":"
entries = []
for item in body.get("items") or []:
if not isinstance(item, dict):
continue
status = item.get("status") or {}
ready = any(isinstance(c, dict) and c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions") or [])
image_id = None
for container in status.get("containerStatuses") or []:
if isinstance(container, dict) and str(container.get("image", "")).startswith(prefix):
image_id = container.get("imageID")
break
entries.append({"name": (item.get("metadata") or {}).get("name"), "ready": ready, "image_id": image_id})
return self._stamp({"pods": entries})
def health(self) -> dict[str, Any]:
"""Observe the policy health URL; the receipt carries the real HTTP status."""
url = self.wpolicy["health_url"]
checked = self.clock().strftime("%Y-%m-%dT%H:%M:%SZ")
status, _ = self._fetch(url, "health")
return self._stamp({"url": url, "status": status, "checked_at": checked})
def rollback_target(self) -> dict[str, Any]:
"""Observe the digest currently pinned in the live workload spec."""
image = self._workload_image()
fields: dict[str, Any] = {}
digest = image.rpartition("@")[2] if isinstance(image, str) else ""
if SHA.fullmatch(digest):
fields["digest"] = digest
return self._stamp(fields)
def collect_for(collector: Collector, target: str, release: Mapping[str, Any]) -> dict[str, Any]:
"""Gather exactly the observations the verifier needs for one target state."""
evidence = release.get("evidence", {})
if target == "merged":
return {"git": collector.git(str(evidence.get("review_url", "")))}
if target == "built":
jenkins = collector.jenkins()
observations = {"jenkins": jenkins}
sha, number = jenkins.get("revision"), jenkins.get("build_number")
if sha is not None and number is not None:
observations["harbor"] = collector.harbor(f"git-{sha}-build-{number}-release")
return observations
if target == "verified":
tag = str(evidence.get("image_ref", "")).rpartition("@")[0].rpartition(":")[2]
return {"harbor": collector.harbor(tag)}
if target == "deployed":
return {"flux": collector.flux(str(evidence.get("image_ref", "")))}
if target == "converged":
return {"workload_spec": collector.workload_spec(), "pods": collector.pods()}
if target == "live_verified":
return {"health": collector.health()}
_check(target == "rolled_back", "no collector plan exists for the target state")
return {"rollback": collector.rollback_target()}
class ProducerClient:
"""Loopback HUX API client for the evidence identity; the key never leaves it."""
def __init__(self, base_url: str, slot: str, subject: str, key: str, opener: Opener | None = None, timeout: float = 10.0) -> None:
base = str(base_url or DEFAULT_BASE_URL).rstrip("/")
parts = urlsplit(base)
loopback = parts.scheme == "http" and parts.hostname in {"127.0.0.1", "::1", "localhost"}
_check(parts.scheme == "https" or loopback, "HUX base URL must be loopback HTTP or HTTPS")
self.base = base
self.opener = opener or _default_opener
self.timeout = timeout
self._headers = {
"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject, "X-Hux-Surface": "api",
"X-Hux-Trust": "evidence", "X-Hux-Relay-Key": key, "Content-Type": "application/json",
}
def request(self, method: str, path: str, body: Any = None, headers: Mapping[str, str] | None = None) -> tuple[int, Any, bool]:
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request( # noqa: S310
self.base + path, data=data, headers={**self._headers, **(headers or {})}, method=method)
try:
response = self.opener(request, self.timeout, None)
except urllib.error.HTTPError as error:
response = error
except OSError as error:
raise Invalid("HUX service is unreachable") from error
with response:
status = int(response.status)
raw = response.read(MAX_RESPONSE_BYTES)
replayed = str(response.headers.get("HUX-Replayed") or "") == "true"
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = None
return status, parsed, replayed
def advance(client: ProducerClient, collector: Collector, wpolicy: Mapping[str, Any], max_age: int, scope_path: str, release_id: str, now: datetime) -> dict[str, Any]:
"""Read the head, verify observations, and post at most one transition."""
status, view, _ = client.request("GET", f"{scope_path}/{release_id}")
_check(status == 200 and isinstance(view, dict), f"release read failed with HTTP {status}")
release, revision = view.get("release") or {}, view.get("revision")
_check(not isinstance(revision, bool) and isinstance(revision, int), "release view carries no revision")
target = next_target(str(release.get("state", "")))
if target is None:
return {"changed": False, "release_id": release_id, "state": release.get("state"), "reason": "release is terminal"}
observations = collect_for(collector, target, release)
evidence = evidence_for(release, target, observations, wpolicy, max_age, now)
status, view, replayed = client.request(
"POST", f"{scope_path}/{release_id}/transitions", {"to": target, "evidence": evidence},
{"If-Match": str(revision), "Idempotency-Key": f"producer-{release_id}-{target}-{revision}"[:120]})
_check(status == 200 and isinstance(view, dict), f"release transition was rejected with HTTP {status}")
head = view.get("release") or {}
return {"changed": not replayed, "release_id": release_id, "state": head.get("state"),
"revision": view.get("revision"), "replayed": replayed}
def _require(environ: Mapping[str, str], name: str) -> str:
value = str(environ.get(name, "")).strip()
_check(value, f"{name} is required")
return value
def _pick_release(client: ProducerClient, scope_path: str, workload: str) -> str | None:
status, listing, _ = client.request("GET", scope_path)
_check(status == 200 and isinstance(listing, dict), f"release list failed with HTTP {status}")
for item in listing.get("items") or []:
release = (item.get("release") or {}) if isinstance(item, dict) else {}
if release.get("workload") == workload and release.get("state") not in TERMINAL:
return str(release.get("id"))
return None
def run_once(environ: Mapping[str, str], opener: Opener | None = None, clock: Callable[[], datetime] | None = None) -> dict[str, Any]:
"""One idempotent producer pass wired entirely from the environment."""
environ = dict(environ)
workload = _require(environ, "HUX_PRODUCER_WORKLOAD")
policy = release_security.load_policy(environ)
wpolicy = policy["workloads"].get(workload)
_check(wpolicy is not None, "workload is not enabled for release evidence")
key = release_security.evidence_key(environ)
client = ProducerClient(environ.get("HUX_BASE_URL", DEFAULT_BASE_URL), _require(environ, "HUX_TENANT_SLOT"),
_require(environ, "HUX_PRODUCER_SUBJECT"), key, opener, _bounded_timeout(environ))
project = _require(environ, "HUX_PRODUCER_PROJECT_ID")
conversation = _require(environ, "HUX_PRODUCER_CONVERSATION_ID")
scope_path = f"/hux/v1/projects/{project}/conversations/{conversation}/releases"
collector = Collector(environ, workload, wpolicy, opener, clock)
release_id = str(environ.get("HUX_PRODUCER_RELEASE_ID", "")).strip() or _pick_release(client, scope_path, workload)
if release_id is None:
return {"changed": False, "reason": "no advanceable release"}
return advance(client, collector, wpolicy, policy["max_evidence_age_seconds"], scope_path, release_id, collector.clock())

Some files were not shown because too many files have changed in this diff Show More