Skip to content

Commit 400d0ff

Browse files
FlyM1ssclaude
andauthored
fix(deploy): chown read-only-rootfs tmpfs in root-init; drop podman-rejected tmpfs uid= (#333 deploy fix) (#337)
The droplet's podman 5.6.2 rejects `--tmpfs=...:uid=1001,gid=1001` outright (`Error: unknown mount option "uid=1001": invalid mount option`; and there is no working `--mount type=tmpfs,tmpfs-uid=` either). This fail-closed #333's pre-cutover BOOT_CHECK gate on the first push-to-main deploy. build+test were green because nothing there runs podman -- and test_tmpfs_options_pinned even pinned the rejected string, so the test agreed with the workflow but not with reality. The gate aborted before cutover, so prod kept serving the pre-#333 image (no outage; the gate did its job). Restore uid1001 ownership the way podman 5.6.2 supports: - mount /home/fingpt and /app/staticfiles at mode=0755 (owner-writable once chowned, not the tmpfs-default world-writable 1777), on both the gate and the ExecStart podman run (flag parity preserved); - chown both dirs to fingpt in entrypoint.sh root-init, before the setpriv drop, while PID1 still holds CAP_CHOWN. Non-recursive is sufficient: only the tmpfs mount point comes up root-owned; tmpcopyup content (baked ~/.cache/fontconfig) is already fingpt-owned. This is behaviorally identical to the intended-but- rejected uid=1001 tmpfs. Correct the invariant that hid the bug: test_tmpfs_options_pinned now asserts no uid=/gid= on the tmpfs (+ mode=0755), and a new test_entrypoint_chowns_read_only_tmpfs_dirs pins the root-init chown ordering. Verified on the droplet against the real #333 image: podman accepts the new flags, root-init chown yields `drwxr-xr-x 1001 1001`, and a setpriv-dropped uid1001 writes both dirs successfully. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 965d214 commit 400d0ff

3 files changed

Lines changed: 61 additions & 11 deletions

File tree

.github/workflows/backend-deploy.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ jobs:
237237
# and ops/egress_firewall.py consume REDIS_URL, and the firewall
238238
# self-test checks host:port reachability only, DB-agnostic).
239239
echo "Validating full boot (root-init + read-only rootfs + /health/) on the new image (pre-cutover)..."
240-
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --tmpfs=/app/runtime:rw,size=512m --memory=1.7g --memory-swap=2g --network fingpt-net \
240+
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --tmpfs=/app/runtime:rw,size=512m --memory=1.7g --memory-swap=2g --network fingpt-net \
241241
--env-file /home/deploy/fingpt/envs/.env.production \
242242
--env REDIS_URL=redis://fingpt-redis:6379/15 \
243243
--env BOOT_CHECK_ONLY=1 \
@@ -287,10 +287,16 @@ jobs:
287287
# (--disable-dev-shm-usage), nft mktemp at root-init,
288288
# /tmp/fingpt_cache; sized + world-writable-sticky;
289289
# /app/staticfiles collectstatic writes it at boot (0 files today, kept
290-
# writable as future-proofing); uid1001 tmpfs;
290+
# writable as future-proofing); mode=0755 tmpfs,
291+
# chowned to fingpt by root-init (see below);
291292
# /home/fingpt fontconfig cache, edgartools ~/.edgar import-time
292-
# marker, yfinance cache; MUST carry uid=1001,gid=1001
293-
# or the MCP-child EACCES bug (#331 class) returns.
293+
# marker, yfinance cache; MUST end up fingpt-owned or
294+
# the MCP-child EACCES bug (#331 class) returns. podman
295+
# 5.6.2 rejects tmpfs uid=/gid= mount options outright,
296+
# so ownership is NOT set here -- entrypoint.sh root-init
297+
# chowns both dirs while PID1 still holds CAP_CHOWN.
298+
# mode=0755 (not the tmpfs default 1777) lands them
299+
# owner-writable, not world-writable.
294300
# /app/logs and /app/media are vestigial with ZERO writers -- deliberately
295301
# NOT tmpfs, so a future stray write fails LOUDLY instead of vanishing
296302
# into RAM. Playwright's DEPENDENCIES_VALIDATED marker is pre-baked at
@@ -307,7 +313,7 @@ jobs:
307313
cat > "$OVERRIDE_DIR/override.conf" <<EOF
308314
[Service]
309315
ExecStart=
310-
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
316+
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
311317
EOF
312318
313319
systemctl --user daemon-reload

Main/backend/entrypoint.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ if [ "$(id -u)" = "0" ]; then
3232
echo "SSRF egress firewall loaded and self-tested."
3333
# :U chowned the runtime mount to root (PID1 is root); hand it to the app user.
3434
chown -R fingpt:fingpt /app/runtime
35+
# Under --read-only rootfs (#333), /home/fingpt and /app/staticfiles are fresh
36+
# tmpfs mounts that MASK the image's build-time ownership and come up root-owned.
37+
# The droplet's podman (5.6.2) has no tmpfs uid= option to fix that at mount time
38+
# -- it rejects `--tmpfs=...:uid=1001` outright (see backend-deploy.yml and
39+
# test_dockerfile_nonroot.test_tmpfs_options_pinned) -- so hand them to the app
40+
# user HERE, while PID1 still holds CAP_CHOWN. Non-recursive: both are freshly
41+
# mounted empty tmpfs. Without this, uid1001 (and every MCP stdio child writing
42+
# $HOME) hits EACCES -- the #331 class the BOOT_CHECK_ONLY gate exists to catch.
43+
chown fingpt:fingpt /home/fingpt /app/staticfiles
3544
# Marker (env survives setpriv) so the app phase can refuse to serve if it was ever
3645
# reached WITHOUT this root-init firewall load (e.g. a mistaken non-root PID1 start).
3746
# A MISTAKE-GUARD ONLY, not a security boundary: trivially spoofable via -e/--env-file,

Main/backend/tests/test_dockerfile_nonroot.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,31 @@ def test_runtime_user_has_writable_home(self):
126126
chown = next(l for l in self.lines if "chown -R fingpt:fingpt" in l)
127127
self.assertIn("/home/fingpt", chown)
128128

129+
def test_entrypoint_chowns_read_only_tmpfs_dirs(self):
130+
# Under the #333 --read-only rootfs, /home/fingpt and /app/staticfiles are
131+
# fresh tmpfs mounts that MASK the image's build-time ownership, so they
132+
# come up root-owned. podman 5.6.2 has no tmpfs uid= option to fix that at
133+
# mount time (test_tmpfs_options_pinned), so the root-init phase must chown
134+
# them to fingpt itself -- before the setpriv drop, while it still holds
135+
# CAP_CHOWN as root-in-userns. Without it uid1001 cannot write $HOME and the
136+
# sec-edgar MCP child dies with EACCES (the #331 class the BOOT_CHECK_ONLY
137+
# gate exists to catch).
138+
lines = _read(ENTRYPOINT_SH).splitlines()
139+
start = next(i for i, l in enumerate(lines) if "id -u" in l and "then" in l)
140+
end = next(i for i in range(start + 1, len(lines)) if lines[i].strip() == "fi")
141+
root_init_chowns = [
142+
l for l in lines[start + 1:end]
143+
if "chown" in l and not l.strip().startswith("#")
144+
]
145+
joined = " ".join(root_init_chowns)
146+
for d in ("/home/fingpt", "/app/staticfiles"):
147+
self.assertIn(d, joined, f"root-init must chown {d} (read-only tmpfs ownership)")
148+
# Every chown must precede the setpriv drop, or it runs as uid1001 and EPERMs.
149+
setpriv_idx = next(i for i, l in enumerate(lines) if l.lstrip().startswith("exec setpriv"))
150+
for i, l in enumerate(lines):
151+
if "chown" in l and not l.strip().startswith("#"):
152+
self.assertLess(i, setpriv_idx, f"chown on line {i} must precede setpriv")
153+
129154
def test_playwright_dependencies_marker_prebaked_for_read_only_rootfs(self):
130155
# Playwright writes a 0-byte DEPENDENCIES_VALIDATED marker next to the
131156
# browser executable at FIRST launch (host-requirements validation cache).
@@ -461,14 +486,24 @@ def test_tmpfs_target_sets_frozen(self):
461486

462487
def test_tmpfs_options_pinned(self):
463488
# /tmp must be sized (unbounded tmpfs is a memory-DoS surface) and
464-
# world-writable-sticky (1777: Chromium/nft/cache all write it as
465-
# uid1001); the uid1001 dirs must carry uid=1001,gid=1001 -- a bare tmpfs
466-
# mounts root-owned and resurrects the EACCES class documented at the
467-
# Dockerfile HOME comment.
489+
# world-writable-sticky (1777: Chromium/nft/cache all write it as uid1001).
490+
#
491+
# The uid1001 dirs (/home/fingpt, /app/staticfiles) must NOT carry
492+
# uid=/gid= tmpfs mount options: the droplet's podman (5.6.2) rejects them
493+
# outright -- `Error: unknown mount option "uid=1001": invalid mount
494+
# option` -- which fail-closed the #333 pre-cutover gate on the very first
495+
# push-to-main deploy (build+test were green because nothing here ever runs
496+
# podman). A tmpfs always mounts root-owned, so ownership is instead
497+
# restored inside the container by the root-init chown
498+
# (test_entrypoint_chowns_read_only_tmpfs_dirs); mode=0755 lands them
499+
# owner-writable (not world-writable) once chowned to fingpt.
468500
for line in (self._execstart_line(), self._gate_line()):
469501
self.assertIn("--tmpfs=/tmp:rw,size=512m,mode=1777", line)
470-
self.assertIn("--tmpfs=/app/staticfiles:rw,uid=1001,gid=1001", line)
471-
self.assertIn("--tmpfs=/home/fingpt:rw,uid=1001,gid=1001", line)
502+
self.assertIn("--tmpfs=/app/staticfiles:rw,mode=0755", line)
503+
self.assertIn("--tmpfs=/home/fingpt:rw,mode=0755", line)
504+
# podman 5.6.2 rejects tmpfs uid=/gid=; never reintroduce them.
505+
self.assertNotIn("uid=1001", line)
506+
self.assertNotIn("gid=1001", line)
472507
self.assertIn("--tmpfs=/app/runtime:rw,size=512m", self._gate_line())
473508

474509
def test_execstart_read_only_rootfs(self):

0 commit comments

Comments
 (0)