Files
d13e922120 Release batch B: Docker single-container UID fix + GLM-5.3 catalog (experimental) (#7090)
* feat: add GLM-5.3 to Z.AI model list

Add glm-5.3 as the newest zai entry in _PROVIDER_MODELS and a matching
zai/glm-5.3 entry in _FALLBACK_MODELS, and bump the Z.AI onboarding
default_model from glm-5.1 to glm-5.3 (Z.ai's current flagship; legacy
GLM-5.2/5.1 requests are routed to GLM-5.3 per docs.z.ai).

Reasoning gating needs no change: _zai_glm_classification() treats
GLM >= 5.2 as effort-ladder capable, so glm-5.3 is already covered and
pinned by tests/test_zai_reasoning_effort_gating.py.

New regression coverage in tests/test_glm_5_3_catalog.py: catalog
presence, newest-first ordering, fallback entry, onboarding default,
full reasoning_effort ladder, and the get_available_models() payload.

* test: restore _cfg_fingerprint in catalog test fixture

Review follow-up (#7017): the isolation fixture snapshot restored cfg,
_cfg_mtime, and _cfg_path but left _cfg_fingerprint pointing at the
temporary config loaded by the payload test. api/config.py uses that
fingerprint to distinguish in-memory overrides from changed files
(config.py:371), so a stale value could make later same-process tests
skip reloading a changed config. Snapshot and restore it like the rest.

* fix: keep Z.AI onboarding default at glm-5.1 until direct API serves GLM-5.3

Review follow-up (#7017): GLM-5.3 is live on Z.ai's Coding Plan
endpoint only; the direct api.z.ai endpoint the zai provider uses
still lists the GLM-5.3 API as coming soon. Defaulting new direct-API
users onto glm-5.3 would fail their first message, so the catalog
addition stays (opt-in) and the default stays glm-5.1. Bump the
default in a follow-up once the direct endpoint serves GLM-5.3.

* fix(docker): probe the configured state dir before /workspace for UID/GID (#7027)

UID/GID auto-detection probed /workspace before the configured
HERMES_WEBUI_STATE_DIR. In a stock single-container image none of the
priority-1 candidates exist, but /workspace does — owned by the image's
build-time 1024:1024. Detection therefore returned the image's own owner,
which carries no information about the host, while the one directory whose
owner *is* the host UID by definition — the state-dir bind mount — was never
probed. With a host-owned state mount and no explicit WANTED_UID the
container remapped to 1024, failed its own state-dir writability check, and
restart-looped.

The log line made this expensive to debug: 1024 is also the fallback default,
so "Auto-detected workspace UID: 1024" read as if detection had found nothing.

- probe ${HERMES_WEBUI_STATE_DIR:-/app/data} first, for both UID and GID
- keep /workspace as a lower-priority signal (unchanged for setups that
  actually bind-mount it), and keep the hermes-home probes from #668
- stop treating an explicitly supplied 1024 as "unset": the sentinel and a
  valid UID were the same number, so an operator who deliberately ran as 1024
  got it overwritten by detection. The explicit/detected origin is persisted
  next to the value because `su` drops the environment when the script
  re-enters as the runtime user, so the second pass would otherwise see an
  explicit choice as a detected one.

Tests: tests/test_7027_state_dir_uid_probe.py runs the real resolution block
under bash with `stat` stubbed, covering the non-1024 state mount, the
explicit 1024 override (including across the privilege drop), and the
pre-existing /workspace + hermes-home fallback paths. A new state-dir-uid job
in the Docker smoke workflow boots a real container on a host-owned state
mount and gates on /health.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Release batch B: Docker UID fix + GLM-5.3 catalog (experimental)

Two independently gate-passed contributor PRs, rebased fresh onto master and
re-gated as a combined stage (Codex SAFE TO SHIP; full suite green except the 8
pre-existing approval-test failures that fail identically on clean origin/master
— CI green on the same commit; tracked separately for a fix).

- #7027 (@jorgejiro) probe configured state-dir before /workspace for Docker
  UID/GID; persist explicit marker so a supplied 1024 survives root->su re-entry
  (fixes the single-container restart loop) (#7034)
- #7017 (@rh-id) add GLM-5.3 to the Z.AI model list (onboarding default stays glm-5.1)

Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: Jorge <jorgejiro@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: n <a@n>
Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>
2026-08-16 21:52:14 +00:00

404 lines
17 KiB
YAML

name: Docker smoke
# Runtime smoke gate for Docker init logic.
#
# Background: v0.51.84 (PR #2470) shipped a startup-killing :ro mount + chown
# interaction (EROFS under `set -e`) that 9 source-level pytest invariants +
# 5800+ existing tests all passed. The independent reviewer caught it by eye.
# This workflow closes that class of gap by actually `docker compose up`-ing
# each variant against a real Docker daemon on the GHA runner.
#
# Scope (intentionally small for v1):
# - 3 compose variants (single, two-container, three-container)
# - For multi-container variants, rebuild the local Dockerfile and re-tag
# it as ghcr.io/nesquena/hermes-webui:latest BEFORE `up` so the PR's
# changes to docker_init.bash / Dockerfile actually execute. Without this
# the multi-container variants would pull the previous release from GHCR
# and silently miss every PR-level regression.
# - Pre-flight `docker compose config` job to catch schema/interpolation drift.
# - Reaper before each smoke run + trap on EXIT for orphan defence.
#
# Out of scope for v1 (per design review):
# - HERMES_WEBUI_SMOKE_TEST env flag in docker_init.bash (production-code footgun)
# - --user 60000:60000 (skips the chown branch we're protecting against)
# - Hadolint / yamllint (separate lint workflow, follow-up PR)
# - Local-runnable scripts/docker-smoke-test.sh (ship CI first, then iterate)
# - Podman runtime smoke (defer until a podman-specific bug ships)
on:
pull_request:
branches: [master]
paths:
- 'Dockerfile'
- 'docker_init.bash'
- 'docker-compose*.yml'
- '.dockerignore'
- '.env.docker.example'
- '.github/workflows/docker-smoke.yml'
push:
branches: [master]
paths:
- 'Dockerfile'
- 'docker_init.bash'
- 'docker-compose*.yml'
- '.dockerignore'
- '.env.docker.example'
- '.github/workflows/docker-smoke.yml'
workflow_dispatch:
# Fork PRs run with no secrets — that's the right model. Pin to least privilege.
permissions:
contents: read
jobs:
compose-config:
name: Compose config validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate every compose file parses
run: |
set -euo pipefail
for f in docker-compose.yml docker-compose.two-container.yml docker-compose.three-container.yml; do
echo "::group::compose config: $f"
docker compose -f "$f" config > /dev/null
echo "::endgroup::"
done
# Build the Docker image once and cache layers via GHA cache.
# The smoke matrix jobs then pull from this cache instead of rebuilding
# from scratch, saving ~1-3 minutes per variant.
build-image:
name: Build Docker image (cache layers)
runs-on: ubuntu-latest
needs: compose-config
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build and cache Docker image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Startup-health gate for UID/GID auto-detection (#7027).
#
# The compose variants above all mount a hermes-home volume, so they never
# exercise the plain single-container shape from the issue: a state directory
# bind-mounted from a host directory owned by a non-1024 user, /workspace left
# as the image's own build-time 1024, and no explicit WANTED_UID/WANTED_GID.
# Before the fix the auto-detector read /workspace, remapped to 1024, failed
# its own state-dir writability check and restart-looped. Source-level
# invariants can't catch that — only actually booting the container can.
state-dir-uid:
name: State-dir UID detection (#7027)
runs-on: ubuntu-latest
needs: [compose-config, build-image]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Restore Docker image from cache
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
- name: Boot on a host-owned state mount with no explicit IDs
run: |
set -euo pipefail
CONTAINER="hermes-smoke-uid-${{ github.run_id }}-${{ github.run_attempt }}"
HOST_UID=1001
STATE_DIR="$(mktemp -d -t hermes-smoke-uidstate-XXXXXX)"
sudo chown -R "$HOST_UID:$HOST_UID" "$STATE_DIR"
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker logs "$CONTAINER" 2>&1 | tail -200 || true
docker rm -f "$CONTAINER" || true
sudo rm -rf "$STATE_DIR" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
# Deliberately NOT mounting /workspace: the point of this gate is that
# the stock image's own /workspace (owned 1024:1024) must not win over
# the host-owned state mount.
echo "::group::docker run"
docker run -d --name "$CONTAINER" \
-p 127.0.0.1:8788:8787 \
-e HERMES_WEBUI_STATE_DIR=/app/data \
-v "$STATE_DIR":/app/data \
ghcr.io/nesquena/hermes-webui:latest
echo "::endgroup::"
# ----- The product gate: the container must reach /health -----
echo "::group::Probe /health"
attempts=0
max_attempts=60
until curl --fail --silent --max-time 5 http://127.0.0.1:8788/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~5m)"
echo " Startup logs follow in the cleanup group below."
exit 1
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
echo "❌ Container exited before /health came up — this is the #7027 restart loop"
exit 1
fi
sleep 5
done
echo "✅ /health = 200 after $attempts attempts"
echo "::endgroup::"
echo "::group::Verify the detected identity"
LOGS="$(docker logs "$CONTAINER" 2>&1)"
# The identity must have been detected from the state dir, not /workspace.
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected UID: ${HOST_UID} \(from /app/data\)"; then
echo "❌ UID was not auto-detected from the state directory (#7027)"
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
exit 1
fi
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected GID: ${HOST_UID} \(from /app/data\)"; then
echo "❌ GID was not auto-detected from the state directory (#7027)"
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
exit 1
fi
# And the runtime user must really carry it.
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
ACTUAL_GID="$(docker exec "$CONTAINER" id -g hermeswebui)"
if [ "$ACTUAL_UID" != "$HOST_UID" ] || [ "$ACTUAL_GID" != "$HOST_UID" ]; then
echo "❌ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, expected ${HOST_UID}:${HOST_UID}"
exit 1
fi
echo "✅ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, detected from the state mount"
echo "::endgroup::"
echo "::group::Startup log scan"
BAD_PATTERNS='!! ERROR|!! Exiting script|Failed to verify state directory|Permission denied'
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
echo "❌ Startup logs contain known-bad pattern (see above)"
exit 1
fi
echo "✅ No known-bad patterns in startup logs"
echo "::endgroup::"
- name: Explicit WANTED_UID=1024 must survive auto-detection
run: |
set -euo pipefail
CONTAINER="hermes-smoke-uid1024-${{ github.run_id }}-${{ github.run_attempt }}"
STATE_DIR="$(mktemp -d -t hermes-smoke-uid1024-XXXXXX)"
HERMES_DIR="$(mktemp -d -t hermes-smoke-uid1024home-XXXXXX)"
# Both mounts are owned by a *different* UID than the one requested.
# The hermes-home mount is what makes this discriminating: it is a
# probe candidate the pre-fix script already read, so if the explicit
# 1024 is still treated as "unset" the container remaps to 1001 and
# the assertion below fails. World-writable state dir so the only
# thing under test is which identity wins, not mount permissions.
sudo chown -R 1001:1001 "$STATE_DIR" "$HERMES_DIR"
sudo chmod 0777 "$STATE_DIR"
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker logs "$CONTAINER" 2>&1 | tail -100 || true
docker rm -f "$CONTAINER" || true
sudo rm -rf "$STATE_DIR" "$HERMES_DIR" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
docker run -d --name "$CONTAINER" \
-p 127.0.0.1:8789:8787 \
-e HERMES_WEBUI_STATE_DIR=/app/data \
-e WANTED_UID=1024 \
-e WANTED_GID=1024 \
-v "$STATE_DIR":/app/data \
-v "$HERMES_DIR":/home/hermeswebui/.hermes \
ghcr.io/nesquena/hermes-webui:latest
attempts=0
max_attempts=60
until curl --fail --silent --max-time 5 http://127.0.0.1:8789/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 with explicit WANTED_UID=1024"
exit 1
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
echo "❌ Container exited before /health came up"
exit 1
fi
sleep 5
done
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
if [ "$ACTUAL_UID" != "1024" ]; then
echo "❌ explicit WANTED_UID=1024 was overwritten by auto-detection — got ${ACTUAL_UID} (#7027)"
exit 1
fi
echo "✅ explicit WANTED_UID=1024 preserved (sentinel no longer shadows the valid value)"
smoke:
name: Smoke ${{ matrix.variant }}
runs-on: ubuntu-latest
needs: [compose-config, build-image]
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
variant:
- single
- two-container
- three-container
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Restore the cached Docker image from the build-image job.
# Read-only: the build-image job already populated the GHA cache, so the
# smoke variants only need cache-from (no redundant cache-to re-export).
- name: Restore Docker image from cache
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
- name: Resolve compose file + project name
id: vars
run: |
set -euo pipefail
case "${{ matrix.variant }}" in
single)
echo "compose_file=docker-compose.yml" >> "$GITHUB_OUTPUT"
;;
two-container)
echo "compose_file=docker-compose.two-container.yml" >> "$GITHUB_OUTPUT"
;;
three-container)
echo "compose_file=docker-compose.three-container.yml" >> "$GITHUB_OUTPUT"
;;
esac
# Per-run project name so concurrent jobs / reruns can't clobber each other.
echo "project=hermes-smoke-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Reap any prior hermes-smoke resources on this runner
run: |
set -euo pipefail
# Hosted GHA runners are fresh, so this is mostly defence-in-depth for
# self-hosted runner re-use. We rely primarily on the unique per-run
# project name + `compose down -v --remove-orphans` in the EXIT trap
# to clean up the resources THIS run creates; this step only sweeps
# leftovers from prior runs that crashed before their trap fired.
# Match by project-name prefix instead of labels (the compose files
# don't carry hermes-smoke labels on their resources).
for c in $(docker ps -aq --filter "name=hermes-smoke-"); do
docker rm -f "$c" || true
done
for v in $(docker volume ls -q | grep "^hermes-smoke-" || true); do
docker volume rm -f "$v" || true
done
for n in $(docker network ls --format '{{.Name}}' | grep "^hermes-smoke-" || true); do
docker network rm "$n" || true
done
- name: Prepare ephemeral host paths
id: paths
run: |
set -euo pipefail
STATE_DIR="$(mktemp -d -t hermes-smoke-state-XXXXXX)"
WORK_DIR="$(mktemp -d -t hermes-smoke-work-XXXXXX)"
echo "state_dir=$STATE_DIR" >> "$GITHUB_OUTPUT"
echo "work_dir=$WORK_DIR" >> "$GITHUB_OUTPUT"
echo "Allocated:"
echo " HERMES_HOME = $STATE_DIR"
echo " HERMES_WORKSPACE = $WORK_DIR"
- name: Smoke (up + health + log scan + down)
env:
COMPOSE_FILE: ${{ steps.vars.outputs.compose_file }}
PROJECT: ${{ steps.vars.outputs.project }}
HERMES_HOME: ${{ steps.paths.outputs.state_dir }}
HERMES_WORKSPACE: ${{ steps.paths.outputs.work_dir }}
run: |
set -euo pipefail
# ----- Trap-guaranteed cleanup, regardless of exit reason -----
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 || true
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans || true
rm -rf "$HERMES_HOME" "$HERMES_WORKSPACE" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
echo "::group::docker compose up"
# --wait blocks until all services report healthy OR --wait-timeout fires.
# Compose v2 returns nonzero on either failure mode.
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait --wait-timeout 120
echo "::endgroup::"
echo "::group::container roster"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" ps
echo "::endgroup::"
# ----- WebUI /health probe -----
# Single-container: WebUI is on the host on 127.0.0.1:8787.
# Two/three-container: same — both compose files publish 127.0.0.1:8787.
echo "::group::Probe /health"
attempts=0
max_attempts=30
until curl --fail --silent --max-time 5 http://127.0.0.1:8787/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~60s)"
exit 1
fi
sleep 2
done
echo "✅ /health = 200 after $attempts attempts"
echo "::endgroup::"
# ----- Startup log scan: must not contain any known-bad signatures -----
# These are the exact patterns that would have flagged #2470 in real time.
# The grep -i is anchored to actual error tokens; benign log lines that
# contain the substring 'error' in a stack-friendly context (e.g.
# "errorless", URL paths) are improbable for these specific tokens.
echo "::group::Startup log scan"
LOGS="$(docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color)"
# `!! ERROR` + `!! Exiting script` are the actual strings emitted by
# docker_init.bash's error_exit() helper — the function name itself
# never appears in output. The literal token `error_exit` is kept as
# a belt-and-suspenders catch for any stray debug/echo of the name.
BAD_PATTERNS='EROFS|Read-only file system|Traceback|PermissionError|!! ERROR|!! Exiting script|error_exit|groupmod: cannot|usermod: cannot|Failed to set (UID|GID|owner|permissions|ownership)'
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
echo "❌ Startup logs contain known-bad pattern (see above)"
exit 1
fi
echo "✅ No known-bad patterns in startup logs"
echo "::endgroup::"