commit
c1671d1e5e
4 files changed
+349
-1
No files matched your search
@@ -0,0 +1,208 @@
|
||||
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
|
||||
|
||||
smoke:
|
||||
name: Smoke ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: compose-config
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- single
|
||||
- two-container
|
||||
- three-container
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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: Build local Dockerfile
|
||||
# We always build the local Dockerfile so the PR's changes are tested,
|
||||
# even on the multi-container variants whose compose files reference
|
||||
# ghcr.io/nesquena/hermes-webui:latest. Without this retag, multi-container
|
||||
# smoke runs would test the previous release, not the PR.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t ghcr.io/nesquena/hermes-webui:latest .
|
||||
|
||||
- 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::"
|
||||
@@ -6,6 +6,13 @@
|
||||
|
||||
- **PR #2483** by @franksong2702 (refs #2364) — Add a narrow README note for the community ARM64 Android AVF field report: Hermes Agent + WebUI running inside a Debian 12 VM on a mid-range Android phone with cloud-hosted inference. The note frames the report as a compatibility signal rather than an official support baseline or provider/model benchmark, and records practical mobile caveats around first-install compile time, Android tab reloads, and battery optimization.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR TBD** — Multi-container Docker startup is no longer broken by the v0.51.84 `:ro` mount on `hermes-agent-src`. `docker_init.bash` was calling `uv pip install "$_agent_src[all]"` against the mounted source tree directly. setuptools' `egg_info` build step touches `hermes_agent.egg-info/` inside the source tree even under PEP 517 build isolation, which `EROFS`-failed on the now-read-only mount and (under `set -e`) killed startup of every multi-container deploy. The init script now stages the agent source into `/tmp/hermes-agent-build` via `rsync` (with a `cp -a` fallback for images without rsync, both excluding any pre-baked `*.egg-info`, `build`, `dist`, and `__pycache__` artifacts) and runs the install against that writable copy, leaving the underlying `:ro` mount untouched. Stage dir is removed after the install completes. This regression was caught by the new Docker runtime smoke gate (below) on its very first CI run against its own PR — 5800+ source-level pytests + the independent reviewer's eyeball had all missed it on PR #2470.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **PR TBD** — Add a Docker runtime smoke gate (`.github/workflows/docker-smoke.yml`) triggered on PRs and pushes to `master` that modify `Dockerfile`, `docker_init.bash`, `docker-compose*.yml`, `.dockerignore`, or `.env.docker.example`. Validates every compose file parses (`docker compose config`), then matrix-runs the single, two-container, and three-container variants end-to-end: rebuilds the local `Dockerfile` and re-tags it as `ghcr.io/nesquena/hermes-webui:latest` so the multi-container variants exercise PR-level changes rather than the previously-released registry image, `docker compose up -d --wait`s with a 120s health window, probes `/health`, and greps startup logs for known-bad signatures (`EROFS`, `Traceback`, `PermissionError`, `error_exit`, `groupmod: cannot`, `usermod: cannot`, `Failed to set`). Closes the source-only-test gap that let v0.51.84's `:ro`-mount × `chown -h ... {} +` startup regression reach review with 5800+ green pytests. Workflow runs with `permissions: contents: read`, uses per-run project names and a pre-flight orphan reaper for safe concurrency, and unconditionally tears down all volumes/networks in an `EXIT` trap. Two new source-level invariants in `tests/test_docker_docs_and_readonly.py` pin the staging path so the underlying `:ro`-incompatible call doesn't regress.
|
||||
|
||||
## [v0.51.86] — 2026-05-17 — Release BJ (stage-379 — 4-PR review-bypass batch — memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord)
|
||||
|
||||
|
||||
+34
-1
@@ -391,7 +391,40 @@ else
|
||||
fi
|
||||
done
|
||||
if [ -n "$_agent_src" ]; then
|
||||
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
|
||||
# The agent source can be mounted read-only (see docker-compose.two-container.yml
|
||||
# / docker-compose.three-container.yml — the WebUI only reads this volume to
|
||||
# install the agent's Python dependencies and never writes to it). setuptools'
|
||||
# `egg_info` build step, however, touches `hermes_agent.egg-info/` inside the
|
||||
# source tree even under PEP 517 build isolation, which `EROFS`-fails on a
|
||||
# `:ro` mount and (under `set -e`) kills startup of every multi-container
|
||||
# deploy. Stage the source into a writable tmpfs copy so the build can write
|
||||
# its metadata side-by-side without touching the underlying mount.
|
||||
#
|
||||
# The copy excludes any pre-baked `*.egg-info` / `build` / `dist` artifacts
|
||||
# to avoid the timestamp-update path setuptools takes when one is present,
|
||||
# and `--reflink=auto` makes the copy near-free on overlay2/btrfs where
|
||||
# supported. We rebuild on every container start (the agent source can
|
||||
# change across volume re-init); cost is one rsync of ~10MB of Python source.
|
||||
_stage_src="/tmp/hermes-agent-build"
|
||||
rm -rf "$_stage_src"
|
||||
mkdir -p "$_stage_src"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a \
|
||||
--exclude='*.egg-info' --exclude='build' --exclude='dist' \
|
||||
--exclude='__pycache__' --exclude='.git' \
|
||||
"$_agent_src"/ "$_stage_src"/ \
|
||||
|| error_exit "Failed to stage hermes-agent source to writable build dir"
|
||||
else
|
||||
# Fallback when rsync isn't in the image — straight cp -a, then drop
|
||||
# the build artifacts that would trip setuptools.
|
||||
cp -a "$_agent_src"/. "$_stage_src"/ \
|
||||
|| error_exit "Failed to copy hermes-agent source to writable build dir"
|
||||
rm -rf "$_stage_src"/*.egg-info "$_stage_src"/build "$_stage_src"/dist 2>/dev/null || true
|
||||
find "$_stage_src" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
uv pip install "$_stage_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org \
|
||||
|| error_exit "Failed to install hermes-agent's requirements"
|
||||
rm -rf "$_stage_src"
|
||||
else
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source not found."
|
||||
|
||||
@@ -152,3 +152,103 @@ def test_docker_md_documents_isolation_model():
|
||||
"isolation expectations — process/network/resource isolation, NOT "
|
||||
"filesystem isolation."
|
||||
)
|
||||
|
||||
|
||||
# ── 5: docker_init.bash stages agent source to a writable build dir ─────────
|
||||
#
|
||||
# The :ro mount fixed in PR #2470 broke a second, less obvious surface:
|
||||
# `uv pip install "$_agent_src[all]"` invokes setuptools' egg_info build step,
|
||||
# which touches `hermes_agent.egg-info/` *inside the source tree* even under
|
||||
# PEP 517 build isolation. On a `:ro` mount this returns `EROFS` and (under
|
||||
# `set -e`) kills container startup. The fix: copy the source tree into a
|
||||
# writable tmpfs build dir, run the install against THAT, then clean up.
|
||||
#
|
||||
# This was caught the first time the Docker smoke gate ran on its own PR — a
|
||||
# real regression that 5800+ source-level pytests had no way to surface
|
||||
# because none of them invoked `docker_init.bash` against a real :ro mount.
|
||||
|
||||
|
||||
def test_docker_init_stages_agent_source_for_writable_install():
|
||||
"""docker_init.bash must NOT pass the raw _agent_src path to `uv pip
|
||||
install` — that hits the :ro mount and fails. It must stage the source
|
||||
into a writable build dir first (the staged path is used in the install
|
||||
invocation)."""
|
||||
src = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
# The fix uses a /tmp staging path that's clearly distinct from the
|
||||
# mounted source path. Pin the staging marker.
|
||||
assert "_stage_src=" in src, (
|
||||
"docker_init.bash must declare a _stage_src writable build dir "
|
||||
"before invoking `uv pip install` against the (potentially :ro) "
|
||||
"hermes-agent source."
|
||||
)
|
||||
|
||||
# The install line must reference the staged path, NOT the raw _agent_src
|
||||
# path. The pre-fix code was:
|
||||
# uv pip install "$_agent_src[all]" ...
|
||||
# The fixed code is:
|
||||
# uv pip install "$_stage_src[all]" ...
|
||||
install_lines = [
|
||||
line for line in src.splitlines()
|
||||
if "uv pip install" in line and "[all]" in line
|
||||
]
|
||||
assert install_lines, "expected an `uv pip install ...[all]` line in docker_init.bash"
|
||||
for line in install_lines:
|
||||
assert '"$_agent_src[all]"' not in line, (
|
||||
"docker_init.bash invokes `uv pip install $_agent_src[all]` "
|
||||
"directly — this fails with EROFS when the hermes-agent volume "
|
||||
"is mounted :ro (the production multi-container default). "
|
||||
"Use the writable $_stage_src path instead. "
|
||||
f"Offending line: {line!r}"
|
||||
)
|
||||
assert "_stage_src" in line, (
|
||||
"the `uv pip install ...[all]` line must use the staged writable "
|
||||
f"path. Offending line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_docker_init_excludes_egg_info_during_staging():
|
||||
"""The staging copy must exclude pre-baked *.egg-info / build / dist
|
||||
directories. setuptools takes a different (timestamp-update) code path
|
||||
when one is already present in the source tree, which itself hits the
|
||||
:ro mount through stat/utime calls. Excluding them keeps the build
|
||||
happily on the fresh-build code path.
|
||||
|
||||
Tight assertions on both the rsync and cp-fallback paths — a loose
|
||||
`"egg-info" in src` check would pass on a stray comment mention, so
|
||||
we require the actual exclusion mechanics to be present.
|
||||
"""
|
||||
src = (REPO / "docker_init.bash").read_text(encoding="utf-8")
|
||||
|
||||
# Find the staging block: rsync invocation OR cp-fallback. Both must
|
||||
# actually exclude *.egg-info — a comment mention is not enough.
|
||||
stage_idx = src.index("_stage_src=")
|
||||
install_idx = src.index("uv pip install", stage_idx)
|
||||
stage_block = src[stage_idx:install_idx]
|
||||
|
||||
# Rsync path must carry --exclude='*.egg-info'.
|
||||
assert "--exclude='*.egg-info'" in stage_block, (
|
||||
"docker_init.bash rsync invocation must include "
|
||||
"--exclude='*.egg-info' so setuptools' timestamp-update code path "
|
||||
"doesn't fire (which itself hits the :ro mount through stat/utime)."
|
||||
)
|
||||
|
||||
# cp-fallback path must explicitly rm the egg-info dir after copy
|
||||
# (cp -a has no --exclude flag, so the cleanup happens post-copy).
|
||||
assert "*.egg-info" in stage_block, (
|
||||
"docker_init.bash cp-fallback must remove $_stage_src/*.egg-info "
|
||||
"after copy so the install runs on the fresh-build code path."
|
||||
)
|
||||
|
||||
# Both build and dist must also be excluded — setuptools touches them
|
||||
# under different conditions but the failure mode is identical.
|
||||
assert "--exclude='build'" in stage_block, (
|
||||
"rsync staging must --exclude='build' (setuptools build artifacts)."
|
||||
)
|
||||
assert "--exclude='dist'" in stage_block, (
|
||||
"rsync staging must --exclude='dist' (setuptools build artifacts)."
|
||||
)
|
||||
assert "--exclude='__pycache__'" in stage_block, (
|
||||
"rsync staging must --exclude='__pycache__' to keep the copy minimal."
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user