Compare commits
No commits in common. "main" and "feature/hermes-next-hux" have entirely different histories.
main
...
feature/he
25
Jenkinsfile
vendored
25
Jenkinsfile
vendored
@ -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}
|
||||
'''
|
||||
}
|
||||
|
||||
70
README.md
70
README.md
@ -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.
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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}
|
||||
'''
|
||||
}
|
||||
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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}$")
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,8 @@ kind: Kustomization
|
||||
metadata:
|
||||
name: comms
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 10m
|
||||
prune: true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
@ -24,3 +24,4 @@ spec:
|
||||
- name: vault
|
||||
- name: gitea
|
||||
- name: hermes-scm-namespace
|
||||
- name: hermes-scm-broker-code
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,8 @@ kind: Kustomization
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 10m
|
||||
prune: true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,8 @@ kind: Kustomization
|
||||
metadata:
|
||||
name: openldap
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 10m
|
||||
prune: true
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,8 @@ kind: Kustomization
|
||||
metadata:
|
||||
name: helm
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 30m
|
||||
sourceRef:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,8 @@ kind: Kustomization
|
||||
metadata:
|
||||
name: metallb
|
||||
namespace: flux-system
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
|
||||
spec:
|
||||
interval: 30m
|
||||
sourceRef:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)' \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
"""Hermes User Experience foundation service package."""
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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", "")}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
@ -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)
|
||||
@ -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")
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user