mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(docker): supervise profile gateways outside the root s6 scandir
The image's root s6 scandir (/run/service) is watched by the PID-1 s6-svscan, so every slot in it runs under a root s6-supervise that executes the slot's run/finish/log scripts as root. 02-reconcile-profiles chowned that directory to hermes so per-profile gateway slots could be registered at runtime, and 015-supervise-perms chowned the static services' supervise/ and event/ trees to hermes. Both handed the unprivileged hermes user control over what the root supervisor runs and writes. - Per-profile gateway slots move to /run/hermes-service, watched by a nested s6-svscan that runs as hermes (new s6-rc longrun profile-gateways). Gateways, their loggers and their supervisors now all run as hermes; the rendered run/log scripts already skip the privilege drop when non-root. - 02-reconcile-profiles creates that scandir hermes-owned (refusing a symlink) and no longer touches /run/service. - 015-supervise-perms keeps the static services' supervise/ trees root-owned and only grants group hermes read on status/lock and write on the control FIFO, leaving event/ as s6 creates it. - hermes doctor probes the static services in the root scandir via a new s6_service_up() helper; S6_DYNAMIC_SCANDIR now points at the gateway scandir and S6_STATIC_SCANDIR names the root one. - Docker integration tests, docs and the s6 skill follow the new gateway slot path.
This commit is contained in:
1 parent
1fcc57e6d2
commit
c08c485305
27 files changed
+380
-149
No files matched your search
+7
-5
@@ -447,10 +447,12 @@ RUN set -eu; \
|
||||
python3 -c 'import json, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; stamp = json.loads(pathlib.Path("/opt/hermes/install-stamp.json").read_text(encoding="utf-8")); commit = stamp.get("commit"); revision = commit if commit and set(commit) != {"0"} else None; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": revision}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)'
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
# Static services declared at build time: main-hermes + dashboard.
|
||||
# Per-profile gateway services are registered dynamically at runtime by
|
||||
# the profile create/delete hooks (Phase 4); they live under
|
||||
# /run/service/ (tmpfs) and are reconciled on container restart by
|
||||
# Static services declared at build time: main-hermes, dashboard and
|
||||
# profile-gateways. Per-profile gateway services are registered
|
||||
# dynamically at runtime by the profile create/delete hooks (Phase 4);
|
||||
# they live under /run/hermes-service/ (tmpfs), supervised by the
|
||||
# hermes-owned s6-svscan of the profile-gateways service, never in the
|
||||
# root /run/service scandir, and are reconciled on container restart by
|
||||
# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
|
||||
COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
|
||||
|
||||
@@ -461,7 +463,7 @@ COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
|
||||
#
|
||||
# 02-reconcile-profiles re-creates per-profile gateway s6 service
|
||||
# slots from $HERMES_HOME/profiles/<name>/ after a container restart
|
||||
# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
|
||||
# (the /run/hermes-service/ scandir is tmpfs and wiped on restart). Phase 4.
|
||||
RUN mkdir -p /etc/cont-init.d && \
|
||||
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
||||
> /etc/cont-init.d/01-hermes-setup && \
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Make supervise/ trees for ALL declared s6 services queryable and
|
||||
# controllable by the unprivileged hermes user (UID 10000).
|
||||
# Let the unprivileged hermes group query and control the STATIC s6-rc
|
||||
# services (dashboard, main-hermes, profile-gateways) without handing
|
||||
# it any part of the root supervision tree.
|
||||
#
|
||||
# Background (PR #30136 review item I4): the entire s6 lifecycle
|
||||
# (s6-svc, s6-svstat, s6-svwait) is dispatched as the hermes user
|
||||
# inside the container (every Hermes runtime path runs under
|
||||
# ``s6-setuidgid hermes``). But s6-supervise creates each service's
|
||||
# ``supervise/`` and top-level ``event/`` directory with mode 0700
|
||||
# owned by its effective UID — which is root, because s6-supervise
|
||||
# is spawned by s6-svscan running as PID 1. So unprivileged clients
|
||||
# get EACCES on every probe / control call against the slot.
|
||||
# s6-supervise creates each service's ``supervise/`` 0700 and its
|
||||
# control FIFO 0600, owned by its effective UID — root, because it is
|
||||
# spawned by the root s6-svscan (PID 1). Hermes runtime paths
|
||||
# (``hermes doctor`` etc.) run as hermes and need ``s6-svstat`` and
|
||||
# ``s6-svc`` against these slots, i.e. group search on ``supervise/``,
|
||||
# group read on ``supervise/status`` + ``supervise/lock`` and group
|
||||
# write on ``supervise/control``.
|
||||
#
|
||||
# Two fixes, one in each registration path:
|
||||
# Ownership stays root: root s6-supervise keeps writing ``status``
|
||||
# (via ``status.new``), ``death_tally`` and the event FIFOs, so a
|
||||
# hermes-owned or hermes-writable directory there would let the hermes
|
||||
# user redirect those root writes. The service-root ``event/`` fifodir
|
||||
# is left as s6 made it — nothing in Hermes subscribes to static
|
||||
# services' events.
|
||||
#
|
||||
# 1. For RUNTIME-registered profile gateways (created via the s6
|
||||
# runtime register hooks in profiles.py): the Python helper
|
||||
# ``_seed_supervise_skeleton`` pre-creates supervise/ + event/ +
|
||||
# supervise/control owned by hermes BEFORE s6-svscanctl -a fires.
|
||||
# s6-supervise's mkdir/mkfifo are EEXIST-safe, so it inherits our
|
||||
# ownership and never tries to chown back to root.
|
||||
#
|
||||
# 2. For STATIC s6-rc services (dashboard, main-hermes) declared at
|
||||
# image-build time under /etc/s6-overlay/s6-rc.d/*: these are
|
||||
# compiled by s6-rc at boot, and s6-supervise spawns BEFORE
|
||||
# cont-init.d gets to run — so by the time we're here, the
|
||||
# supervise/ tree is already there as root:root 0700. We chown
|
||||
# it here. s6-supervise will keep using the same files; it never
|
||||
# re-asserts ownership on a running service.
|
||||
# Per-profile gateway slots are not affected: they live in their own
|
||||
# scandir under an s6-svscan that runs as hermes (see
|
||||
# 02-reconcile-profiles and the profile-gateways service).
|
||||
#
|
||||
# This script runs as root after 01-hermes-setup but before
|
||||
# 02-reconcile-profiles, so the chowns are settled before the
|
||||
# Python reconciler walks the scandir. Lexicographic ordering
|
||||
# guarantees this — the suffix is unusual because we want to slot
|
||||
# in between 01 and the existing 02-reconcile-profiles without
|
||||
# renumbering both (which would be a churn-noise patch on its own).
|
||||
# 02-reconcile-profiles. s6-rc static services' supervisors are
|
||||
# already running by now, so their supervise/ trees exist.
|
||||
|
||||
set -eu
|
||||
|
||||
# /run/s6-rc/servicedirs holds the live, compiled service directories
|
||||
# for every static (s6-rc) service. Symlinks under /run/service/*
|
||||
# point here. Per-service supervise/ + event/ both need hermes
|
||||
# ownership for s6-svstat etc. to work as hermes.
|
||||
# point here.
|
||||
SVC_ROOT=/run/s6-rc/servicedirs
|
||||
|
||||
if [ ! -d "$SVC_ROOT" ]; then
|
||||
@@ -53,38 +43,33 @@ for svc in "$SVC_ROOT"/*; do
|
||||
[ -d "$svc" ] || continue
|
||||
name=$(basename "$svc")
|
||||
|
||||
# Skip s6-overlay-internal services (they need to stay root-only;
|
||||
# the s6rc-* helpers manage the supervision tree itself).
|
||||
# Skip s6-overlay-internal services (they stay root-only; the
|
||||
# s6rc-* helpers manage the supervision tree itself).
|
||||
case "$name" in
|
||||
s6rc-*|s6-linux-*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# supervise/ tree — needed by s6-svstat / s6-svc.
|
||||
if [ -d "$svc/supervise" ]; then
|
||||
chown -R hermes:hermes "$svc/supervise" 2>/dev/null || \
|
||||
echo "[supervise-perms] could not chown $svc/supervise"
|
||||
# 0710 = group searchable. ``s6-svstat`` only needs to openat
|
||||
# status, not list the dir, but giving the hermes group +x is
|
||||
# the minimum that lets group members access the contents.
|
||||
chmod 0710 "$svc/supervise" 2>/dev/null || true
|
||||
# supervise/control is a FIFO that s6-svc writes commands
|
||||
# into; the hermes user needs +w. Owner is already hermes
|
||||
# after the recursive chown above; widen perms to 0660 so
|
||||
# ``s6-svc`` works for any member of the hermes group too.
|
||||
if [ -p "$svc/supervise/control" ]; then
|
||||
chmod 0660 "$svc/supervise/control" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
sup="$svc/supervise"
|
||||
[ -d "$sup" ] && [ ! -L "$sup" ] || continue
|
||||
|
||||
# Top-level event/ dir — s6-svlisten1 / s6-svwait subscribe here.
|
||||
if [ -d "$svc/event" ]; then
|
||||
chown hermes:hermes "$svc/event" 2>/dev/null || \
|
||||
echo "[supervise-perms] could not chown $svc/event"
|
||||
# Preserve s6's 03730 mode (setgid + g+rwx + sticky).
|
||||
chmod 03730 "$svc/event" 2>/dev/null || true
|
||||
chown root:hermes "$sup" 2>/dev/null || \
|
||||
echo "[supervise-perms] could not chgrp $sup"
|
||||
chmod 0750 "$sup" 2>/dev/null || true
|
||||
|
||||
# s6-svc writes commands into the control FIFO.
|
||||
if [ -p "$sup/control" ]; then
|
||||
chown root:hermes "$sup/control" 2>/dev/null || true
|
||||
chmod 0660 "$sup/control" 2>/dev/null || true
|
||||
fi
|
||||
# s6-svstat reads status and probes lock.
|
||||
for f in lock status; do
|
||||
if [ -f "$sup/$f" ] && [ ! -L "$sup/$f" ]; then
|
||||
chown root:hermes "$sup/$f" 2>/dev/null || true
|
||||
chmod 0640 "$sup/$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "[supervise-perms] chowned supervise/ trees for static s6-rc services"
|
||||
echo "[supervise-perms] static s6-rc services: supervise/ readable/controllable by group hermes"
|
||||
@@ -7,40 +7,30 @@
|
||||
# services. /etc/cont-init.d/* scripts run in lexicographic order,
|
||||
# so the `02-` prefix guarantees ordering.
|
||||
#
|
||||
# Service directories under /run/service/ live on tmpfs and are
|
||||
# wiped on every container restart. Profile directories under
|
||||
# $HERMES_HOME/profiles/ live on the persistent VOLUME. This script
|
||||
# walks the persistent profiles, recreates the s6 service slots,
|
||||
# and auto-starts only those whose last recorded state was
|
||||
# `running` — see hermes_cli/container_boot.py.
|
||||
# Service directories live on tmpfs and are wiped on every container
|
||||
# restart. Profile directories under $HERMES_HOME/profiles/ live on
|
||||
# the persistent VOLUME. This script walks the persistent profiles,
|
||||
# recreates the s6 service slots, and auto-starts only those whose
|
||||
# last recorded state was `running` — see hermes_cli/container_boot.py.
|
||||
#
|
||||
# Phase 4 also needs hermes-user writes to /run/service/ (so the
|
||||
# profile create/delete hooks can register/unregister at runtime),
|
||||
# so we chown the scandir before invoking the reconciler. We
|
||||
# additionally chown the s6-svscan control FIFO so the hermes user
|
||||
# can send rescan signals via ``s6-svscanctl -a``; without this the
|
||||
# entire runtime-registration path is inert under UID 10000 (the
|
||||
# Python wrapper catches the resulting EACCES, prints a warning,
|
||||
# and swallows the failure).
|
||||
# Per-profile gateway slots are registered at runtime by the hermes
|
||||
# user (profile create/delete hooks, `hermes gateway start`), so they
|
||||
# live in their own scandir, $GATEWAY_SCANDIR, watched by an s6-svscan
|
||||
# that itself runs as hermes (the `profile-gateways` s6-rc service).
|
||||
# The root scandir /run/service and its .s6-svscan control files stay
|
||||
# root-owned: every slot in it is supervised by a root s6-supervise,
|
||||
# which executes the slot's run/finish/log scripts as root.
|
||||
set -e
|
||||
|
||||
# Make the dynamic scandir hermes-writable. The directory itself
|
||||
# starts root-owned by s6-overlay.
|
||||
chown hermes:hermes /run/service 2>/dev/null || true
|
||||
GATEWAY_SCANDIR=/run/hermes-service
|
||||
|
||||
# Make the svscan control FIFO hermes-writable so s6-svscanctl -a
|
||||
# / -an work for the hermes user. The FIFO is created by s6-svscan
|
||||
# at PID-1 startup, so by the time this cont-init.d script runs it
|
||||
# already exists. Both ``control`` and ``lock`` need to be writable
|
||||
# for the various svscanctl operations; the directory itself stays
|
||||
# root-owned (we only need to touch the two FIFOs/locks inside).
|
||||
if [ -d /run/service/.s6-svscan ]; then
|
||||
for entry in control lock; do
|
||||
if [ -e "/run/service/.s6-svscan/$entry" ]; then
|
||||
chown hermes:hermes "/run/service/.s6-svscan/$entry" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ -L "$GATEWAY_SCANDIR" ]; then
|
||||
echo "[reconcile] refusing symlinked $GATEWAY_SCANDIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$GATEWAY_SCANDIR"
|
||||
chown hermes:hermes "$GATEWAY_SCANDIR"
|
||||
chmod 0755 "$GATEWAY_SCANDIR"
|
||||
|
||||
# Skip the drop when already non-root.
|
||||
[ "$(id -u)" = 0 ] || exec /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
# For now this service is a no-op: it sleeps forever, doing nothing.
|
||||
# The dashboard runs as a real s6 service alongside it (see
|
||||
# ../dashboard/run) and per-profile gateways register dynamically via
|
||||
# /run/service/ at runtime (Phase 4).
|
||||
# /run/hermes-service/ at runtime (Phase 4; see ../profile-gateways/run).
|
||||
exec sleep infinity
|
||||
Whitespace-only changes.
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Unprivileged supervision tree for per-profile gateways.
|
||||
#
|
||||
# Gateway slots are created at runtime by the hermes user (profile
|
||||
# create/delete hooks, `hermes gateway start`, boot reconciliation), so
|
||||
# their run/finish/log scripts are hermes-writable. They therefore must
|
||||
# not sit in the root scandir (/run/service), where a root s6-supervise
|
||||
# would execute them. Instead this service runs a nested s6-svscan AS
|
||||
# HERMES over its own scandir; every s6-supervise it spawns, and every
|
||||
# gateway it supervises, runs as hermes.
|
||||
#
|
||||
# The scandir is created hermes-owned by cont-init.d/02-reconcile-profiles,
|
||||
# which also publishes the slots restored from the data volume, before
|
||||
# s6-rc starts this service. Keep the path in sync with
|
||||
# hermes_cli/service_manager.py::S6_DYNAMIC_SCANDIR.
|
||||
|
||||
scandir=/run/hermes-service
|
||||
|
||||
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID).
|
||||
[ "$(id -u)" = 0 ] || exec s6-svscan "$scandir"
|
||||
exec s6-setuidgid hermes s6-svscan "$scandir"
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
Whitespace-only changes.
@@ -94,7 +94,7 @@ def reconcile_profile_gateways(
|
||||
so it is what ``hermes gateway start`` (no ``-p``) targets.
|
||||
|
||||
Without it, bare ``hermes gateway start`` inside the container would land on ``s6-svc -u
|
||||
/run/service/gateway-default`` → uncaught ``CalledProcessError`` → traceback to the user (PR #30136
|
||||
/run/hermes-service/gateway-default`` → uncaught ``CalledProcessError`` → traceback to the user (PR #30136
|
||||
review).
|
||||
"""
|
||||
actions: list[ReconcileAction] = []
|
||||
@@ -361,8 +361,10 @@ def main() -> int:
|
||||
print("reconcile: skipping (dashboard container — does not need per-profile gateways)")
|
||||
return 0
|
||||
|
||||
from hermes_cli.service_manager import S6_DYNAMIC_SCANDIR
|
||||
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", str(S6_DYNAMIC_SCANDIR)))
|
||||
actions = reconcile_profile_gateways(hermes_home=hermes_home, scandir=scandir)
|
||||
folded = [a.profile for a in actions if a.profile != "default" and a.folded_into_root]
|
||||
if folded:
|
||||
|
||||
@@ -184,15 +184,16 @@ def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Under our s6 /init, report static services and the ONE host gateway slot; no-op elsewhere.
|
||||
Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host)."""
|
||||
try:
|
||||
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
|
||||
from hermes_cli.service_manager import (
|
||||
S6_STATIC_SCANDIR, S6ServiceManager, detect_service_manager, s6_service_up)
|
||||
except Exception:
|
||||
return
|
||||
if detect_service_manager() != "s6":
|
||||
return
|
||||
_section("s6 Supervision")
|
||||
mgr = S6ServiceManager()
|
||||
for static in ("main-hermes", "dashboard"): # s6-rc symlinks under /run/service/, same s6-svstat probe
|
||||
up = mgr.is_running(static)
|
||||
for static in ("main-hermes", "dashboard"): # s6-rc symlinks in the root scandir, not the gateway one
|
||||
up = s6_service_up(S6_STATIC_SCANDIR / static)
|
||||
(check_ok if up else check_info)(f"{static}: up" if up else f"{static}: down (expected if not enabled via env)")
|
||||
_report_host_gateway_slot(mgr, issues)
|
||||
|
||||
|
||||
@@ -4844,7 +4844,7 @@ def _dispatch_via_service_manager_if_s6(action: str, profile: str | None = None)
|
||||
getattr(mgr, action)(service)
|
||||
except GatewayNotRegisteredError:
|
||||
# A profile created from the HOST against a bind-mounted home has a directory but no
|
||||
# slot (`profile create` cannot reach the container's /run/service). Only `start`
|
||||
# slot (`profile create` cannot reach the container's /run/hermes-service). Only `start`
|
||||
# repairs that; stop/restart on a missing slot stay an error.
|
||||
if action != "start" or not register_unregistered_profile_gateway(mgr, profile):
|
||||
raise
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""s6 leg of the one-gateway-per-host convergence, shared by container boot and in-process migration.
|
||||
|
||||
Inside the official image every profile has an s6 slot (``/run/service/gateway-<profile>``). The
|
||||
Inside the official image every profile has an s6 slot (``/run/hermes-service/gateway-<profile>``). The
|
||||
container's boot (``hermes_cli/container_boot.py``) registers every NAMED slot down and lets the
|
||||
root slot inherit their autostart intent; ``hermes gateway migrate --multiplex`` (and the hook
|
||||
``hermes update`` runs) must be able to do the same thing to a slot that is UP, from inside the
|
||||
|
||||
@@ -222,9 +222,12 @@ def get_service_manager() -> ServiceManager:
|
||||
# /etc/s6-overlay/s6-rc.d/ as part of the image and are NOT managed here.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# s6-overlay's dynamic scandir (tmpfs) that s6-svscan watches; writes here trigger supervision
|
||||
# on the next rescan.
|
||||
S6_DYNAMIC_SCANDIR = Path("/run/service")
|
||||
# s6-overlay's root scandir, watched by the PID-1 s6-svscan: every slot in it runs under a root
|
||||
# s6-supervise, so it stays root-owned and holds only the image's static s6-rc services.
|
||||
S6_STATIC_SCANDIR = Path("/run/service")
|
||||
# Per-profile gateway scandir (tmpfs), watched by a nested s6-svscan running as hermes (the
|
||||
# ``profile-gateways`` s6-rc service); writes here trigger supervision on the next rescan.
|
||||
S6_DYNAMIC_SCANDIR = Path("/run/hermes-service")
|
||||
S6_SERVICE_PREFIX = "gateway-"
|
||||
|
||||
|
||||
@@ -277,7 +280,7 @@ def register_unregistered_profile_gateway(mgr: ServiceManager, profile: str) ->
|
||||
|
||||
`hermes profile create` can only register a slot when it runs inside the container; created
|
||||
from the host against a bind-mounted home, the directory lands where the container reads it
|
||||
but no ``/run/service/gateway-<name>`` exists, and the boot reconciler only notices on the
|
||||
but no ``/run/hermes-service/gateway-<name>`` exists, and the boot reconciler only notices on the
|
||||
next container restart. Returns False without touching anything unless the directory carries
|
||||
``SOUL.md`` — the reconciler's own "real profile" marker — so a mistyped ``-p`` name cannot
|
||||
mint a phantom slot. ``start_now=False``: the caller's ordinary ``start`` stays the single
|
||||
@@ -306,8 +309,18 @@ def _s6_run(cmd: str, *args: str, timeout: float = 5, check: bool = False):
|
||||
)
|
||||
|
||||
|
||||
def s6_service_up(service_dir: Path) -> bool:
|
||||
"""``s6-svstat -o up`` for any s6 service directory; False on any failure."""
|
||||
try:
|
||||
result = _s6_run("s6-svstat", "-o", "up", str(service_dir))
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return result.returncode == 0 and result.stdout.split() == ["true"]
|
||||
|
||||
|
||||
# UID/GID of the in-image ``hermes`` user; hardcoded to match what ``stage2-hook.sh`` enforces
|
||||
# (tests/docker/test_uid_remap.py). s6-supervise starts as root and drops via ``s6-setuidgid``.
|
||||
# (tests/docker/test_uid_remap.py). Only matters for root callers; the gateway scandir's
|
||||
# s6-supervise already runs as hermes.
|
||||
_HERMES_UID = 10000
|
||||
_HERMES_GID = 10000
|
||||
|
||||
|
||||
@@ -37,16 +37,17 @@ If you're just running the Hermes Agent and want to use Docker, see `website/doc
|
||||
│ │ ├── seed .env / config.yaml / SOUL.md
|
||||
│ │ └── skills_sync.py
|
||||
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
|
||||
│ ├── chown /run/service (hermes-writable for runtime register)
|
||||
│ ├── mkdir+chown /run/hermes-service (hermes-owned gateway scandir; /run/service stays root)
|
||||
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
|
||||
│ → recreate /run/service/gateway-<name>/
|
||||
│ → recreate /run/hermes-service/gateway-<name>/
|
||||
│ → auto-start only those with prior_state == "running"
|
||||
│
|
||||
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
|
||||
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
|
||||
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
|
||||
│ ├── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
|
||||
│ └── profile-gateways/run ← `s6-setuidgid hermes s6-svscan /run/hermes-service`
|
||||
│
|
||||
├── /run/service (s6-svscan watches; tmpfs)
|
||||
├── /run/hermes-service (hermes s6-svscan from s6-rc `profile-gateways` watches; tmpfs)
|
||||
│ ├── gateway-coder/ ← runtime-registered per-profile
|
||||
│ │ ├── type ("longrun")
|
||||
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
|
||||
@@ -84,7 +85,7 @@ The original plan (v1–v3) called for main hermes to run as a supervised s6-rc
|
||||
|
||||
So we use the s6-overlay-native CMD pattern via the dispatcher: `ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"]`, which under PID 1 exec's `/init /opt/hermes/docker/main-wrapper.sh "$@"`. The wrapper is prepended to user args automatically — so `docker run <image> --version` becomes `/init main-wrapper.sh --version`, and `--version` doesn't get intercepted by /init's POSIX shell. The wrapper drops to hermes via `s6-setuidgid`, then exec's the chosen program. The program's exit code becomes the container exit code, exactly matching the pre-s6 tini contract. When the entrypoint is NOT PID 1 (Fly Machines, `docker run --init`), the dispatcher skips `/init` entirely (it would abort with `can only run as pid 1`), restores the s6 helper PATH, runs stage2-hook.sh, and exec's main-wrapper.sh directly — no supervised services on that path (#38349).
|
||||
|
||||
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/service/` get full supervision.
|
||||
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/hermes-service/` get full supervision.
|
||||
|
||||
## Quick recipes
|
||||
|
||||
@@ -99,7 +100,7 @@ docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
|
||||
|
||||
```sh
|
||||
# /command/ isn't on docker-exec PATH — use absolute path
|
||||
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
|
||||
docker exec <c> /command/s6-svstat /run/hermes-service/gateway-<name>
|
||||
# "up (pid …) … seconds" → running
|
||||
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
|
||||
# "down … normally up, ready …" → user stopped it
|
||||
@@ -108,9 +109,9 @@ docker exec <c> /command/s6-svstat /run/service/gateway-<name>
|
||||
### Bring a service up/down manually
|
||||
|
||||
```sh
|
||||
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
|
||||
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
|
||||
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
|
||||
docker exec <c> /command/s6-svc -u /run/hermes-service/gateway-<name> # up
|
||||
docker exec <c> /command/s6-svc -d /run/hermes-service/gateway-<name> # down
|
||||
docker exec <c> /command/s6-svc -t /run/hermes-service/gateway-<name> # SIGTERM (restart)
|
||||
```
|
||||
|
||||
### Watch the cont-init reconciler log
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Container-restart survives per-profile gateway registrations.
|
||||
|
||||
The s6 dynamic scandir at /run/service/ lives on tmpfs and is wiped
|
||||
The s6 gateway scandir at /run/hermes-service/ lives on tmpfs and is wiped
|
||||
on every container restart. Phase 4 Task 4.0's container_boot module
|
||||
+ cont-init.d/02-reconcile-profiles regenerate the service slots from
|
||||
$HERMES_HOME/profiles/<name>/gateway_state.json on every boot and
|
||||
@@ -96,11 +96,11 @@ def test_stopped_gateway_stays_stopped_after_restart(restart_container: str) ->
|
||||
|
||||
# Slot exists.
|
||||
assert wait_for_path(
|
||||
container, "/run/service/gateway-writer", kind="d", deadline_s=10.0,
|
||||
container, "/run/hermes-service/gateway-writer", kind="d", deadline_s=10.0,
|
||||
)
|
||||
|
||||
# Down marker present.
|
||||
r = docker_exec_sh(container, "test -f /run/service/gateway-writer/down")
|
||||
r = docker_exec_sh(container, "test -f /run/hermes-service/gateway-writer/down")
|
||||
assert r.returncode == 0, "down marker missing despite prior_state=stopped"
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from tests.docker.conftest import (
|
||||
|
||||
|
||||
def _svstat(container: str, slot: str = "gateway-default") -> str:
|
||||
r = docker_exec_sh(container, f"/command/s6-svstat /run/service/{slot}")
|
||||
r = docker_exec_sh(container, f"/command/s6-svstat /run/hermes-service/{slot}")
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def _svstat(container: str) -> str:
|
||||
"""Returns the raw s6-svstat output for the test profile's slot.
|
||||
/command/s6-svstat is called by absolute path because /command/
|
||||
isn't on PATH for docker-exec sessions."""
|
||||
r = _sh(container, f"/command/s6-svstat /run/service/gateway-{PROFILE}")
|
||||
r = _sh(container, f"/command/s6-svstat /run/hermes-service/gateway-{PROFILE}")
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_named_profile_gateway_start_refuses_without_force(
|
||||
assert r.returncode == 0, f"profile create failed: {r.stderr}"
|
||||
|
||||
# Profile create's s6-register hook still produces the slot (registered DOWN).
|
||||
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
|
||||
r = _sh(container_name, f"test -d /run/hermes-service/gateway-{PROFILE}")
|
||||
assert r.returncode == 0, "s6 service slot not created on profile create"
|
||||
|
||||
r = _sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
|
||||
|
||||
@@ -16,7 +16,7 @@ import time
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, start_container, wait_for_docker_logs
|
||||
|
||||
SLOT = "/run/service/gateway-default"
|
||||
SLOT = "/run/hermes-service/gateway-default"
|
||||
|
||||
|
||||
def _svstat_field(container: str, field: str) -> str:
|
||||
|
||||
@@ -13,9 +13,10 @@ hooks land.
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec` in conftest); see the conftest module
|
||||
docstring. ``/run/service`` is chowned hermes-writable by the
|
||||
``02-reconcile-profiles`` cont-init.d script, so register/unregister
|
||||
operations work correctly under UID 10000.
|
||||
docstring. The gateway scandir ``/run/hermes-service`` is created
|
||||
hermes-owned by the ``02-reconcile-profiles`` cont-init.d script and
|
||||
watched by a hermes s6-svscan, so register/unregister operations work
|
||||
under UID 10000 while the root ``/run/service`` stays root-owned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,7 +40,7 @@ def test_s6_register_creates_service_dir_in_live_container(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""S6ServiceManager.register_profile_gateway must create
|
||||
``/run/service/gateway-<profile>/`` and trigger s6-svscan rescan
|
||||
``/run/hermes-service/gateway-<profile>/`` and trigger s6-svscan rescan
|
||||
against the real s6 supervision tree."""
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
@@ -49,21 +50,21 @@ def test_s6_register_creates_service_dir_in_live_container(
|
||||
)
|
||||
|
||||
# Service directory exists with the expected structure.
|
||||
r = docker_exec(container_name, "test", "-d", "/run/service/gateway-phase3test")
|
||||
r = docker_exec(container_name, "test", "-d", "/run/hermes-service/gateway-phase3test")
|
||||
assert r.returncode == 0, "service directory not created"
|
||||
|
||||
r = docker_exec(container_name, "test", "-f", "/run/service/gateway-phase3test/run")
|
||||
r = docker_exec(container_name, "test", "-f", "/run/hermes-service/gateway-phase3test/run")
|
||||
assert r.returncode == 0, "run script not created"
|
||||
|
||||
r = docker_exec(container_name, "test", "-f",
|
||||
"/run/service/gateway-phase3test/log/run")
|
||||
"/run/hermes-service/gateway-phase3test/log/run")
|
||||
assert r.returncode == 0, "log/run script not created"
|
||||
|
||||
# s6-svscan picked it up — s6-svstat works against the dir.
|
||||
# `docker exec` doesn't put /command/ on PATH (only the supervision
|
||||
# tree does), so call s6-svstat by absolute path.
|
||||
r = docker_exec(container_name, "/command/s6-svstat",
|
||||
"/run/service/gateway-phase3test")
|
||||
"/run/hermes-service/gateway-phase3test")
|
||||
assert r.returncode == 0, f"s6-svstat failed: {r.stderr or r.stdout}"
|
||||
|
||||
# list_profile_gateways picks it up.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""``hermes doctor``'s s6 section probes the image's static s6-rc services in the root scandir and
|
||||
lists profile gateway slots from the separate, unprivileged gateway scandir."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import doctor_platform, service_manager
|
||||
|
||||
ROOT_SCANDIR = Path("/run/service")
|
||||
|
||||
|
||||
def test_static_services_and_gateway_slots_use_distinct_scandirs(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
svstat_targets: list[Path] = []
|
||||
listed: list[Path] = []
|
||||
|
||||
def _fake_s6_run(cmd: str, *args: str, **_kw):
|
||||
if cmd == "s6-svstat":
|
||||
svstat_targets.append(Path(args[-1]))
|
||||
return subprocess.CompletedProcess([cmd, *args], 0, "true\n", "")
|
||||
|
||||
def _list(self) -> list[str]:
|
||||
listed.append(self.scandir)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service_manager, "detect_service_manager", lambda: "s6")
|
||||
monkeypatch.setattr(service_manager, "_s6_run", _fake_s6_run)
|
||||
monkeypatch.setattr(service_manager.S6ServiceManager, "list_profile_gateways", _list)
|
||||
monkeypatch.setattr("gateway.host_topology.host_gateway_topology", lambda: None)
|
||||
|
||||
doctor_platform._check_s6_supervision([])
|
||||
|
||||
assert ROOT_SCANDIR / "main-hermes" in svstat_targets
|
||||
assert ROOT_SCANDIR / "dashboard" in svstat_targets
|
||||
assert "main-hermes: up" in capsys.readouterr().out
|
||||
# Gateway slots are hermes-registered, so they must not live in the root-supervised scandir.
|
||||
assert len(listed) == 1 and listed[0] != ROOT_SCANDIR
|
||||
assert ROOT_SCANDIR not in listed[0].parents
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The image's root s6 supervision tree must stay out of the unprivileged ``hermes`` user's reach.
|
||||
|
||||
Everything under the root ``s6-svscan`` scandir (``/run/service``) is supervised by a root
|
||||
``s6-supervise``, which executes each slot's ``run``/``finish``/``log/run`` as root and writes
|
||||
its ``supervise/`` state as root. The cont-init scripts and the static s6-rc services are run
|
||||
here against fake privileged binaries so their ownership decisions can be checked without a
|
||||
container.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCKER_DIR = REPO_ROOT / "docker"
|
||||
ROOT_SCANDIR = "/run/service"
|
||||
|
||||
|
||||
def _sh() -> str:
|
||||
shell = shutil.which("sh")
|
||||
if shell is None:
|
||||
pytest.skip("sh not available")
|
||||
return shell
|
||||
|
||||
|
||||
def _fake_bin(tmp_path: Path, log: Path, names: dict[str, str], sub: str = "fakebin") -> Path:
|
||||
"""Fake binaries that append ``<name> <args>`` to ``log`` and print a canned stdout."""
|
||||
bindir = tmp_path / sub
|
||||
bindir.mkdir()
|
||||
for name, stdout in names.items():
|
||||
script = bindir / name
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'printf "%s\\n" "{name} $*" >> "{log}"\n'
|
||||
+ (f"printf '%s\\n' '{stdout}'\n" if stdout else "")
|
||||
+ "exit 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script.chmod(0o755)
|
||||
return bindir
|
||||
|
||||
|
||||
def _run(script: Path, bindir: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = {k: v for k, v in os.environ.items() if k != "HERMES_DASHBOARD"}
|
||||
env["PATH"] = f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return subprocess.run([_sh(), str(script), *args], capture_output=True, text=True, env=env, check=False)
|
||||
|
||||
|
||||
def _calls(log: Path, name: str) -> list[list[str]]:
|
||||
if not log.exists():
|
||||
return []
|
||||
return [line.split()[1:] for line in log.read_text().splitlines() if line.split()[:1] == [name]]
|
||||
|
||||
|
||||
def _mode(path: Path) -> int:
|
||||
return stat.S_IMODE(path.stat().st_mode)
|
||||
|
||||
|
||||
def _under_root_scandir(path: str) -> bool:
|
||||
return path == ROOT_SCANDIR or path.startswith(ROOT_SCANDIR + "/")
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_reconcile_cont_init_leaves_root_scandir_root_owned(tmp_path: Path) -> None:
|
||||
"""02-reconcile-profiles must not hand /run/service (or its svscan control) to hermes:
|
||||
a hermes-writable root scandir lets hermes publish a slot that root s6-supervise executes."""
|
||||
log = tmp_path / "calls.log"
|
||||
bindir = _fake_bin(
|
||||
tmp_path, log, {"chown": "", "chmod": "", "mkdir": "", "id": "0", "s6-setuidgid": ""}
|
||||
)
|
||||
|
||||
proc = _run(DOCKER_DIR / "cont-init.d" / "02-reconcile-profiles", bindir)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
for args in _calls(log, "chown") + _calls(log, "chmod"):
|
||||
assert not any(_under_root_scandir(a) for a in args), args
|
||||
# The reconciler itself still runs, dropped to hermes.
|
||||
assert any(args[:1] == ["hermes"] and "hermes_cli.container_boot" in args
|
||||
for args in _calls(log, "s6-setuidgid"))
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_profile_gateway_scandir_is_supervised_unprivileged(tmp_path: Path) -> None:
|
||||
"""Per-profile gateway slots are hermes-writable, so the s6-svscan watching them must run as
|
||||
hermes (never as root), and on the same directory the service manager registers into."""
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
|
||||
gateway_scandir = str(S6ServiceManager().scandir)
|
||||
log = tmp_path / "calls.log"
|
||||
bindir = _fake_bin(tmp_path, log, {"id": "0", "s6-setuidgid": "", "s6-svscan": "", "sleep": ""})
|
||||
rc_dir = DOCKER_DIR / "s6-rc.d"
|
||||
runners = sorted(rc_dir.glob("*/run"))
|
||||
|
||||
supervisors = []
|
||||
for run in runners:
|
||||
log.unlink(missing_ok=True)
|
||||
proc = _run(run, bindir)
|
||||
assert proc.returncode == 0, (run, proc.stderr)
|
||||
for args in _calls(log, "s6-setuidgid"):
|
||||
if args[1:2] == ["s6-svscan"]:
|
||||
supervisors.append((run.parent.name, args))
|
||||
assert not _calls(log, "s6-svscan"), f"{run} runs s6-svscan as root"
|
||||
|
||||
assert len(supervisors) == 1, supervisors
|
||||
name, args = supervisors[0]
|
||||
assert args[0] == "hermes"
|
||||
assert args[-1] == gateway_scandir
|
||||
assert not _under_root_scandir(gateway_scandir)
|
||||
# Wired into the user bundle so s6-rc starts it after cont-init has seeded the scandir.
|
||||
assert (rc_dir / name / "type").read_text().strip() == "longrun"
|
||||
assert (rc_dir / "user" / "contents.d" / name).exists()
|
||||
assert (rc_dir / name / "dependencies.d" / "base").exists()
|
||||
|
||||
# cont-init seeds that same directory hermes-owned before s6-rc starts the supervisor.
|
||||
log.unlink(missing_ok=True)
|
||||
bindir2 = _fake_bin(
|
||||
tmp_path, log, {"chown": "", "chmod": "", "mkdir": "", "id": "0", "s6-setuidgid": ""},
|
||||
sub="fakebin-cont-init",
|
||||
)
|
||||
proc = _run(DOCKER_DIR / "cont-init.d" / "02-reconcile-profiles", bindir2)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert ["hermes:hermes", gateway_scandir] in _calls(log, "chown")
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_static_supervise_trees_stay_root_owned(tmp_path: Path) -> None:
|
||||
"""015-supervise-perms may let the hermes group query/control static services, but must not
|
||||
make hermes the owner of anything a root s6-supervise writes to."""
|
||||
svc_root = tmp_path / "servicedirs"
|
||||
for name in ("dashboard", "s6rc-oneshot-runner"):
|
||||
sup = svc_root / name / "supervise"
|
||||
sup.mkdir(parents=True)
|
||||
sup.chmod(0o700)
|
||||
os.mkfifo(sup / "control", 0o600)
|
||||
for f in ("lock", "status"):
|
||||
(sup / f).write_bytes(b"")
|
||||
(sup / f).chmod(0o600)
|
||||
(svc_root / name / "event").mkdir()
|
||||
(svc_root / name / "event").chmod(0o700)
|
||||
|
||||
text = (DOCKER_DIR / "cont-init.d" / "015-supervise-perms").read_text(encoding="utf-8")
|
||||
script_text, n = re.subn(r"(?m)^SVC_ROOT=.*$", f"SVC_ROOT={svc_root}", text)
|
||||
assert n == 1
|
||||
script = tmp_path / "015-supervise-perms"
|
||||
script.write_text(script_text, encoding="utf-8")
|
||||
log = tmp_path / "calls.log"
|
||||
bindir = _fake_bin(tmp_path, log, {"chown": ""})
|
||||
|
||||
proc = _run(script, bindir)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
chowns = _calls(log, "chown")
|
||||
assert chowns, "static services were not made reachable for the hermes group"
|
||||
for args in chowns:
|
||||
owner = next(a for a in args if not a.startswith("-"))
|
||||
assert owner.split(":")[0] == "root", args
|
||||
assert not any("/event" in a or "s6rc-" in a for a in args), args
|
||||
|
||||
sup = svc_root / "dashboard" / "supervise"
|
||||
assert _mode(sup) & 0o002 == 0 and _mode(sup) & 0o020 == 0 # nobody but root may create entries
|
||||
assert _mode(sup / "control") & 0o020 # hermes group can s6-svc
|
||||
assert _mode(sup / "status") & 0o040 and _mode(sup / "lock") & 0o040 # and s6-svstat
|
||||
assert _mode(svc_root / "dashboard" / "event") == 0o700 # event fifodir untouched
|
||||
assert _mode(svc_root / "s6rc-oneshot-runner" / "supervise") == 0o700
|
||||
@@ -275,12 +275,12 @@ Hermes supports [multiple profiles](../reference/profile-commands.md) — separa
|
||||
|
||||
Each profile created with `hermes profile create <name>` gets:
|
||||
|
||||
- A dedicated s6 service slot at `/run/service/gateway-<name>/`, registered dynamically by the runtime — no container rebuild required.
|
||||
- A dedicated s6 service slot at `/run/hermes-service/gateway-<name>/`, registered dynamically by the runtime — no container rebuild required.
|
||||
- Auto-restart on crash, backoff-managed by `s6-supervise`.
|
||||
- Per-profile rotated logs at `${HERMES_HOME}/logs/gateways/<name>/current` (10 archives × 1 MB each).
|
||||
- State persistence across container restarts: the boot-time reconciler reads `gateway_state.json` from each profile directory and brings the slot back up only for profiles whose last recorded state was `running`. Only a gateway you explicitly stopped (`hermes gateway stop`) stays down across a restart — a container restart, image upgrade, or unexpected exit leaves the recorded state as `running`, so the gateway auto-starts on the next boot.
|
||||
|
||||
A profile created from the **host** against a bind-mounted `~/.hermes` gets its directory but no slot (the host process cannot reach the container's `/run/service`). Inside the container, `hermes -p <name> gateway start` registers the missing slot on demand and starts it — no `docker restart` needed. Only `start` does this, and only for a real profile directory (one carrying `SOUL.md`); `stop`/`restart` on an unregistered profile and a mistyped `-p` name still fail with `✗ no such gateway`.
|
||||
A profile created from the **host** against a bind-mounted `~/.hermes` gets its directory but no slot (the host process cannot reach the container's `/run/hermes-service`). Inside the container, `hermes -p <name> gateway start` registers the missing slot on demand and starts it — no `docker restart` needed. Only `start` does this, and only for a real profile directory (one carrying `SOUL.md`); `stop`/`restart` on an unregistered profile and a mistyped `-p` name still fail with `✗ no such gateway`.
|
||||
|
||||
The lifecycle commands you'd run on the host work the same way from inside the container:
|
||||
|
||||
@@ -300,7 +300,7 @@ docker exec hermes hermes -p coder gateway status
|
||||
docker exec hermes hermes profile delete coder
|
||||
```
|
||||
|
||||
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
|
||||
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/hermes-service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
|
||||
|
||||
### Reaching more than one profile from outside the container
|
||||
|
||||
@@ -594,7 +594,7 @@ The container's `ENTRYPOINT` is a small dispatcher (`docker/entrypoint-dispatch.
|
||||
|
||||
On the PID-1 path, `/init`:
|
||||
1. Runs `/etc/cont-init.d/01-hermes-setup` (= `docker/stage2-hook.sh`) as root: optional UID/GID remap, fixes volume ownership, seeds `.env` / `config.yaml` / `SOUL.md` on first boot, runs non-interactive config-schema migrations unless `HERMES_SKIP_CONFIG_MIGRATION=1`, syncs bundled skills.
|
||||
2. Runs `/etc/cont-init.d/02-reconcile-profiles` (= `hermes_cli.container_boot`): walks `$HERMES_HOME/profiles/<name>/`, recreates the per-profile gateway s6 service slot under `/run/service/gateway-<profile>/`, and auto-starts only those whose last recorded state was `running` (see [Per-profile gateway supervision](#per-profile-gateway-supervision)).
|
||||
2. Runs `/etc/cont-init.d/02-reconcile-profiles` (= `hermes_cli.container_boot`): walks `$HERMES_HOME/profiles/<name>/`, recreates the per-profile gateway s6 service slot under `/run/hermes-service/gateway-<profile>/`, and auto-starts only those whose last recorded state was `running` (see [Per-profile gateway supervision](#per-profile-gateway-supervision)).
|
||||
3. Starts the static `main-hermes` and `dashboard` s6-rc services.
|
||||
4. Exec's the container's CMD as the main program (`/opt/hermes/docker/main-wrapper.sh`), which routes the arguments the user passed to `docker run`:
|
||||
- no args → `hermes` (the default)
|
||||
@@ -641,7 +641,7 @@ The shim accepts `1` / `true` / `yes` (case-insensitive). Anything else — incl
|
||||
|
||||
### Per-profile gateway supervision
|
||||
|
||||
Each profile created with `hermes profile create <name>` automatically gets an s6-supervised gateway service registered at `/run/service/gateway-<name>/`, with state-persistent auto-restart across container restarts. See [Multi-profile support](#multi-profile-support) above for the user-facing workflow and the lifecycle commands.
|
||||
Each profile created with `hermes profile create <name>` automatically gets an s6-supervised gateway service registered at `/run/hermes-service/gateway-<name>/`, with state-persistent auto-restart across container restarts. See [Multi-profile support](#multi-profile-support) above for the user-facing workflow and the lifecycle commands.
|
||||
|
||||
**Supervision benefits over the pre-s6 image:**
|
||||
|
||||
@@ -650,7 +650,7 @@ Each profile created with `hermes profile create <name>` automatically gets an s
|
||||
- `docker restart`, image upgrades (`docker compose up -d --force-recreate`), and unexpected exits preserve running gateways: the cont-init reconciler reads `$HERMES_HOME/profiles/<name>/gateway_state.json` and brings the slot back up if the last recorded state was `running`. Only an explicit `hermes gateway stop` records `stopped` and keeps the gateway down across the restart; the container/s6 SIGTERM sent on a restart or upgrade is treated as "still running" and auto-starts.
|
||||
- Per-profile gateway logs persist under `$HERMES_HOME/logs/gateways/<profile>/current` (rotated by `s6-log`), and the reconciler's actions are appended to `$HERMES_HOME/logs/container-boot.log` per boot. See [Where the logs go](#where-the-logs-go) for the full routing map.
|
||||
|
||||
`hermes status --full` inside the container reports `Manager: s6 (container supervisor)`. Use `/command/s6-svstat /run/service/gateway-<name>` for the raw supervisor view (note `/command/` is on PATH for supervision-tree processes only; pass the absolute path when calling from `docker exec`).
|
||||
`hermes status --full` inside the container reports `Manager: s6 (container supervisor)`. Use `/command/s6-svstat /run/hermes-service/gateway-<name>` for the raw supervisor view (note `/command/` is on PATH for supervision-tree processes only; pass the absolute path when calling from `docker exec`).
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -1120,7 +1120,7 @@ the host gateway with `gateway.standalone: true` (see
|
||||
### Docker / Hermes Cloud (s6-supervised container)
|
||||
|
||||
Inside the official image every profile has an s6 slot
|
||||
(`/run/service/gateway-<profile>`). The container's boot registers every *named*
|
||||
(`/run/hermes-service/gateway-<profile>`). The container's boot registers every *named*
|
||||
slot down and folds its autostart intent into the root slot, so a fresh boot
|
||||
already multiplexes. An **in-place** update no longer needs a container restart
|
||||
to converge either: `hermes gateway migrate --multiplex` (and the hook `hermes
|
||||
|
||||
@@ -286,7 +286,7 @@ assistant gateway install # creates hermes-gateway-assistant service
|
||||
Each profile gets its own service name. They run independently.
|
||||
|
||||
:::note Inside the official Docker image
|
||||
Per-profile gateways are supervised by [s6-overlay](https://github.com/just-containers/s6-overlay) (PID 1 in the container), so `hermes profile create <name>` automatically registers an s6 service slot at `/run/service/gateway-<name>/`. `hermes -p <name> gateway start/stop/restart` dispatches to `s6-svc` instead of spawning a bare process — crashes are auto-restarted and `docker restart` preserves the previously-running set of gateways. See [Per-profile gateway supervision](./docker.md#per-profile-gateway-supervision) for details.
|
||||
Per-profile gateways are supervised by [s6-overlay](https://github.com/just-containers/s6-overlay) (PID 1 in the container), so `hermes profile create <name>` automatically registers an s6 service slot at `/run/hermes-service/gateway-<name>/`. `hermes -p <name> gateway start/stop/restart` dispatches to `s6-svc` instead of spawning a bare process — crashes are auto-restarted and `docker restart` preserves the previously-running set of gateways. See [Per-profile gateway supervision](./docker.md#per-profile-gateway-supervision) for details.
|
||||
:::
|
||||
|
||||
## Configuring profiles
|
||||
|
||||
+10
-9
@@ -55,16 +55,17 @@ If you're just running the Hermes Agent and want to use Docker, see `website/doc
|
||||
│ │ ├── seed .env / config.yaml / SOUL.md
|
||||
│ │ └── skills_sync.py
|
||||
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
|
||||
│ ├── chown /run/service (hermes-writable for runtime register)
|
||||
│ ├── mkdir+chown /run/hermes-service (hermes-owned gateway scandir; /run/service stays root)
|
||||
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
|
||||
│ → recreate /run/service/gateway-<name>/
|
||||
│ → recreate /run/hermes-service/gateway-<name>/
|
||||
│ → auto-start only those with prior_state == "running"
|
||||
│
|
||||
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
|
||||
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
|
||||
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
|
||||
│ ├── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
|
||||
│ └── profile-gateways/run ← `s6-setuidgid hermes s6-svscan /run/hermes-service`
|
||||
│
|
||||
├── /run/service (s6-svscan watches; tmpfs)
|
||||
├── /run/hermes-service (hermes s6-svscan from s6-rc `profile-gateways` watches; tmpfs)
|
||||
│ ├── gateway-coder/ ← runtime-registered per-profile
|
||||
│ │ ├── type ("longrun")
|
||||
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
|
||||
@@ -103,7 +104,7 @@ The original plan (v1–v3) called for main hermes to run as a supervised s6-rc
|
||||
|
||||
So we use the s6-overlay-native CMD pattern via the dispatcher: `ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"]`, which under PID 1 exec's `/init /opt/hermes/docker/main-wrapper.sh "$@"`. The wrapper is prepended to user args automatically — so `docker run <image> --version` becomes `/init main-wrapper.sh --version`, and `--version` doesn't get intercepted by /init's POSIX shell. The wrapper drops to hermes via `s6-setuidgid`, then exec's the chosen program. The program's exit code becomes the container exit code, exactly matching the pre-s6 tini contract. When the entrypoint is NOT PID 1 (Fly Machines, `docker run --init`), the dispatcher skips `/init` entirely (it would abort with `can only run as pid 1`), restores the s6 helper PATH, runs stage2-hook.sh, and exec's main-wrapper.sh directly — no supervised services on that path (#38349).
|
||||
|
||||
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/service/` get full supervision.
|
||||
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/hermes-service/` get full supervision.
|
||||
|
||||
## Quick recipes
|
||||
|
||||
@@ -118,7 +119,7 @@ docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
|
||||
|
||||
```sh
|
||||
# /command/ isn't on docker-exec PATH — use absolute path
|
||||
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
|
||||
docker exec <c> /command/s6-svstat /run/hermes-service/gateway-<name>
|
||||
# "up (pid …) … seconds" → running
|
||||
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
|
||||
# "down … normally up, ready …" → user stopped it
|
||||
@@ -127,9 +128,9 @@ docker exec <c> /command/s6-svstat /run/service/gateway-<name>
|
||||
### Bring a service up/down manually
|
||||
|
||||
```sh
|
||||
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
|
||||
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
|
||||
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
|
||||
docker exec <c> /command/s6-svc -u /run/hermes-service/gateway-<name> # up
|
||||
docker exec <c> /command/s6-svc -d /run/hermes-service/gateway-<name> # down
|
||||
docker exec <c> /command/s6-svc -t /run/hermes-service/gateway-<name> # SIGTERM (restart)
|
||||
```
|
||||
|
||||
### Watch the cont-init reconciler log
|
||||
|
||||
@@ -176,7 +176,7 @@ Hermes 支持[多个 profile](../reference/profile-commands.md)——独立的 `
|
||||
|
||||
每个通过 `hermes profile create <name>` 创建的 profile 都会获得:
|
||||
|
||||
- 一个专用的 s6 服务槽位 `/run/service/gateway-<name>/`,运行时动态注册,无需重建镜像。
|
||||
- 一个专用的 s6 服务槽位 `/run/hermes-service/gateway-<name>/`,运行时动态注册,无需重建镜像。
|
||||
- 崩溃后的自动重启,由 `s6-supervise` 管理退避。
|
||||
- 每个 profile 独立的轮转日志:`${HERMES_HOME}/logs/gateways/<name>/current`。
|
||||
- 跨容器重启的状态持久化:启动协调器会读取该 profile 的 `gateway_state.json`,仅在上次记录状态为 `running` 时自动拉起。
|
||||
@@ -309,7 +309,7 @@ PID-1 路径先准备数据卷和配置,重建各 profile 的 gateway 服务
|
||||
|
||||
### Per-profile gateway 监管 {#per-profile-gateway-supervision}
|
||||
|
||||
在容器内,每个通过 `hermes profile create <name>` 创建的 profile 都会自动在 `/run/service/gateway-<name>/` 注册一个受 s6 监管的 gateway 服务。你在宿主机上运行的生命周期命令在此同样适用:
|
||||
在容器内,每个通过 `hermes profile create <name>` 创建的 profile 都会自动在 `/run/hermes-service/gateway-<name>/` 注册一个受 s6 监管的 gateway 服务。你在宿主机上运行的生命周期命令在此同样适用:
|
||||
|
||||
```sh
|
||||
hermes profile create coder # 注册 gateway-coder s6 槽
|
||||
@@ -326,7 +326,7 @@ hermes profile delete coder # 拆除 s6 槽
|
||||
- `docker restart` 保留运行中的 gateway:cont-init 协调器读取 `$HERMES_HOME/profiles/<name>/gateway_state.json`,若上次记录状态为 `running` 则恢复该槽。已停止的 gateway 保持停止状态。
|
||||
- 各 profile 的 gateway 日志持久化于 `$HERMES_HOME/logs/gateways/<profile>/current`(由 `s6-log` 轮转),协调器的操作记录在每次启动时追加到 `$HERMES_HOME/logs/container-boot.log`。
|
||||
|
||||
在容器内执行 `hermes status` 会显示 `Manager: s6 (container supervisor)`。使用 `/command/s6-svstat /run/service/gateway-<name>` 查看原始 supervisor 状态(注意 `/command/` 仅在监管树进程的 PATH 中;从 `docker exec` 调用时请传入绝对路径)。
|
||||
在容器内执行 `hermes status` 会显示 `Manager: s6 (container supervisor)`。使用 `/command/s6-svstat /run/hermes-service/gateway-<name>` 查看原始 supervisor 状态(注意 `/command/` 仅在监管树进程的 PATH 中;从 `docker exec` 调用时请传入绝对路径)。
|
||||
|
||||
## 升级
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ assistant gateway install # 创建 hermes-gateway-assistant 服务
|
||||
每个 profile 拥有独立的服务名称,各自独立运行。
|
||||
|
||||
:::note 在官方 Docker 镜像中
|
||||
各 profile 的 gateway 由 [s6-overlay](https://github.com/just-containers/s6-overlay)(容器中的 PID 1)监管,因此 `hermes profile create <name>` 会自动在 `/run/service/gateway-<name>/` 注册 s6 服务槽。`hermes -p <name> gateway start/stop/restart` 会调度到 `s6-svc` 而非直接启动裸进程——崩溃后自动重启,`docker restart` 会保留之前运行的 gateway 集合。详见 [各 profile gateway 监管](./docker.md#per-profile-gateway-supervision)。
|
||||
各 profile 的 gateway 由 [s6-overlay](https://github.com/just-containers/s6-overlay)(容器中的 PID 1)监管,因此 `hermes profile create <name>` 会自动在 `/run/hermes-service/gateway-<name>/` 注册 s6 服务槽。`hermes -p <name> gateway start/stop/restart` 会调度到 `s6-svc` 而非直接启动裸进程——崩溃后自动重启,`docker restart` 会保留之前运行的 gateway 集合。详见 [各 profile gateway 监管](./docker.md#per-profile-gateway-supervision)。
|
||||
:::
|
||||
|
||||
## 配置 profile
|
||||
|
||||
Reference in new issue
Block a user