Harden Podman backend and verify GPU passthrough end-to-end - #11
Conversation
…st fixes Ports JaneliaScientificComputingSystems/agentic-sandbox's battle-tested Podman wrapper hardening onto this repo's own Podman scripts: per-job storage isolation (concurrent jobs on one GPU node no longer corrupt each other's storage), staleness recovery after a node reboot, a catatonit orphan watchdog, exit-code-safe cleanup traps, and an opt-in --allow network egress allowlist. Adapted rather than copied verbatim -- this cluster has no /etc/subuid ranges, so per-job storage cleanup needed `podman unshare rm -rf` instead of a plain `rm -rf`. Verified end-to-end on a real gpu_l4 LSF allocation: Podman's NVIDIA CDI GPU passthrough (previously "blocked in testing"), two concurrent GPU sessions on the same node, and the egress allowlist against the real image. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mirrors the existing "marimo" (Apptainer) runnable but launches via pixi run marimo-podman, and exposes the new --allow egress-allowlist flag. Points to README.md's GPU passthrough and Podman storage isolation sections for why this backend is preferred. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…apper https-wrap.sh's backend auto-detection always preferred Apptainer when present, with no way to get Podman+HTTPS on a host that has both installed. Adds a BACKEND=apptainer|podman env var override, and a marimo-podman-https runnable that sets it, mirroring marimo-podman's --allow egress-allowlist parameter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the Podman container backend (storage isolation, staleness recovery, watchdog, and opt-in egress allowlisting) and wires it into Fileglancer runnables, while also adding a BACKEND=apptainer|podman override to enable Podman+HTTPS on hosts that have both backends installed.
Changes:
- Add new Fileglancer services
marimo-podmanandmarimo-podman-https, including--allowsupport for opt-in egress allowlisting. - Refactor Podman scripts to use a shared
container/podman/lib.shfor per-job storage isolation, cleanup, and a catatonit watchdog. - Add backend override selection to
container/https-wrap.shand update README paths/examples after the container script reorg.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| runnables.yaml | Adds Podman and Podman+HTTPS service runnables with parameters including --allow. |
| README.md | Updates repo layout/docs; documents Podman CDI verification, storage isolation, and allowlist behavior. |
| container/podman/shell.sh | Switches to lib-based storage/network setup and watched podman run for interactive sessions. |
| container/podman/relay.py | Adds in-container TCP↔UDS relay used for the egress allowlist proxy pattern. |
| container/podman/marimo.sh | Switches to lib-based storage/network setup and watched podman run, plus allowlist support. |
| container/podman/lib.sh | Introduces Podman hardening helpers: shared storage config, per-job isolation, watchdog, allowlist wiring. |
| container/podman/build.sh | Refactors build to use lib-based shared storage setup. |
| container/podman/allowlist_proxy.py | Adds host-side allowlist forward proxy for HTTP/HTTPS (CONNECT) over a Unix socket. |
| container/https-wrap.sh | Adds `BACKEND=apptainer |
| container/common.sh | Adds ALLOW_HOSTS parsing and --allow flag support passed through to Podman scripts. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # ALLOW_HOSTS -- space-separated hostnames allowed through the Podman | ||
| # egress allowlist (container/podman/{marimo,shell}.sh only); default | ||
| # unset, i.e. no allowlist -- those scripts keep their existing | ||
| # unrestricted --net=host behavior. See podman_network_setup below. |
| --allow) | ||
| [[ -n "${2:-}" ]] && ALLOW_HOSTS="${ALLOW_HOSTS:-}${ALLOW_HOSTS:+ }$2" | ||
| shift 2 | ||
| ;; |
| podman_network_setup() { | ||
| local script_dir="$1" | ||
| PODMAN_PROXY_PID="" | ||
| PODMAN_PROXY_SOCK="" | ||
| PODMAN_INNER_ENTRYPOINT="" | ||
| PODMAN_INNER_ARGS=() | ||
|
|
||
| if [[ -z "${ALLOW_HOSTS// /}" ]]; then | ||
| PODMAN_NETWORK_ARGS=(--net=host) | ||
| return 0 | ||
| fi | ||
|
|
||
| PODMAN_PROXY_SOCK="$(mktemp -u /tmp/podman-sandbox-proxy.XXXXXX.sock)" | ||
| # shellcheck disable=SC2086 -- ALLOW_HOSTS is a space-separated list, | ||
| # intentionally word-split into multiple allowlist_proxy.py arguments. | ||
| python3 "$script_dir/allowlist_proxy.py" "$PODMAN_PROXY_SOCK" $ALLOW_HOSTS \ | ||
| > "${PODMAN_PROXY_SOCK}.log" 2>&1 & | ||
| PODMAN_PROXY_PID=$! | ||
| sleep 1 | ||
|
|
| # podman_network_cleanup -- stop the host-side allowlist proxy (if any) and | ||
| # remove its socket. No-op when the allowlist was never enabled. | ||
| podman_network_cleanup() { | ||
| [[ -n "${PODMAN_PROXY_PID:-}" ]] && kill "$PODMAN_PROXY_PID" 2>/dev/null | ||
| [[ -n "${PODMAN_PROXY_SOCK:-}" && -e "$PODMAN_PROXY_SOCK" ]] && rm -f "$PODMAN_PROXY_SOCK" | ||
| return 0 | ||
| } |
| # Plain HTTP: read headers, find Host:, forward raw bytes. | ||
| headers = first_line | ||
| host = None | ||
| while True: | ||
| line = await reader.readline() | ||
| headers += line | ||
| if line.lower().startswith(b"host:"): | ||
| host = line.split(b":", 1)[1].strip().decode("latin1").split(":")[0] | ||
| if line in (b"\r\n", b""): | ||
| break | ||
| if not host or not host_allowed(host, allowlist): | ||
| print(f"[allowlist_proxy] DENY HTTP {host} (peer={peer})", file=sys.stderr) | ||
| writer.write(b"HTTP/1.1 403 Forbidden\r\n\r\n") | ||
| await writer.drain() | ||
| writer.close() | ||
| return | ||
| print(f"[allowlist_proxy] ALLOW HTTP {host} (peer={peer})", file=sys.stderr) | ||
| try: | ||
| remote_reader, remote_writer = await asyncio.open_connection(host, 80) | ||
| except OSError: | ||
| writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") | ||
| await writer.drain() | ||
| writer.close() | ||
| return |
… bug - container/podman/lib.sh: use `mktemp -d` for a private proxy directory instead of `mktemp -u` on the socket path directly -- `-u` only prints a name it thinks is free, with no exclusive creation, so a concurrent process (or an attacker in world-writable /tmp) can win the race. Also fail fast (clear error + exit 1) if allowlist_proxy.py dies immediately instead of silently continuing with a broken proxy, and have podman_network_cleanup remove the whole per-invocation directory (log included) instead of just the socket file. - container/common.sh: --allow now errors clearly if the hostname argument is missing, instead of an unconditional `shift 2` failing with a generic "shift count out of range" under `set -e`. Also fixes a comment that pointed at "podman_network_setup below" in this file, when the function actually lives in container/podman/lib.sh. - container/podman/allowlist_proxy.py: the plain-HTTP (non-CONNECT) path always connected to port 80, discarding any port in the Host: header (e.g. Host: example.com:8080) -- broke allowlisted HTTP traffic to any non-80 port. Now parses and honors the actual port. All four fixes verified directly: --allow with no argument now errors instead of crashing; the proxy directory is created privately (0700) and fully removed on cleanup; a deliberately-broken proxy invocation now fails fast with the log printed; a unit test against handle_client() confirms Host: example.com:8080 now connects to port 8080, not 80. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed all 5 Copilot review comments in 8133931:
All four fixes verified live (proxy directory permissions/cleanup, |
…on3 use - container/https-wrap.sh: the --allow incompatibility comment still said "Podman's network egress allowlist" and pointed at container/podman/lib.sh specifically, but the allowlist is backend-agnostic (common.sh + both backends' lib.sh) -- confusing when https-wrap.sh is actually running Apptainer. Reworded to be backend-neutral. - container/common.sh: network_allowlist_proxy_start's bare `python3` looked inconsistent with this same file's _CONFIG/tomllib section, which explicitly avoids bare python3 in favor of the pixi-managed one. Added a comment explaining why that concern doesn't apply here: allowlist_proxy.py only needs stdlib asyncio (no tomllib/3.11+ requirement), and it has to run on the host BEFORE any container starts -- at which point $WORK/.pixi (the "Python this repo guarantees") may not exist yet on a genuinely first-ever run, so it can't be relied on for this specific script. Behavior unchanged; agentic-sandbox's own allowlist_proxy.py makes the same assumption. (The mktemp -u race this same Copilot review flagged in common.sh was already fixed while addressing PR #11's review and carried forward through the rebase -- confirmed still fixed on this branch.) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on3 use - container/https-wrap.sh: the --allow incompatibility comment still said "Podman's network egress allowlist" and pointed at container/podman/lib.sh specifically, but the allowlist is backend-agnostic (common.sh + both backends' lib.sh) -- confusing when https-wrap.sh is actually running Apptainer. Reworded to be backend-neutral. - container/common.sh: network_allowlist_proxy_start's bare `python3` looked inconsistent with this same file's _CONFIG/tomllib section, which explicitly avoids bare python3 in favor of the pixi-managed one. Added a comment explaining why that concern doesn't apply here: allowlist_proxy.py only needs stdlib asyncio (no tomllib/3.11+ requirement), and it has to run on the host BEFORE any container starts -- at which point $WORK/.pixi (the "Python this repo guarantees") may not exist yet on a genuinely first-ever run, so it can't be relied on for this specific script. Behavior unchanged; agentic-sandbox's own allowlist_proxy.py makes the same assumption. (The mktemp -u race this same Copilot review flagged in common.sh was already fixed while addressing PR #11's review and carried forward through the rebase -- confirmed still fixed on this branch.) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
README.md:216
- README still references
start.sh/shell.sh, but those top-level scripts no longer exist after thecontainer/{apptainer,podman}/reorg. This can confuse users following the read-only/autofs guidance; point to the currentpixi run marimo/pixi run shellentrypoints instead.
> `/groups/scicompsoft`. `start.sh`/`shell.sh` **default `RO_PATHS` to your lab
> dirs** (`/groups/scicompsoft`, `/nrs/scicompsoft`) and **refuse** bare autofs
> parents. Set your own with `RO_PATHS="/groups/<lab> /nrs/<lab> ..."`, or
| -c | ||
| 'python3 /opt/relay.py 127.0.0.1 '"$relay_port"' /run/proxy.sock & | ||
| sleep 1 | ||
| export http_proxy=http://127.0.0.1:'"$relay_port"' https_proxy=http://127.0.0.1:'"$relay_port"' |
Several places (README, code comments) asserted "this cluster has no
/etc/subuid/subgid ranges" as a blanket, permanent fact. Confirmed
otherwise: it's a per-account default that HPC can and does grant on
request -- this repo's own author's account just had one added. Reworded
to match agentic-sandbox's own prerequisite framing ("reach out to the
HPC team first"), and clarified that the existing no-range workarounds
(ignore_chown_errors, TAR_OPTIONS=--no-same-owner, podman unshare rm -rf)
are expected to remain harmless for a range-enabled account too, though
that combination hasn't been verified live yet -- and that this repo
doesn't yet take advantage of a range being present (e.g. --userns=keep-id
for real-UID-instead-of-root identity), which is a real possible
follow-up now that ranges are requestable, not something implemented.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on3 use - container/https-wrap.sh: the --allow incompatibility comment still said "Podman's network egress allowlist" and pointed at container/podman/lib.sh specifically, but the allowlist is backend-agnostic (common.sh + both backends' lib.sh) -- confusing when https-wrap.sh is actually running Apptainer. Reworded to be backend-neutral. - container/common.sh: network_allowlist_proxy_start's bare `python3` looked inconsistent with this same file's _CONFIG/tomllib section, which explicitly avoids bare python3 in favor of the pixi-managed one. Added a comment explaining why that concern doesn't apply here: allowlist_proxy.py only needs stdlib asyncio (no tomllib/3.11+ requirement), and it has to run on the host BEFORE any container starts -- at which point $WORK/.pixi (the "Python this repo guarantees") may not exist yet on a genuinely first-ever run, so it can't be relied on for this specific script. Behavior unchanged; agentic-sandbox's own allowlist_proxy.py makes the same assumption. (The mktemp -u race this same Copilot review flagged in common.sh was already fixed while addressing PR #11's review and carried forward through the rebase -- confirmed still fixed on this branch.) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (6)
Previously missed (4) — in code that hasn't changed since the last review.
container/podman/lib.sh:36
- The Podman shared storage directories are created with the caller's umask, which can leave them world-readable/executable (e.g. 755). Since these directories can contain pulled layers and runtime metadata, it's safer to force restrictive permissions (700) after creation.
This issue also appears on line 78 of the same file.
PODMAN_STORAGE_ROOT="${PODMAN_STORAGE_ROOT:-/scratch/$(id -un)/podman-storage}"
PODMAN_RUN_ROOT="${PODMAN_RUN_ROOT:-/tmp/podman-run-$(id -u)/run}"
mkdir -p "$PODMAN_STORAGE_ROOT" "$PODMAN_RUN_ROOT"
container/podman/lib.sh:140
- With
set -eenabled in the callers, thegrep ... && kill ...construct can cause the whole script to exit ifkill -9races and returns non-zero (thekillis the final command in an&&list). Make the kill best-effort so the watchdog never turns a benign race into a hard failure.
This issue also appears on line 178 of the same file.
for pid in $(pgrep -u "$USER" -x catatonit 2>/dev/null); do
[[ "$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" == "1" ]] || continue
grep -q "$job_dir" "/proc/$pid/mountinfo" 2>/dev/null && kill -9 "$pid" 2>/dev/null
done
container/podman/allowlist_proxy.py:135
host_allowed()lowercases the incoming host, but the allowlist values are not normalized. If a user passes an uppercase hostname (or mixed-case), it will be denied unexpectedly. Normalize the allowlist entries to lowercase once in main().
sock_path = sys.argv[1]
allowlist = sys.argv[2:]
print(f"[allowlist_proxy] listening on {sock_path}, allowlist={allowlist}", file=sys.stderr)
container/podman/lib.sh:270
podman_network_cleanupis invoked from EXIT traps in scripts that run withset -e. If the proxy process already exited,kill "$PODMAN_PROXY_PID"can return non-zero and cause the cleanup trap to fail/override the script's exit status. Make the kill best-effort.
podman_network_cleanup() {
[[ -n "${PODMAN_PROXY_PID:-}" ]] && kill "$PODMAN_PROXY_PID" 2>/dev/null
[[ -n "${PODMAN_PROXY_DIR:-}" && -e "$PODMAN_PROXY_DIR" ]] && rm -rf "$PODMAN_PROXY_DIR"
return 0
container/podman/lib.sh:81
- Per-job storage directories are also created with default permissions, which can expose job-local container metadata to other users on a shared /scratch. Consider forcing 700 on the job directory and its root/run subdirs.
local jobtag="${LSB_JOBID:-$$-$RANDOM}"
PODMAN_JOB_STORAGE_DIR="/scratch/$(id -un)/podman-jobs/$jobtag"
mkdir -p "$PODMAN_JOB_STORAGE_DIR/root" "$PODMAN_JOB_STORAGE_DIR/run"
PODMAN_GLOBAL_ARGS=(
container/podman/lib.sh:182
podman_run_watchedruns underset -e(inherited from the caller). If the watchdog process exits quickly,kill "$watchdog_pid"can return non-zero (no such process) and prematurely abort the script, potentially masking the realpodman runexit code. Make the kill best-effort.
local exit_code=0
wait "$podman_pid" || exit_code=$?
kill "$watchdog_pid" 2>/dev/null
wait "$watchdog_pid" 2>/dev/null || true
_podman_kill_orphaned_catatonit "$job_dir"
…on3 use - container/https-wrap.sh: the --allow incompatibility comment still said "Podman's network egress allowlist" and pointed at container/podman/lib.sh specifically, but the allowlist is backend-agnostic (common.sh + both backends' lib.sh) -- confusing when https-wrap.sh is actually running Apptainer. Reworded to be backend-neutral. - container/common.sh: network_allowlist_proxy_start's bare `python3` looked inconsistent with this same file's _CONFIG/tomllib section, which explicitly avoids bare python3 in favor of the pixi-managed one. Added a comment explaining why that concern doesn't apply here: allowlist_proxy.py only needs stdlib asyncio (no tomllib/3.11+ requirement), and it has to run on the host BEFORE any container starts -- at which point $WORK/.pixi (the "Python this repo guarantees") may not exist yet on a genuinely first-ever run, so it can't be relied on for this specific script. Behavior unchanged; agentic-sandbox's own allowlist_proxy.py makes the same assumption. (The mktemp -u race this same Copilot review flagged in common.sh was already fixed while addressing PR #11's review and carried forward through the rebase -- confirmed still fixed on this branch.) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
catatonitorphan watchdog, exit-code-safe cleanup traps, and an opt-in--allownetwork egress allowlist./etc/subuidranges, so per-job storage cleanup neededpodman unshare rm -rfinstead of a plainrm -rf(confirmed live -- a plainrm -rfhitPermission deniedon every file).marimo-podmanentry torunnables.yaml(mirroring the existing Apptainermarimorunnable), since Podman is the backend preferred by HPC admins.marimo-podman-httpsrunnable, plus aBACKEND=apptainer|podmanoverride incontainer/https-wrap.sh-- the HTTPS wrapper's backend auto-detection previously always preferred Apptainer when present, with no way to get Podman+HTTPS on a host that has both installed.container/{apptainer,podman}/reorganization.Verification
Verified end-to-end on a real
gpu_l4LSF allocation:nvidia-smi -Linside the container now returns a real GPU UUID.One caveat documented transparently in the README rather than overclaiming: an orphaned
catatonitoccasionally survived the watchdog in testing (observed once), matching a known low-frequency residual risk agentic-sandbox's own investigation documents, not something this port introduced.Test plan
bash -non all modified/new shell scriptspython3 -m py_compileon the vendoredallowlist_proxy.py/relay.pypython3 -c "import yaml; yaml.safe_load(...)"onrunnables.yamlgpu_l4LSF job: GPU passthrough, concurrency, and allowlist tests (see above)marimo.sh/shell.shwith no new flags against the real published imageBACKEND=apptainer|podman|""|bogusoverride logic exercised directly (correct task selection, clear error on invalid value)🤖 Generated with Claude Code