The single cgroup.procs read now feeds only the empty-check and the live-gateway guard. The guard can take seconds (gateway.status import, per-PID cmdline with psutil/ps fallback), so killing from that stale list could SIGKILL a PID that exited and was reused outside the cgroup, and would miss orphans spawned in the gap. Re-read cgroup.procs for the kill loop (one small sysfs read), restoring base's kill-set freshness while keeping the skipped import on the common empty-cgroup stop.
114 lines
4.6 KiB
Python
114 lines
4.6 KiB
Python
"""Tests for the systemd ExecStopPost cgroup reaper (issue #37454)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gateway import cgroup_cleanup
|
|
|
|
|
|
class TestOwnCgroupPath:
|
|
def test_parses_v2_cgroup_path(self, tmp_path, monkeypatch):
|
|
proc_self = tmp_path / "cgroup"
|
|
proc_self.write_text("0::/user.slice/user-1000.slice/hermes-gateway.service\n")
|
|
monkeypatch.setattr(
|
|
cgroup_cleanup,
|
|
"Path",
|
|
lambda p: proc_self if p == "/proc/self/cgroup" else Path(p),
|
|
)
|
|
|
|
assert cgroup_cleanup._own_cgroup_path() == "/user.slice/user-1000.slice/hermes-gateway.service"
|
|
|
|
|
|
class TestReapCgroup:
|
|
def test_noop_when_procs_file_missing(self, tmp_path, monkeypatch):
|
|
cgroup_path = "/missing.slice/hermes-gateway.service"
|
|
monkeypatch.setattr(
|
|
cgroup_cleanup,
|
|
"Path",
|
|
lambda p: tmp_path / "does-not-exist" if "cgroup.procs" in p else Path(p),
|
|
)
|
|
|
|
def _explode(*_a, **_kw):
|
|
pytest.fail("os.kill must not be called when cgroup.procs is unreadable")
|
|
|
|
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
|
|
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0
|
|
|
|
|
|
class TestMain:
|
|
def test_main_refuses_when_pid1_parent_is_not_systemd(self, tmp_path, monkeypatch):
|
|
# Regression: a container where the gateway itself is PID 1 (or init
|
|
# is tini/launchd) must NOT be authorized by "ppid == 1". PID 1 has to
|
|
# present as systemd in /proc/1/comm like any other parent.
|
|
comm = tmp_path / "comm"
|
|
comm.write_text("tini\n")
|
|
monkeypatch.setattr(cgroup_cleanup.os, "getppid", lambda: 1)
|
|
monkeypatch.setattr(
|
|
cgroup_cleanup,
|
|
"Path",
|
|
lambda p: comm if p == "/proc/1/comm" else Path(p),
|
|
)
|
|
|
|
def _explode(*_a, **_kw):
|
|
pytest.fail("os.kill must not be called for a non-systemd PID 1 parent")
|
|
|
|
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
|
|
assert cgroup_cleanup.main() == 1
|
|
|
|
|
|
class TestLiveGatewayGuard:
|
|
@pytest.mark.parametrize("verb", ["run", "restart"])
|
|
def test_reap_refuses_when_live_gateway_in_cgroup(self, monkeypatch, verb):
|
|
# Regression: a live gateway PID still in cgroup.procs (gateway is PID 1
|
|
# in a plain container, a targeted reap of a running service, or an
|
|
# in-process `gateway restart` on a host without a service manager)
|
|
# must abort the reap before any signal — even under a systemd parent —
|
|
# and main() must report the refusal with a non-zero exit.
|
|
import gateway.status
|
|
|
|
cgroup_path = "/some.slice/some-gateway.service"
|
|
gateway_cmdline = f"/opt/hermes/venv/bin/python -m hermes_cli.main gateway {verb}"
|
|
monkeypatch.setattr(
|
|
cgroup_cleanup, "_read_cgroup_pids", lambda _p: [777, os.getpid()]
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway.status,
|
|
"_read_process_cmdline",
|
|
lambda pid: gateway_cmdline if pid == 777 else None,
|
|
)
|
|
|
|
def _explode(*_a, **_kw):
|
|
pytest.fail("os.kill must not signal a cgroup holding a live gateway")
|
|
|
|
monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
|
|
assert cgroup_cleanup.reap_cgroup(cgroup_path) is None
|
|
monkeypatch.setattr(cgroup_cleanup, "_parent_is_systemd", lambda: True)
|
|
monkeypatch.setattr(cgroup_cleanup, "_own_cgroup_path", lambda: cgroup_path)
|
|
assert cgroup_cleanup.main() == 1
|
|
|
|
# Allow-path: once the gateway is gone, a non-gateway orphan in the
|
|
# same cgroup must still be reaped — the guard must not destroy the
|
|
# feature it secures.
|
|
monkeypatch.setattr(
|
|
gateway.status,
|
|
"_read_process_cmdline",
|
|
lambda pid: "bash -c adb forward tcp:8888 tcp:8889" if pid == 777 else None,
|
|
)
|
|
killed: list[int] = []
|
|
monkeypatch.setattr(cgroup_cleanup.os, "kill", lambda pid, sig: killed.append(pid))
|
|
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 1
|
|
assert killed == [777]
|
|
|
|
# The kill set comes from a fresh cgroup.procs read taken after the
|
|
# (slow) guard: a PID that exited meanwhile is not signalled (it may be
|
|
# reused outside the cgroup) and an orphan spawned meanwhile is reaped.
|
|
reads = iter([[777, os.getpid()], [888, os.getpid()]])
|
|
monkeypatch.setattr(cgroup_cleanup, "_read_cgroup_pids", lambda _p: next(reads))
|
|
killed.clear()
|
|
assert cgroup_cleanup.reap_cgroup(cgroup_path) == 1
|
|
assert killed == [888]
|