Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

4 changed files with 3 additions and 139 deletions

View file

@ -1,27 +1,5 @@
# 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,6 +1,4 @@
# Fully qualified registry: Podman on distros without an unqualified search FROM archlinux:latest
# 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 (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)): 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:
```sh ```sh
sudo pacman -S podman passt sudo pacman -S podman passt

View file

@ -179,12 +179,6 @@ 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()
@ -369,89 +363,7 @@ 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})"
) )
@ -519,11 +431,7 @@ class AgentContainer:
self.IMAGE, self.IMAGE,
"sh", "sh",
"-c", "-c",
# PID 1 ignores signals with default dispositions, so a bare "sleep infinity",
# `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',
] ]
) )
@ -565,26 +473,6 @@ 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],