diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index 1ac6e20b..79a817e6 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -44,6 +44,7 @@ data: enabled: - atlas-broker - auto-router + - hux-runtime model_catalog: enabled: true ttl_hours: 1 diff --git a/services/hermes/chat-pvcs.yaml b/services/hermes/chat-pvcs.yaml index 50d286cb..553a8a3e 100644 --- a/services/hermes/chat-pvcs.yaml +++ b/services/hermes/chat-pvcs.yaml @@ -15,6 +15,24 @@ spec: requests: storage: 10Gi --- +# HUX tenants share the backing claim but only receive their pod-specific +# subdirectory through kubelet subPath mounts. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: hermes-chat-hux-data + namespace: hermes + labels: + app: hermes-chat-tenant + ai.bstein.dev/data: tenant-hux-ledger +spec: + accessModes: + - ReadWriteMany + storageClassName: astreae + resources: + requests: + storage: 10Gi +--- apiVersion: v1 kind: PersistentVolumeClaim metadata: diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index c518684e..c99e84ef 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -30,6 +30,7 @@ spec: ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides ai.bstein.dev/config-rev: "20260816-telegram-topics" + ai.bstein.dev/hux-config-rev: "20260824-hux-v1" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-chat vault.hashicorp.com/agent-inject-secret-chat-relay-key: kv/data/atlas/hermes/chat-telegram @@ -144,6 +145,121 @@ spec: resources: requests: {cpu: 25m, memory: 32Mi} limits: {cpu: 100m, memory: 64Mi} + - name: init-hux-runtime + image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 + imagePullPolicy: IfNotPresent + command: [/bin/sh, -ec] + args: + - | + umask 077 + tenant_root="/hux-data/${HOSTNAME}" + export HUX_INIT_ROOT="${tenant_root}" + mkdir -p \ + "${tenant_root}/binding" \ + "${tenant_root}/context" \ + "${tenant_root}/store" \ + /hux-relay \ + /hux-worker + chown 10000:10000 \ + "${tenant_root}" \ + "${tenant_root}/binding" \ + "${tenant_root}/context" \ + "${tenant_root}/store" \ + /hux-relay \ + /hux-worker + chmod 0700 \ + "${tenant_root}" \ + "${tenant_root}/binding" \ + "${tenant_root}/context" \ + "${tenant_root}/store" \ + /hux-relay \ + /hux-worker + if [ ! -e "${tenant_root}/context/context-key" ]; then + dd if=/dev/urandom of="${tenant_root}/context/.context-key.tmp" bs=32 count=1 2>/dev/null + chown 10000:10000 "${tenant_root}/context/.context-key.tmp" + chmod 0600 "${tenant_root}/context/.context-key.tmp" + mv "${tenant_root}/context/.context-key.tmp" "${tenant_root}/context/context-key" + fi + test "$(wc -c < "${tenant_root}/context/context-key")" -eq 32 + chown 10000:10000 "${tenant_root}/context/context-key" + chmod 0600 "${tenant_root}/context/context-key" + ordinal="${HOSTNAME##*-}" + HUX_INIT_SLOT="slot-${ordinal}" \ + /opt/hermes/.venv/bin/python - <<'PY' + import hashlib + import hmac + import os + import stat + from pathlib import Path + + root = Path(os.environ["HUX_INIT_ROOT"]) + key = (root / "context/context-key").read_bytes() + slot = os.environ["HUX_INIT_SLOT"] + subject = "usr_" + hmac.new( + key, + b"hux.subject.id.v1\0" + slot.encode("ascii"), + hashlib.sha256, + ).hexdigest() + target = root / "binding/subject" + expected = (subject + "\n").encode("ascii") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(target, flags, 0o440) + except FileExistsError: + info = target.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != 10000 + or stat.S_IMODE(info.st_mode) != 0o440 + or info.st_nlink != 1 + or target.read_bytes() != expected + ): + raise SystemExit("persistent HUX subject binding is unsafe") + else: + try: + os.write(descriptor, expected) + os.fchown(descriptor, 10000, 10000) + os.fchmod(descriptor, 0o440) + os.fsync(descriptor) + finally: + os.close(descriptor) + PY + if [ ! -e "${tenant_root}/context/redaction-canary" ]; then + dd if=/dev/urandom bs=32 count=1 2>/dev/null \ + | sha256sum | cut -d ' ' -f 1 \ + > "${tenant_root}/context/.redaction-canary.tmp" + chown 10000:10000 "${tenant_root}/context/.redaction-canary.tmp" + chmod 0400 "${tenant_root}/context/.redaction-canary.tmp" + mv "${tenant_root}/context/.redaction-canary.tmp" "${tenant_root}/context/redaction-canary" + fi + for target in /hux-relay/relay-key /hux-worker/worker-key; do + if [ ! -e "${target}" ]; then + dd if=/dev/urandom bs=32 count=1 2>/dev/null \ + | sha256sum | cut -d ' ' -f 1 > "${target}.tmp" + chown 10000:10000 "${target}.tmp" + chmod 0400 "${target}.tmp" + mv "${target}.tmp" "${target}" + fi + test "$(wc -c < "${target}")" -eq 65 + chown 10000:10000 "${target}" + chmod 0400 "${target}" + done + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + add: [CHOWN, DAC_OVERRIDE, FOWNER] + runAsUser: 0 + runAsGroup: 0 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - {name: hux-data, mountPath: /hux-data} + - {name: hux-relay-key, mountPath: /hux-relay} + - {name: hux-worker-key, mountPath: /hux-worker} + resources: + requests: {cpu: 10m, memory: 16Mi} + limits: {cpu: 50m, memory: 32Mi} - name: stage-runtime-access image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 imagePullPolicy: IfNotPresent @@ -267,10 +383,15 @@ spec: API_SERVER_KEY="$(tr -d '\r\n' < /runtime-access/chat-relay-key)" test -n "${API_SERVER_KEY}" export API_SERVER_KEY + export HUX_TENANT_SLOT="slot-${ordinal}" exec /opt/hermes/.venv/bin/hermes gateway run ports: - {name: api, containerPort: 8642, protocol: TCP} env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name - {name: HERMES_HOME, value: /opt/data} - {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json} - {name: HOME, value: /opt/data/home} @@ -288,6 +409,16 @@ spec: - {name: HERMES_IMAGE_BROKER_URL, value: 'http://hermes-image-broker.hermes.svc.cluster.local:9002'} - {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key} - {name: HERMES_AUTO_ROUTER_PROFILE, value: chat} + - {name: HUX_BASE_URL, value: 'http://127.0.0.1:8790'} + - {name: HUX_RUNTIME_ENABLED, value: "1"} + # First rollout is observe-only until approval parking/resume is + # connected to the upstream tool loop and proven live. + - {name: HUX_TOOL_ENFORCEMENT, value: "0"} + - {name: HUX_WORKER_KEY_FILE, value: /run/hermes-hux-worker/worker-key} + - {name: HUX_SUBJECT_FILE, value: /run/hermes-hux-subject/subject} + - {name: HUX_CONTEXT_KEY_FILE, value: /run/hermes-hux-context/context-key} + - {name: HUX_PROJECT_SOURCE, value: 'profile:default'} + - {name: HUX_TIMEOUT_SECONDS, value: "3"} volumeMounts: - {name: home, mountPath: /opt/data} - {name: workspace, mountPath: /opt/data/workspace} @@ -300,6 +431,10 @@ spec: - {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py} - {name: image-plugin, mountPath: /opt/hermes/plugins/image_gen/atlas-broker, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} + - {name: hux-runtime-plugin, mountPath: /opt/data/plugins/hux-runtime, readOnly: true} + - {name: hux-worker-key, mountPath: /run/hermes-hux-worker, readOnly: true} + - {name: hux-data, mountPath: /run/hermes-hux-context, subPathExpr: $(POD_NAME)/context, readOnly: true} + - {name: hux-data, mountPath: /run/hermes-hux-subject, subPathExpr: $(POD_NAME)/binding, readOnly: true} readinessProbe: tcpSocket: {port: api} initialDelaySeconds: 30 @@ -333,6 +468,10 @@ spec: ports: - {name: webui, containerPort: 8787, protocol: TCP} env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name - {name: HERMES_HOME, value: /opt/data} - {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json} - {name: HOME, value: /opt/data/home} @@ -361,12 +500,16 @@ spec: - {name: HERMES_LOCAL_STT_COMMAND, value: "/opt/hermes/.venv/bin/python /opt/coordinator/hermes_stt_client.py {input_path} --output-dir {output_dir} --language {language} --model {model}"} - {name: HERMES_WEBUI_ATLAS_TTS_URL, value: 'http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech'} - {name: HERMES_WEBUI_ATLAS_TTS_STREAM_URL, value: 'http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech/stream'} + - {name: HUX_CONTEXT_KEY_FILE, value: /run/hermes-hux-context/context-key} + - {name: HUX_PROJECT_SOURCE, value: 'profile:default'} volumeMounts: - {name: home, mountPath: /opt/data} - {name: workspace, mountPath: /opt/data/workspace} - {name: runtime-access, mountPath: /runtime-access, readOnly: true} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: tmp, mountPath: /tmp} + - {name: hux-relay-key, mountPath: /run/hermes-webui-hux, readOnly: true} + - {name: hux-data, mountPath: /run/hermes-hux-context, subPathExpr: $(POD_NAME)/context, readOnly: true} readinessProbe: httpGet: {path: /health, port: webui} initialDelaySeconds: 10 @@ -389,6 +532,76 @@ spec: resources: requests: {cpu: 100m, memory: 224Mi} limits: {cpu: 750m, memory: 1Gi} + - name: hux + image: registry.bstein.dev/bstein/hermes-webui:git-91eb4f92b7cf46e65dc615106aedfde232c9c670-build-18-release@sha256:df91f8a3cdb54d685f8fea023a4539d89d1d0b3f89024cca4dce87b0b4a7df9c # {"$imagepolicy": "hermes:hermes-webui-release"} + imagePullPolicy: IfNotPresent + command: [/bin/sh, -ec] + args: + - | + ordinal="${HOSTNAME##*-}" + export HUX_TENANT_SLOT="slot-${ordinal}" + exec /opt/hermes/.venv/bin/python -m hux.server + ports: + - {name: hux-loopback, containerPort: 8790, protocol: TCP} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - {name: PYTHONPATH, value: /opt/hermes-hux} + - {name: PYTHONDONTWRITEBYTECODE, value: "1"} + - {name: HOME, value: /tmp} + - {name: HUX_BIND, value: 127.0.0.1} + - {name: HUX_PORT, value: "8790"} + - {name: HUX_DATA_ROOT, value: /var/lib/hux/store} + - {name: HUX_FLAGS, value: 'hux.foundation,hux.activity_timeline,hux.memory_control,hux.projects,hux.artifacts,hux.autonomy,hux.friendly_modes,hux.multimodal,hux.research,hux.onboarding,hux.privacy,hux.release_followthrough'} + - {name: HUX_RELAY_KEY_FILE, value: /run/hermes-webui-hux/relay-key} + - {name: HUX_WORKER_KEY_FILE, value: /run/hermes-hux-worker/worker-key} + - {name: HUX_SUBJECT_BINDING_FILE, value: /var/lib/hux/binding/subject} + - {name: HUX_CONTEXT_KEY_FILE, value: /var/lib/hux/context/context-key} + - {name: HUX_CANARY_FILE, value: /var/lib/hux/context/redaction-canary} + - {name: HUX_IMAGE_TAG, value: 'git-91eb4f92b7cf46e65dc615106aedfde232c9c670-build-18-release'} # {"$imagepolicy": "hermes:hermes-webui-release:tag"} + - {name: HUX_IMAGE_DIGEST, value: 'sha256:df91f8a3cdb54d685f8fea023a4539d89d1d0b3f89024cca4dce87b0b4a7df9c'} # {"$imagepolicy": "hermes:hermes-webui-release:digest"} + - {name: HUX_SWITCHYARD_ROUTE_CATALOG, value: 'atlas/manual/codex/luna,atlas/manual/codex/terra,atlas/manual/codex/sol,atlas/manual/claude/haiku,atlas/manual/claude/fable,atlas/manual/claude/sonnet,atlas/manual/claude/opus,atlas/manual/local/qwen-14b'} + - {name: HUX_READS_PER_MINUTE, value: "600"} + - {name: HUX_WRITES_PER_MINUTE, value: "120"} + - {name: HUX_REQUEST_TIMEOUT_SECONDS, value: "10"} + volumeMounts: + - {name: hux-data, mountPath: /var/lib/hux, subPathExpr: $(POD_NAME)} + - {name: hux-relay-key, mountPath: /run/hermes-webui-hux, readOnly: true} + - {name: hux-worker-key, mountPath: /run/hermes-hux-worker, readOnly: true} + - {name: hux-tmp, mountPath: /tmp} + readinessProbe: + exec: + command: + - /opt/hermes/.venv/bin/python + - -c + - "import json,urllib.request; body=json.load(urllib.request.urlopen('http://127.0.0.1:8790/healthz', timeout=2)); assert body['status']=='ok'" + initialDelaySeconds: 2 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 12 + livenessProbe: + exec: + command: + - /opt/hermes/.venv/bin/python + - -c + - "import json,urllib.request; body=json.load(urllib.request.urlopen('http://127.0.0.1:8790/healthz', timeout=2)); assert body['status']=='ok'" + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 3 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + resources: + requests: {cpu: 25m, memory: 64Mi} + limits: {cpu: 250m, memory: 256Mi} - name: telegram-media image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 imagePullPolicy: IfNotPresent @@ -452,9 +665,35 @@ spec: - name: image-plugin configMap: name: hermes-chat-image-plugin + - name: hux-runtime-plugin + configMap: + name: hermes-hux-runtime-plugin + items: + - {key: __init__.py, path: __init__.py} + - {key: context_ids.py, path: context_ids.py} + - {key: runtime.py, path: runtime.py} + - {key: tool_policy.py, path: tool_policy.py} + - {key: plugin.yaml, path: plugin.yaml} + - {key: hux-hook-init.py, path: hux_hook/__init__.py} + - {key: hux-hook-client.py, path: hux_hook/client.py} + - {key: hux-hook-hooks.py, path: hux_hook/hooks.py} - name: tmp emptyDir: sizeLimit: 256Mi + - name: hux-relay-key + emptyDir: + medium: Memory + sizeLimit: 1Mi + - name: hux-worker-key + emptyDir: + medium: Memory + sizeLimit: 1Mi + - name: hux-tmp + emptyDir: + sizeLimit: 64Mi + - name: hux-data + persistentVolumeClaim: + claimName: hermes-chat-hux-data volumeClaimTemplates: - metadata: name: home diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index b4f36491..152b0e33 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -195,6 +195,19 @@ configMapGenerator: - dashboard-style.css=plugins/auto-router/dashboard/dist/style.css options: disableNameSuffixHash: true + - name: hermes-hux-runtime-plugin + namespace: hermes + files: + - __init__.py=plugins/hux-runtime/__init__.py + - context_ids.py=plugins/hux-runtime/context_ids.py + - runtime.py=plugins/hux-runtime/runtime.py + - tool_policy.py=plugins/hux-runtime/tool_policy.py + - plugin.yaml=plugins/hux-runtime/plugin.yaml + - hux-hook-init.py=plugins/hux-runtime/hux_hook/__init__.py + - hux-hook-client.py=plugins/hux-runtime/hux_hook/client.py + - hux-hook-hooks.py=plugins/hux-runtime/hux_hook/hooks.py + options: + disableNameSuffixHash: true - name: hermes-chat-image-plugin namespace: hermes files: diff --git a/testing/tests/test_hermes_hux_delivery.py b/testing/tests/test_hermes_hux_delivery.py new file mode 100644 index 00000000..8fcaae64 --- /dev/null +++ b/testing/tests/test_hermes_hux_delivery.py @@ -0,0 +1,190 @@ +"""Flux delivery gates for the per-tenant HUX service boundary.""" + +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +SERVICE = ROOT / "services/hermes" +CHAT = SERVICE / "chat-statefulset.yaml" +CHAT_PVCS = SERVICE / "chat-pvcs.yaml" +KUSTOMIZATION = SERVICE / "kustomization.yaml" +WEBUI_IMAGE = "registry.bstein.dev/bstein/hermes-webui" +ALL_FLAGS = { + "hux.activity_timeline", + "hux.artifacts", + "hux.autonomy", + "hux.foundation", + "hux.friendly_modes", + "hux.memory_control", + "hux.multimodal", + "hux.onboarding", + "hux.privacy", + "hux.projects", + "hux.release_followthrough", + "hux.research", +} + + +def _statefulset() -> dict: + return yaml.safe_load(CHAT.read_text(encoding="utf-8")) + + +def _named(items: list[dict], name: str) -> dict: + return next(item for item in items if item["name"] == name) + + +def _env(container: dict) -> dict[str, str]: + return {item["name"]: item.get("value", "") for item in container["env"]} + + +def _mounts(container: dict) -> dict[str, dict]: + return {item["name"]: item for item in container.get("volumeMounts", [])} + + +def test_hux_sidecar_is_loopback_only_and_uses_the_reviewed_webui_image() -> None: + """The browser BFF and HUX backend ship as one immutable reviewed artifact.""" + pod = _statefulset()["spec"]["template"]["spec"] + webui = _named(pod["containers"], "webui") + hux = _named(pod["containers"], "hux") + values = _env(hux) + + assert webui["image"] == hux["image"] + assert webui["image"].startswith(WEBUI_IMAGE + ":") + assert values["HUX_BIND"] == "127.0.0.1" + assert values["HUX_PORT"] == "8790" + assert values["HUX_DATA_ROOT"] == "/var/lib/hux/store" + assert set(values["HUX_FLAGS"].split(",")) == ALL_FLAGS + assert set(values["HUX_SWITCHYARD_ROUTE_CATALOG"].split(",")) == { + "atlas/manual/codex/luna", + "atlas/manual/codex/terra", + "atlas/manual/codex/sol", + "atlas/manual/claude/haiku", + "atlas/manual/claude/fable", + "atlas/manual/claude/sonnet", + "atlas/manual/claude/opus", + "atlas/manual/local/qwen-14b", + } + assert values["PYTHONDONTWRITEBYTECODE"] == "1" + assert hux["securityContext"]["readOnlyRootFilesystem"] is True + assert hux["securityContext"]["capabilities"]["drop"] == ["ALL"] + for probe in ("readinessProbe", "livenessProbe"): + command = " ".join(hux[probe]["exec"]["command"]) + assert "127.0.0.1" in command + assert "8790" in command + assert "/healthz" in command + assert "tcpSocket" not in hux[probe] + assert "8790" not in (SERVICE / "service.yaml").read_text(encoding="utf-8") + policy = (SERVICE / "networkpolicy.yaml").read_text(encoding="utf-8") + assert "port: 8790" not in policy + + +def test_hux_storage_and_keys_are_mounted_by_least_privilege() -> None: + """Hermes receives only its key/context views, never the HUX ledger root.""" + stateful = _statefulset() + pod = stateful["spec"]["template"]["spec"] + hermes = _named(pod["containers"], "hermes") + webui = _named(pod["containers"], "webui") + hux = _named(pod["containers"], "hux") + media = _named(pod["containers"], "telegram-media") + hermes_mounts = hermes["volumeMounts"] + webui_mounts = _mounts(webui) + hux_mounts = _mounts(hux) + + assert hux_mounts["hux-data"] == { + "name": "hux-data", + "mountPath": "/var/lib/hux", + "subPathExpr": "$(POD_NAME)", + } + assert { + item["subPathExpr"] + for item in hermes_mounts + if item["name"] == "hux-data" + } == {"$(POD_NAME)/binding", "$(POD_NAME)/context"} + assert webui_mounts["hux-data"]["subPathExpr"] == "$(POD_NAME)/context" + assert not any(item.get("mountPath") == "/var/lib/hux" for item in hermes["volumeMounts"]) + assert not any(item.get("mountPath") == "/var/lib/hux" for item in webui["volumeMounts"]) + assert not any(item["name"].startswith("hux-") for item in media["volumeMounts"]) + assert not any(item["name"] == "hux-relay-key" for item in hermes_mounts) + assert "hux-worker-key" not in webui_mounts + + assert {item["metadata"]["name"] for item in stateful["spec"]["volumeClaimTemplates"]} == { + "home", + "workspace", + } + claims = { + item["metadata"]["name"]: item + for item in yaml.safe_load_all(CHAT_PVCS.read_text(encoding="utf-8")) + } + claim = claims["hermes-chat-hux-data"]["spec"] + assert claim["accessModes"] == ["ReadWriteMany"] + assert claim["storageClassName"] == "astreae" + assert claim["resources"]["requests"]["storage"] == "10Gi" + + +def test_hux_init_preserves_context_identity_and_rotates_transport_keys() -> None: + """Context identity is durable while relay/worker credentials are pod-local.""" + pod = _statefulset()["spec"]["template"]["spec"] + init = _named(pod["initContainers"], "init-hux-runtime") + script = init["args"][0] + volumes = {item["name"]: item for item in pod["volumes"]} + + assert 'tenant_root="/hux-data/${HOSTNAME}"' in script + assert "chown -R" not in script + assert 'if [ ! -e "${tenant_root}/context/context-key" ]' in script + assert "bs=32 count=1" in script + assert 'chmod 0600 "${tenant_root}/context/context-key"' in script + assert "/hux-relay/relay-key /hux-worker/worker-key" in script + assert "chmod 0400" in script + assert 'ordinal="${HOSTNAME##*-}"' in script + assert 'b"hux.subject.id.v1\\0" + slot.encode("ascii")' in script + assert 'target = root / "binding/subject"' in script + assert 'or stat.S_IMODE(info.st_mode) != 0o440' in script + assert volumes["hux-relay-key"]["emptyDir"]["medium"] == "Memory" + assert volumes["hux-worker-key"]["emptyDir"]["medium"] == "Memory" + assert init["securityContext"]["capabilities"] == { + "drop": ["ALL"], + "add": ["CHOWN", "DAC_OVERRIDE", "FOWNER"], + } + + +def test_hux_identity_and_authentication_inputs_are_file_backed() -> None: + """No HUX shared key or subject is placed directly in an environment value.""" + pod = _statefulset()["spec"]["template"]["spec"] + hermes = _env(_named(pod["containers"], "hermes")) + webui = _env(_named(pod["containers"], "webui")) + hux = _env(_named(pod["containers"], "hux")) + + assert hermes["HUX_WORKER_KEY_FILE"] == "/run/hermes-hux-worker/worker-key" + assert hermes["HUX_SUBJECT_FILE"] == "/run/hermes-hux-subject/subject" + assert hermes["HUX_CONTEXT_KEY_FILE"] == "/run/hermes-hux-context/context-key" + assert hermes["HUX_TOOL_ENFORCEMENT"] == "0" + assert webui["HUX_CONTEXT_KEY_FILE"] == "/run/hermes-hux-context/context-key" + assert hux["HUX_RELAY_KEY_FILE"] == "/run/hermes-webui-hux/relay-key" + assert hux["HUX_WORKER_KEY_FILE"] == "/run/hermes-hux-worker/worker-key" + assert hux["HUX_SUBJECT_BINDING_FILE"] == "/var/lib/hux/binding/subject" + assert hux["HUX_CONTEXT_KEY_FILE"] == "/var/lib/hux/context/context-key" + assert hux["HUX_IMAGE_TAG"].startswith("git-") + assert hux["HUX_IMAGE_TAG"].endswith("-release") + assert hux["HUX_IMAGE_DIGEST"].startswith("sha256:") + assert not {"HUX_RELAY_KEY", "HUX_WORKER_KEY", "HUX_ROUTER_KEY"} & set(hux) + + +def test_hux_runtime_plugin_renders_its_vendored_hook_package() -> None: + """The mounted plugin must contain its Kustomize-local Python package tree.""" + stateful = _statefulset() + pod = stateful["spec"]["template"]["spec"] + plugin = _named(pod["volumes"], "hux-runtime-plugin")["configMap"] + items = {item["key"]: item["path"] for item in plugin["items"]} + expected = { + "hux-hook-init.py": "hux_hook/__init__.py", + "hux-hook-client.py": "hux_hook/client.py", + "hux-hook-hooks.py": "hux_hook/hooks.py", + } + + assert expected.items() <= items.items() + rendered = KUSTOMIZATION.read_text(encoding="utf-8") + assert "hux-hook-init.py=plugins/hux-runtime/hux_hook/__init__.py" in rendered + assert "hux-hook-client.py=plugins/hux-runtime/hux_hook/client.py" in rendered + assert "hux-hook-hooks.py=plugins/hux-runtime/hux_hook/hooks.py" in rendered diff --git a/testing/tests/test_hermes_image_automation.py b/testing/tests/test_hermes_image_automation.py index 7cd5d978..8a0d6c90 100644 --- a/testing/tests/test_hermes_image_automation.py +++ b/testing/tests/test_hermes_image_automation.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +import re import yaml @@ -73,6 +74,28 @@ def test_flux_updates_only_the_reviewed_hermes_image_digests() -> None: webui_marker = '"$imagepolicy": "hermes:hermes-webui-release"' assert chat.count(webui_marker) == 2 assert dashboard.count(webui_marker) == 1 + assert chat.count( + '"$imagepolicy": "hermes:hermes-webui-release:tag"' + ) == 1 + assert chat.count( + '"$imagepolicy": "hermes:hermes-webui-release:digest"' + ) == 1 + chat_object = yaml.safe_load(chat) + hux = next( + item + for item in chat_object["spec"]["template"]["spec"]["containers"] + if item["name"] == "hux" + ) + hux_env = {item["name"]: item["value"] for item in hux["env"] if "value" in item} + release = re.fullmatch( + r"git-([0-9a-f]{40})-build-[1-9][0-9]*-release", + hux_env["HUX_IMAGE_TAG"], + ) + assert release is not None + assert hux["image"].split(":git-", 1)[1].split("@", 1)[0] == hux_env[ + "HUX_IMAGE_TAG" + ].removeprefix("git-") + assert hux["image"].endswith("@" + hux_env["HUX_IMAGE_DIGEST"]) # A digest-only setter replaces the complete YAML scalar with ``sha256:...``. # Whole-image setters must retain the registry and repository in pod specs. for workload in (chat, dashboard):