Compare commits

...

3 commits
v1.0.0 ... main

Author SHA1 Message Date
fcbfa32545 container: Stop gracefully on podman stop
Containers used `sleep infinity` as PID 1, which never reacts to
SIGTERM: the kernel does not apply default signal dispositions to PID 1,
and sleep has no SIGTERM handler, so every `podman stop` silently
ignored the signal, waited out the full 10-second grace period, and fell
back to SIGKILL.

PID 1 now traps TERM and INT itself and exits on request, so one-off
manual stops of a still-running agent container end immediately instead
of after the kill timeout.

The PID 1 command is baked in at create time, so containers predating
this change are detected and recreated automatically on the next run.
2026-10-29 18:56:22 +09:00
43dc757b92 container: Use fully qualified image name in FROM
Podman only resolves short image names through an unqualified search
registry, which is not configured by default on Arch (and on Ubuntu
setups without a registries.d file), so builds fail at the first step
with 'short-name "archlinux" did not resolve'. Naming the registry
explicitly makes the image build on any distro without per-distro
configuration.
2026-10-29 08:42:06 +09:00
8a7af06983 container: Fail early on missing subuid/subgid ranges
Rootless Podman falls back to a single-UID user namespace when the
invoking user has no /etc/subuid and /etc/subgid entry, and then any
chown to an unmapped UID fails with EINVAL. Newer pacman versions hit
this during builds and updates: their download sandbox chowns its
temporary download directory to the 'alpm' user, which made image
builds fail with a cryptic traceback after several minutes of output.

Accounts created manually or before shadow 4.11.1-3 (October 2022) get
no subordinate ID ranges automatically, while freshly created ones do.
Rather than working around this in the image (disabling pacman's
sandbox would silently drop a security feature), detect the missing
ranges before building and stop with the exact fix, using the first
unclaimed 65536-ID block so the printed command never collides with
other users' ranges. Link the Arch Wiki section and Podman's rootless
tutorial for background.
2026-10-29 08:33:23 +09:00
4 changed files with 139 additions and 3 deletions

View file

@ -1,5 +1,27 @@
# Release notes # 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 ## 1.0.0 — 2026-10-29
Containers now run with rootless Podman instead of Docker. Containers now run with rootless Podman instead of Docker.

View file

@ -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 USERNAME=dev
ARG UID=1000 ARG UID=1000

View file

@ -69,7 +69,7 @@ socket goes away.
## Install ## 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 <user>` 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 <user>` 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 ```sh
sudo pacman -S podman passt sudo pacman -S podman passt

View file

@ -179,6 +179,12 @@ class AgentContainer:
) )
self.remove_container() 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(): if not self.container_exists():
self.create_container() self.create_container()
@ -363,7 +369,89 @@ class AgentContainer:
== 0 == 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: def build_image(self, no_cache: bool = False, pull: bool = False) -> None:
self._subid_guard()
logger.info( logger.info(
f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})" f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})"
) )
@ -431,7 +519,11 @@ class AgentContainer:
self.IMAGE, self.IMAGE,
"sh", "sh",
"-c", "-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 ).stdout
return "AGENT_CONTAINER=1" in env 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: def start_container(self) -> bool:
subprocess.run( subprocess.run(
[self.RUNTIME, "start", self.container_name], [self.RUNTIME, "start", self.container_name],