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:
parent
090d146479
commit
f40f4711ed
42
Dockerfile
42
Dockerfile
|
|
@ -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 [ ]
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue