fix(install): support non-pid-1 container entrypoints

Replace the bare /init ENTRYPOINT with entrypoint-dispatch.sh: exec
/init + main-wrapper when the image owns PID 1, fall back to a direct
stage2 bootstrap (with the s6 helper PATH restored) on wrapped runtimes
where s6-overlay-suexec would abort with 'can only run as pid 1'
(Fly Machines, docker run --init, podman/FreeBSD setups).

Cherry-picked from PR #43763 by @konsisumer, conflicts with current
main resolved (tests/test_dockerfile_tini_compat_shim.py was moved to
tests/docker/, container_boot argv tests were reshaped upstream).

Fixes #38349
This commit is contained in:
konsisumer 2026-07-31 23:26:35 -07:00 committed by Teknium
parent 090d146479
commit f40f4711ed
6 changed files with 123 additions and 25 deletions

View File

@ -400,6 +400,8 @@ ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
@ -416,27 +418,37 @@ ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
RUN mkdir -p /opt/data
VOLUME [ "/opt/data" ]
# s6-overlay's /init is PID 1. It sets up the supervision tree, runs
# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services
# declared in /etc/s6-overlay/s6-rc.d/, then exec's its remaining
# argv as the container's "main program" with stdin/stdout/stderr
# inherited (this is what makes interactive --tui work). When the
# main program exits, /init begins stage 3 shutdown and the container
# exits with the program's exit code. Replaces tini — see Phase 2 of
# docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly.
# When the image really owns PID 1 (normal Docker / Podman), the dispatcher
# execs `/init` and preserves the full s6 supervision tree. When a platform
# wraps the image entrypoint under its own PID-1 init (Fly Machines,
# `docker run --init`, some schedulers), `/init` would abort with
# `can only run as pid 1`; in that case the dispatcher falls back to
# `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still
# work. See #38349.
#
# On the PID-1 path, s6-overlay's /init sets up the supervision tree, runs
# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services declared in
# /etc/s6-overlay/s6-rc.d/, then exec's its remaining argv as the container's
# "main program" with stdin/stdout/stderr inherited (this is what makes
# interactive --tui work). When the main program exits, /init begins stage 3
# shutdown and the container exits with the program's exit code. Replaces
# tini — see Phase 2 of docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
#
# We use the ENTRYPOINT+CMD split rather than CMD alone so the
# wrapper is prepended to user-supplied args automatically:
#
# docker run <image> → /init main-wrapper.sh (CMD default)
# docker run <image> chat -q "hi" → /init main-wrapper.sh chat -q hi
# docker run <image> sleep infinity → /init main-wrapper.sh sleep infinity
# docker run <image> --tui → /init main-wrapper.sh --tui
# docker run <image> → entrypoint-dispatch.sh (CMD default)
# docker run <image> chat -q "hi" → entrypoint-dispatch.sh chat -q hi
# docker run <image> sleep infinity → entrypoint-dispatch.sh sleep infinity
# docker run <image> --tui → entrypoint-dispatch.sh --tui
#
# main-wrapper.sh handles arg routing (bare-exec vs. hermes
# subcommand vs. no-args), drops to the hermes user via s6-setuidgid,
# and exec's the final program so its exit code becomes the container
# exit code. Without the wrapper-as-ENTRYPOINT, leading-dash args
# like `--version` would be intercepted by /init's POSIX shell.
ENTRYPOINT [ "/init", "/opt/hermes/docker/main-wrapper.sh" ]
# exit code. The dispatcher preserves that contract across both the
# supervised PID-1 path and the non-PID-1 fallback path. Without the
# wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be
# intercepted by /init's POSIX shell.
ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ]
CMD [ ]

View File

@ -0,0 +1,25 @@
#!/bin/sh
# shellcheck shell=sh
# Entry-point dispatcher for runtimes that may or may not give the image
# ownership of PID 1.
#
# Normal Docker / Podman path: this script is PID 1, so we delegate to
# s6-overlay's /init exactly as before and keep the full supervision tree.
#
# Wrapped-runtime path (Fly Machines, `docker run --init`, some Nomad/K8s
# setups): the platform's own init is already PID 1 and execs the image
# entrypoint as a child. s6-overlay aborts there with "can only run as pid 1",
# so we run the stage2 bootstrap directly and then exec the main wrapper
# without /init.
set -e
if [ "$$" -eq 1 ]; then
exec /init /opt/hermes/docker/main-wrapper.sh "$@"
fi
echo "[hermes] WARNING: container entrypoint is not PID 1; skipping s6-overlay /init and falling back to direct bootstrap. Supervised services are unavailable in this runtime, but the requested command will still run." >&2
# /init normally seeds PATH with s6's helpers; the non-PID-1 fallback skips it.
export PATH="/command:/package/admin/s6/command:${PATH}"
/opt/hermes/docker/stage2-hook.sh
exec /opt/hermes/docker/main-wrapper.sh "$@"

View File

@ -19,9 +19,10 @@
# Surface a warning to stderr so anyone still invoking this path
# sees the migration notice in their logs.
echo "[hermes] WARNING: docker/entrypoint.sh is a deprecated shim under " \
"s6-overlay. The container's real ENTRYPOINT is /init + " \
"main-wrapper.sh; this script only runs the stage2 cont-init hook " \
"s6-overlay. The container's real ENTRYPOINT is " \
"entrypoint-dispatch.sh (which delegates to /init + main-wrapper.sh " \
"when PID 1); this script only runs the stage2 cont-init hook " \
"and does NOT exec the CMD. If you hard-coded docker/entrypoint.sh " \
"as your ENTRYPOINT, drop the override — docker will use the image's " \
"default ENTRYPOINT (/init), which handles bootstrap AND CMD." >&2
"default ENTRYPOINT dispatcher, which handles bootstrap AND CMD." >&2
exec /opt/hermes/docker/stage2-hook.sh "$@"

View File

@ -1,15 +1,16 @@
#!/command/with-contenv sh
#!/bin/sh
# shellcheck shell=sh
# /opt/hermes/docker/main-wrapper.sh — wraps the container's CMD with
# the same argument-routing logic the pre-s6 entrypoint.sh used. Runs
# as /init's "main program" (Docker CMD) so it inherits stdin/stdout/
# stderr from the container.
# stderr from the container. The non-PID-1 entrypoint fallback also
# execs this script directly after running the stage2 bootstrap.
#
# Shebang note: /init scrubs env before invoking CMD, so a plain
# `#!/bin/sh` wrapper sees an empty environ and `ENV HERMES_HOME=/opt/data`
# from the Dockerfile never reaches `hermes`. with-contenv repopulates
# the env from /run/s6/container_environment before exec'ing, which is
# what s6-supervised services use too (see main-hermes/run).
# Env note: /init scrubs env before invoking CMD, so when this wrapper
# is launched through the supervised path it must rehydrate via
# with-contenv before touching HERMES_HOME / PATH. On the non-PID-1
# fallback path the Dockerfile env is still intact, so we skip the
# re-exec and continue directly.
#
# Routing:
# no args → exec `hermes` (the default)
@ -19,6 +20,14 @@
# Drop to hermes via s6-setuidgid, but skip it when already non-root.
set -e
if [ -z "${HERMES_MAIN_WRAPPER_ENV_READY:-}" ] && \
[ -z "${HERMES_HOME:-}" ] && \
[ -x /command/with-contenv ]; then
export HERMES_MAIN_WRAPPER_ENV_READY=1
exec /command/with-contenv sh "$0" "$@"
fi
unset HERMES_MAIN_WRAPPER_ENV_READY
drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
# --- Reject the unsupported `docker run --user <uid>:<gid>` start ---

View File

@ -332,6 +332,10 @@ def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
# Defensive: an `init` prefix with no wrapper token in argv.
args = args[1:]
# Non-PID-1 entrypoints go through the dispatch shim instead of /init.
if args and args[0].endswith("entrypoint-dispatch.sh"):
args = args[1:]
# The wrapper re-execs `hermes <subcommand>`; peel an explicit hermes.
if args and Path(args[0]).name == "hermes":
args = args[1:]

View File

@ -28,6 +28,7 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
DOCKERFILE = REPO_ROOT / "Dockerfile"
DOCKERIGNORE = REPO_ROOT / ".dockerignore"
ENTRYPOINT_DISPATCH = REPO_ROOT / "docker" / "entrypoint-dispatch.sh"
# Init-process families this repo accepts as PID 1. ``tini`` /
@ -112,6 +113,52 @@ def test_dockerfile_installs_an_init_for_zombie_reaping(dockerfile_text):
)
def test_dockerfile_entrypoint_routes_through_the_init(dockerfile_text):
"""The ENTRYPOINT must preserve a PID-1 init path, even with a dispatcher.
Installing the init is only half the fix the container must actually
run with it as PID 1. A shell dispatcher is fine only if it execs the
real init when the image owns PID 1; otherwise the shell would become
PID 1 and hermes would run without zombie reaping.
"""
# Find the last uncommented ENTRYPOINT line — Docker honours the final one.
entrypoint_line = None
for raw_line in dockerfile_text.splitlines():
line = raw_line.strip()
if line.startswith("#"):
continue
if line.startswith("ENTRYPOINT"):
entrypoint_line = line
assert entrypoint_line is not None, "Dockerfile is missing an ENTRYPOINT directive"
if any(name in entrypoint_line for name in _KNOWN_INIT_TOKENS):
return
assert "/opt/hermes/docker/entrypoint-dispatch.sh" in entrypoint_line, (
f"Unexpected Dockerfile ENTRYPOINT: {entrypoint_line!r}"
)
assert ENTRYPOINT_DISPATCH.exists(), (
"Dockerfile points at entrypoint-dispatch.sh but the script is missing."
)
dispatcher = ENTRYPOINT_DISPATCH.read_text(encoding="utf-8")
assert 'if [ "$$" -eq 1 ]; then' in dispatcher
assert "exec /init /opt/hermes/docker/main-wrapper.sh" in dispatcher, (
"The entrypoint dispatcher must hand PID-1 execution off to /init; "
"otherwise the shell becomes PID 1 and zombies will accumulate."
)
def test_dispatcher_non_pid1_fallback_restores_s6_helpers_on_path() -> None:
"""Skipping /init must still expose s6-setuidgid to stage2/main-wrapper."""
dispatcher = ENTRYPOINT_DISPATCH.read_text(encoding="utf-8")
assert 'export PATH="/command:/package/admin/s6/command:${PATH}"' in dispatcher, (
"The non-PID-1 entrypoint fallback skips /init, so it must restore the "
"s6 helper directories on PATH before invoking stage2-hook.sh and "
"main-wrapper.sh."
)
def test_dockerignore_excludes_nested_dependency_dirs():
if not DOCKERIGNORE.exists():
pytest.skip(".dockerignore not present in this checkout")