diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b3aaa..38c6e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Release notes +## 1.0.1 — 2026-10-29 + +`podman stop` now stops a running agent container immediately instead of +after a 10-second SIGTERM timeout followed by SIGKILL. + +### Why + +Containers used `sleep infinity` as PID 1. The kernel does not apply +default signal dispositions to PID 1, and `sleep` has no SIGTERM handler, +so the stop signal was silently ignored and every stop fell through to +SIGKILL. PID 1 now traps TERM (and INT) itself and exits on request: + +```sh +trap "exit 0" TERM INT; sleep infinity & wait +``` + +### Upgrading + +The PID 1 command is baked in when a container is created, so existing +containers are detected on the next run and recreated automatically. +Nothing in the container home is touched. + ## 1.0.0 — 2026-10-29 Containers now run with rootless Podman instead of Docker. diff --git a/Dockerfile b/Dockerfile index faa411f..c2968d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM archlinux:latest +# Fully qualified registry: Podman on distros without an unqualified search +# registry configured (e.g. Arch or Ubuntu) cannot resolve the short name. +FROM docker.io/library/archlinux:latest ARG USERNAME=dev ARG UID=1000 diff --git a/README.md b/README.md index 2072a9e..929f4e1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ socket goes away. ## Install -You need rootless Podman on the host. On Arch Linux the packages `podman` and `passt` (or `slirp4netns`) are enough; your user also needs subuid/subgid entries, which `usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ` sets up if the package did not: +You need rootless Podman on the host. On Arch Linux the packages `podman` and `passt` (or `slirp4netns`) are enough; your user also needs subuid/subgid entries, which `usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ` sets up if the package did not (accounts created before shadow 4.11.1-3 get no entries automatically — see [Podman: Set subuid and subgid](https://wiki.archlinux.org/title/Podman#Set_subuid_and_subgid)): ```sh sudo pacman -S podman passt diff --git a/agent-container.py b/agent-container.py index a7312b6..c430f88 100755 --- a/agent-container.py +++ b/agent-container.py @@ -179,6 +179,12 @@ class AgentContainer: ) self.remove_container() + if self.container_exists() and not self._container_cmd_matches(): + logger.info( + "Recreating container: it predates the SIGTERM-aware PID 1" + ) + self.remove_container() + if not self.container_exists(): self.create_container() @@ -363,7 +369,89 @@ class AgentContainer: == 0 ) + # Subordinate ID block size; rootless Podman needs at least 65536 IDs so + # every container UID is mappable. 100000 is the default first block the + # shadow suite allocates on Arch (and the number the Arch Wiki suggests). + SUBID_SIZE = 65536 + SUBID_START = 100000 + + @classmethod + def _free_subid_range(cls) -> str: + """Return the first unclaimed 65536-ID block as "start-end". + + Ranges in /etc/subuid and /etc/subgid must never overlap between + users (a shared range would let one user impersonate another inside + containers), so both files' claims are considered. + """ + taken = [] + for path in ("/etc/subuid", "/etc/subgid"): + try: + with open(path) as f: + for line in f: + parts = line.split(":") + if len(parts) < 3: + continue + try: + taken.append( + (int(parts[1]), int(parts[1]) + int(parts[2])) + ) + except ValueError: + continue + except FileNotFoundError: + pass + taken.sort() + start = cls.SUBID_START + for lo, hi in taken: + if hi <= start: + continue + if lo - start >= cls.SUBID_SIZE: + break + start = max(start, hi) + return f"{start}-{start + cls.SUBID_SIZE - 1}" + + def _subid_guard(self) -> None: + """Warn when rootless Podman has no subuid/subgid ranges for this user. + + Without a range in /etc/subuid and /etc/subgid, Podman falls back to a + single-UID user namespace: the container only has the invoking user's + UID, and chown to any other UID fails with EINVAL. This breaks e.g. + pacman's download sandbox, which chowns its download directory to the + 'alpm' user. Distros like Fedora allocate ranges for every user + automatically, but accounts created manually (e.g. on Arch) may not + have one. + """ + if self.RUNTIME != "podman" or os.geteuid() == 0: + return + + def has_entry(path: str) -> bool: + try: + with open(path) as f: + return any(line.split(":", 1)[0] == self.host_username for line in f) + except FileNotFoundError: + return False + + missing = [ + p for p in ("/etc/subuid", "/etc/subgid") if not has_entry(p) + ] + if not missing: + return + + range_str = self._free_subid_range() + logger.error( + f"No subuid/subgid entry for user '{self.host_username}' in" + " /etc/subuid, /etc/subgid — rootless Podman has only a single UID" + " available, so operations inside containers that chown to other" + " UIDs (e.g. pacman's alpm download sandbox) fail with 'Invalid" + " argument'. Fix once per user:\n" + f" sudo usermod --add-subuids {range_str} --add-subgids {range_str} {self.host_username}\n" + " podman system migrate\n" + " See https://wiki.archlinux.org/title/Podman#Set_subuid_and_subgid" + " or https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md" + ) + sys.exit(1) + def build_image(self, no_cache: bool = False, pull: bool = False) -> None: + self._subid_guard() logger.info( f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})" ) @@ -431,7 +519,11 @@ class AgentContainer: self.IMAGE, "sh", "-c", - "sleep infinity", + # PID 1 ignores signals with default dispositions, so a bare + # `sleep infinity` never reacts to SIGTERM and every + # `podman stop` times out into SIGKILL. Trapping TERM (and + # INT) makes stop requests end the container immediately. + 'trap "exit 0" TERM INT; sleep infinity & wait', ] ) @@ -473,6 +565,26 @@ class AgentContainer: ).stdout return "AGENT_CONTAINER=1" in env + def _container_cmd_matches(self) -> bool: + """Whether the container runs the current PID 1 command. + + The PID 1 entrypoint is baked in at create time and can only change + by recreating the container. Comparing against Config.Cmd catches + containers created before the SIGTERM-aware fix.""" + cmd = subprocess.run( + [ + self.RUNTIME, + "container", + "inspect", + "-f", + "{{json .Config.Cmd}}", + self.container_name, + ], + capture_output=True, + text=True, + ).stdout.strip() + return "TERM INT" in cmd + def start_container(self) -> bool: subprocess.run( [self.RUNTIME, "start", self.container_name],