From 06c729706f9019a436c6db86bcc964c4522793c3 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sat, 18 Jul 2026 14:37:40 +0700 Subject: [PATCH] test(docker): cover tini -g legacy entrypoint boot path Unit-test flag stripping without Docker, and assert the image shim rejects the rc.init '-g: not found' restart loop from #66679. --- tests/docker/test_tini_compat_shim.py | 62 +++++++++++---- tests/test_tini_shim.py | 107 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 tests/test_tini_shim.py diff --git a/tests/docker/test_tini_compat_shim.py b/tests/docker/test_tini_compat_shim.py index 26e0e4d13c431..e1978a7e03f07 100644 --- a/tests/docker/test_tini_compat_shim.py +++ b/tests/docker/test_tini_compat_shim.py @@ -1,33 +1,42 @@ -"""Runtime smoke test for the Docker tini compatibility shim (#34192). +"""Runtime smoke test for the Docker tini compatibility shim (#34192, #66679). Build the real image and verify: - 1. /usr/bin/tini exists and is a symlink to /init (the compat shim - for orchestration templates that still reference /usr/bin/tini) + 1. /usr/bin/tini exists as an executable shim (not a bare symlink to + /init — that forwarded tini's ``-g`` into s6 and boot-looped) 2. The actual ENTRYPOINT is /init (s6-overlay), not /usr/bin/tini + 3. Legacy ``tini -g -- `` entrypoints boot without + ``rc.init: -g: not found`` """ from __future__ import annotations import subprocess -def test_tini_compat_symlink_exists(built_image: str) -> None: - """/usr/bin/tini must exist as a symlink to /init. +def test_tini_compat_shim_exists(built_image: str) -> None: + """/usr/bin/tini must be an executable shim script. - Regression for #34192: orchestration templates (e.g. Hostinger's - 'Hermes WebUI' catalog) still pin /usr/bin/tini as the entrypoint. - The shim symlinks it to /init so legacy wrappers exec the right - PID-1 reaper without behavior change. + Regression for #34192 / #66679: orchestration templates (e.g. + Hostinger's 'Hermes WebUI' catalog, NAS compose projects that keep + an old entrypoint across image updates) still pin /usr/bin/tini as + the entrypoint, often with ``-g --``. The shim must exist *and* + strip those flags before exec'ing /init. """ r = subprocess.run( ["docker", "run", "--rm", "--entrypoint", "sh", built_image, "-c", - 'test -L /usr/bin/tini && ' - 'test "$(readlink -f /usr/bin/tini)" = "/init"'], + 'test -x /usr/bin/tini && ' + # Must NOT be a raw symlink to /init — that reintroduces #66679. + 'if [ -L /usr/bin/tini ]; then ' + ' target="$(readlink -f /usr/bin/tini)"; ' + ' test "$target" != "/init"; ' + 'fi && ' + 'head -n1 /usr/bin/tini | grep -q "^#!"'], capture_output=True, text=True, timeout=60, ) assert r.returncode == 0, ( - f"/usr/bin/tini is not a symlink to /init: {r.stderr[-500:]}" + f"/usr/bin/tini is not a usable tini shim: " + f"stdout={r.stdout[-500:]!r} stderr={r.stderr[-500:]!r}" ) @@ -51,4 +60,31 @@ def test_entrypoint_is_init_not_tini(built_image: str) -> None: # /usr/bin/tini should NOT be in the entrypoint. assert "tini" not in entrypoint.lower(), ( f"ENTRYPOINT references tini instead of /init: {entrypoint!r}" - ) \ No newline at end of file + ) + + +def test_legacy_tini_g_entrypoint_does_not_boot_loop(built_image: str) -> None: + """``docker run --entrypoint /usr/bin/tini … -g -- --help`` must work. + + Exact failure from #66679: after update, NAS templates still invoke + ``/usr/bin/tini -g -- …``. The old symlink turned that into + ``/init -g -- …``, rc.init tried to exec ``-g``, and the container + restart-looped. The shim must strip ``-g`` / ``--`` and reach hermes. + """ + r = subprocess.run( + [ + "docker", "run", "--rm", + "--entrypoint", "/usr/bin/tini", + built_image, + "-g", "--", "--help", + ], + capture_output=True, text=True, timeout=120, + ) + combined = r.stdout + r.stderr + assert "-g: not found" not in combined, ( + f"tini -g leaked into rc.init (boot-loop regression):\n{combined[-3000:]}" + ) + assert r.returncode == 0, ( + f"legacy tini -g -- --help failed (exit {r.returncode}):\n" + f"stdout={r.stdout[-2000:]!r}\nstderr={r.stderr[-2000:]!r}" + ) diff --git a/tests/test_tini_shim.py b/tests/test_tini_shim.py new file mode 100644 index 0000000000000..254fbea66c17a --- /dev/null +++ b/tests/test_tini_shim.py @@ -0,0 +1,107 @@ +"""Unit tests for docker/tini-shim.sh argument stripping (#66679). + +These run without Docker: the shim's HERMES_TINI_SHIM_TARGET / +HERMES_TINI_SHIM_WRAPPER hooks let us record the argv that would be +handed to /init. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SHIM = REPO_ROOT / "docker" / "tini-shim.sh" + + +@pytest.fixture +def recorder(tmp_path: Path) -> tuple[Path, Path]: + """Fake /init + wrapper that print argv, one token per line.""" + init = tmp_path / "fake-init" + wrapper = tmp_path / "fake-wrapper" + init.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n") + wrapper.write_text("#!/bin/sh\nprintf 'wrapper\\n'\n") + init.chmod(0o755) + wrapper.chmod(0o755) + return init, wrapper + + +def _run_shim( + recorder: tuple[Path, Path], + args: list[str], +) -> subprocess.CompletedProcess[str]: + init, wrapper = recorder + env = os.environ.copy() + env["HERMES_TINI_SHIM_TARGET"] = str(init) + env["HERMES_TINI_SHIM_WRAPPER"] = str(wrapper) + return subprocess.run( + ["sh", str(SHIM), *args], + capture_output=True, + text=True, + timeout=10, + env=env, + check=False, + ) + + +def test_shim_script_is_executable_bit_friendly() -> None: + assert SHIM.is_file() + text = SHIM.read_text() + assert text.startswith("#!/bin/sh") + assert "HERMES_TINI_SHIM_TARGET" in text + + +def test_strips_g_and_double_dash(recorder: tuple[Path, Path]) -> None: + """Legacy `tini -g -- gateway run` must not forward `-g` to /init.""" + r = _run_shim(recorder, ["-g", "--", "gateway", "run"]) + assert r.returncode == 0, r.stderr + lines = [ln for ln in r.stdout.splitlines() if ln] + init, wrapper = recorder + assert lines[0] == str(wrapper) + assert lines[1:] == ["gateway", "run"] + assert "-g" not in lines + assert "--" not in lines + + +def test_strips_g_without_double_dash(recorder: tuple[Path, Path]) -> None: + r = _run_shim(recorder, ["-g", "gateway", "run"]) + assert r.returncode == 0, r.stderr + lines = [ln for ln in r.stdout.splitlines() if ln] + init, wrapper = recorder + assert lines == [str(wrapper), "gateway", "run"] + + +def test_empty_args_after_flags_uses_wrapper_only( + recorder: tuple[Path, Path], +) -> None: + """entrypoint `tini -g --` with empty CMD → /init main-wrapper.""" + r = _run_shim(recorder, ["-g", "--"]) + assert r.returncode == 0, r.stderr + lines = [ln for ln in r.stdout.splitlines() if ln] + _, wrapper = recorder + assert lines == [str(wrapper)] + + +def test_does_not_double_wrap_existing_wrapper( + recorder: tuple[Path, Path], +) -> None: + _, wrapper = recorder + r = _run_shim(recorder, ["-g", "--", str(wrapper), "gateway", "run"]) + assert r.returncode == 0, r.stderr + lines = [ln for ln in r.stdout.splitlines() if ln] + assert lines == [str(wrapper), "gateway", "run"] + + +def test_strips_p_and_e_with_arguments(recorder: tuple[Path, Path]) -> None: + r = _run_shim( + recorder, + ["-p", "SIGKILL", "-e", "143", "-v", "-v", "--", "sleep", "infinity"], + ) + assert r.returncode == 0, r.stderr + lines = [ln for ln in r.stdout.splitlines() if ln] + _, wrapper = recorder + assert lines == [str(wrapper), "sleep", "infinity"] + assert "SIGKILL" not in lines + assert "143" not in lines