Skip to content

Commit 3d6c034

Browse files
authored
validate + re-land #37: ingest workers as abc + chown migration (#56)
* Run ingest service python workers as abc, not root (#37) With PUID/PGID set (the documented configuration), cps.py already drops to abc via s6-setuidgid in svc-calibre-web-automated/run, and the inotifywait call in cwa-ingest-service/run is also prefixed with s6-setuidgid abc. The three other python invocations in the same run script (watch_fallback.py on the polling path and the two ingest_processor.py calls) run as root, so every auto-imported book lands in /calibre-library as root:root. The web UI, running as abc, then fails to delete these books with Permission denied on metadata.opf. Prefix each of those python3 invocations with s6-setuidgid abc so the polling watcher and the ingest processor run with the same uid and gid as the rest of the stack, producing abc-owned files that the web UI can manage. Co-authored-by: new-usemame <248195428+new-usemame@users.noreply.github.com> * feat(s6): one-time chown-to-abc migration alongside #37 re-land #37 (held by #54 from v4.0.16) prefixes the ingest-service python workers with s6-setuidgid abc. Architecturally correct — the web UI runs as abc and was failing to delete books that the ingest worker had created as root. But for users whose libraries already contain root:root-owned author/series subdirectories from before the patch landed, post-#37 ingest of a new book targeting an existing root-owned author dir fails with EACCES. Empirically demonstrated by tests/integration/test_ingest_setuidgid_permission.py: | Scenario | Result | |---------------------------------------------------------|---------------| | Pre-#37 root worker writes into root-owned subdir | PASS | | Post-#37 abc worker writes into root-owned subdir | EACCES | | Post-#37 abc worker after chown -R abc:abc /cal-lib | PASS | | Post-#37 abc worker into clean abc-owned library | PASS | Fix: a new oneshot s6 service cwa-chown-library-migration that runs once per installation: - Gated by /config/.cwa-chown-library-done sentinel (idempotent across reboots; second invocation no-ops) - Skipped when NETWORK_SHARE_MODE is set — chown over NFS is slow and may be refused by the export's policy. Logs guidance for manual run; still writes the sentinel so the skip is recorded. - Best-effort: chown failures are logged but never block boot. cwa-ingest-service is wired to depend on the migration via dependencies.d/cwa-chown-library-migration so the chown completes before any ingest worker starts. 8 integration tests in test_ingest_setuidgid_permission.py exercise the real artifacts inside the v4.0.15 production image (skipped when Docker is unavailable): - baseline pre-#37 behavior - post-#37 regression visibility - migration script chowns + writes sentinel - migration script is idempotent - migration script honors NETWORK_SHARE_MODE - end-to-end: post-migration ingest into formerly root-owned dir works Co-author: @haraldpdl (upstream PR #1290). --------- Co-authored-by: new-usemame <248195428+new-usemame@users.noreply.github.com>
1 parent cc13678 commit 3d6c034

8 files changed

Lines changed: 352 additions & 3 deletions

File tree

root/etc/s6-overlay/s6-rc.d/cwa-chown-library-migration/dependencies.d/cwa-init

Whitespace-only changes.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/with-contenv bash
2+
# One-time chown migration so the abc-uid ingest worker (introduced when
3+
# cwa-ingest-service started prefixing every python3 invocation with
4+
# `s6-setuidgid abc`) can write into author/series subdirectories that
5+
# pre-existing CWA deployments left as root:root.
6+
#
7+
# Without this migration, users upgrading from <= v4.0.16 hit
8+
# cp: cannot create regular file '/calibre-library/<Author>/<Title>/...':
9+
# Permission denied
10+
# the moment they try to ingest a new book by an author who already has a
11+
# root-owned directory in the library.
12+
#
13+
# Behavior:
14+
# - Gated by /config/.cwa-chown-library-done sentinel — runs once per
15+
# installation, then no-ops on every subsequent boot.
16+
# - Skipped when NETWORK_SHARE_MODE is enabled, because chown over an
17+
# NFS export is slow and may be refused by the share's policy. The
18+
# sentinel is still written so the skip is recorded; NFS users get a
19+
# log line telling them to run the chown manually if ingest later
20+
# fails with EACCES.
21+
# - Best-effort: any chown failure is logged but does not block boot.
22+
23+
set -e
24+
25+
MARKER=/config/.cwa-chown-library-done
26+
LIBRARY=/calibre-library
27+
INGEST=/cwa-book-ingest
28+
29+
if [ -f "$MARKER" ]; then
30+
echo "[cwa-chown-library-migration] sentinel present, skipping"
31+
exit 0
32+
fi
33+
34+
if [ "${NETWORK_SHARE_MODE,,}" = "true" ] || [ "${NETWORK_SHARE_MODE}" = "1" ] \
35+
|| [ "${NETWORK_SHARE_MODE,,}" = "yes" ] || [ "${NETWORK_SHARE_MODE,,}" = "on" ]; then
36+
echo "[cwa-chown-library-migration] NETWORK_SHARE_MODE=true — skipping migration"
37+
echo "[cwa-chown-library-migration] If post-upgrade ingest fails with EACCES, manually run:"
38+
echo "[cwa-chown-library-migration] docker exec <container> chown -R abc:abc $LIBRARY $INGEST"
39+
mkdir -p /config 2>/dev/null || true
40+
touch "$MARKER" 2>/dev/null || true
41+
exit 0
42+
fi
43+
44+
echo "[cwa-chown-library-migration] aligning $LIBRARY and $INGEST ownership to abc:abc..."
45+
46+
if [ -d "$LIBRARY" ]; then
47+
chown -R abc:abc "$LIBRARY" 2>/dev/null || \
48+
echo "[cwa-chown-library-migration] WARN: chown $LIBRARY had partial failures"
49+
echo "[cwa-chown-library-migration] $LIBRARY: aligned"
50+
fi
51+
52+
if [ -d "$INGEST" ]; then
53+
chown -R abc:abc "$INGEST" 2>/dev/null || \
54+
echo "[cwa-chown-library-migration] WARN: chown $INGEST had partial failures"
55+
echo "[cwa-chown-library-migration] $INGEST: aligned"
56+
fi
57+
58+
mkdir -p /config 2>/dev/null || true
59+
touch "$MARKER"
60+
echo "[cwa-chown-library-migration] migration complete; sentinel: $MARKER"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
oneshot
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/etc/s6-overlay/s6-rc.d/cwa-chown-library-migration/run

root/etc/s6-overlay/s6-rc.d/cwa-ingest-service/dependencies.d/cwa-chown-library-migration

Whitespace-only changes.

root/etc/s6-overlay/s6-rc.d/cwa-ingest-service/run

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ wait_for_stable_file() {
111111

112112
run_fallback() {
113113
echo "[cwa-ingest-service] Falling back to polling watcher" >&2
114-
python3 /app/calibre-web-automated/scripts/watch_fallback.py --path "$WATCH_FOLDER" --interval 5 |
114+
s6-setuidgid abc python3 /app/calibre-web-automated/scripts/watch_fallback.py --path "$WATCH_FOLDER" --interval 5 |
115115
while read -r events filepath; do
116116
handle_event "$filepath"
117117
done
@@ -155,7 +155,7 @@ process_retry_queue() {
155155
echo "[cwa-ingest-service] Retrying: $queued_file"
156156
local configured_timeout=$(get_timeout_from_db) # Get configured timeout from database
157157
local safety_timeout=$((configured_timeout * 3)) # Safety timeout is 3x the configured timeout
158-
timeout $safety_timeout python3 /app/calibre-web-automated/scripts/ingest_processor.py "$queued_file"
158+
timeout $safety_timeout s6-setuidgid abc python3 /app/calibre-web-automated/scripts/ingest_processor.py "$queued_file"
159159
local retry_exit=$?
160160

161161
if [ $retry_exit -eq 2 ]; then
@@ -213,7 +213,7 @@ handle_event() {
213213
echo "processing:$filename:$(date '+%Y-%m-%d %H:%M:%S')" > "$STATUS_FILE"
214214

215215
# Use safety timeout as last resort - processor should handle its own timeout internally
216-
timeout $safety_timeout python3 /app/calibre-web-automated/scripts/ingest_processor.py "$filepath"
216+
timeout $safety_timeout s6-setuidgid abc python3 /app/calibre-web-automated/scripts/ingest_processor.py "$filepath"
217217
local exit_code=$?
218218

219219
if [ $exit_code -eq 124 ]; then

root/etc/s6-overlay/s6-rc.d/user/contents.d/cwa-chown-library-migration

Whitespace-only changes.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# Calibre-Web-NextGen — fork of Calibre-Web-Automated
2+
# Copyright (C) 2024-2026 Calibre-Web-NextGen contributors
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
"""Validation suite for #37 (s6-setuidgid abc on ingest python workers).
6+
7+
The patch is architecturally correct (web UI runs as abc; ingest used to
8+
run as root, leaving root:root-owned books that the web UI couldn't
9+
delete). But it introduces a regression risk for users whose libraries
10+
already contain root-owned author/series subdirectories from before the
11+
patch landed: post-#37, an ingest of a new book targeting an existing
12+
root-owned author dir fails with EACCES.
13+
14+
These tests prove the regression empirically by running the actual
15+
production container image and exercising the filesystem transitions an
16+
ingest worker performs. They also verify the chown migration that needs
17+
to ship alongside #37 actually heals the regression.
18+
19+
The tests are skipped when Docker is unavailable, so the suite still
20+
imports cleanly during a unit-only `pytest` run.
21+
"""
22+
from __future__ import annotations
23+
24+
import shutil
25+
import subprocess
26+
import textwrap
27+
28+
import pytest
29+
30+
31+
def _docker_available() -> bool:
32+
if not shutil.which("docker"):
33+
return False
34+
try:
35+
subprocess.run(["docker", "version", "--format", "{{.Server.Version}}"],
36+
capture_output=True, check=True, timeout=5)
37+
return True
38+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
39+
return False
40+
41+
42+
pytestmark = pytest.mark.skipif(
43+
not _docker_available(),
44+
reason="docker required to exercise s6-setuidgid abc permission semantics",
45+
)
46+
47+
48+
# Image used to host the regression scenarios. The /tmp directory inside
49+
# any Calibre-Web-NextGen image already has the abc user defined and
50+
# `s6-setuidgid` available; the test does not touch the real ingest
51+
# pipeline, only the filesystem-permission transitions an ingest_processor
52+
# performs at the os layer.
53+
IMAGE = "ghcr.io/new-usemame/calibre-web-nextgen:v4.0.15"
54+
55+
56+
def _run(script: str) -> subprocess.CompletedProcess:
57+
"""Run a bash script inside a disposable container and return the
58+
completed process (stdout/stderr captured)."""
59+
return subprocess.run(
60+
[
61+
"docker", "run", "--rm",
62+
"--entrypoint", "",
63+
IMAGE,
64+
"bash", "-c", textwrap.dedent(script),
65+
],
66+
capture_output=True, text=True, timeout=120,
67+
)
68+
69+
70+
def test_pre37_root_worker_can_write_into_root_owned_subdir():
71+
"""Establish baseline: an ingest worker running as root (pre-#37
72+
behavior) successfully writes into a root-owned author directory."""
73+
proc = _run("""
74+
set -e
75+
mkdir -p /tmp/lib/root-author /tmp/ingest
76+
chown root:root /tmp/lib/root-author
77+
echo fake > /tmp/ingest/b.epub
78+
cp /tmp/ingest/b.epub /tmp/lib/root-author/b.epub
79+
ls -la /tmp/lib/root-author/b.epub
80+
""")
81+
assert proc.returncode == 0, f"baseline FAILED: {proc.stderr}"
82+
assert "/tmp/lib/root-author/b.epub" in proc.stdout
83+
84+
85+
def test_post37_abc_worker_blocked_by_root_owned_subdir():
86+
"""The regression — with #37 applied, an ingest worker running as abc
87+
cannot write into a pre-existing root-owned author directory."""
88+
proc = _run("""
89+
mkdir -p /tmp/lib/root-author /tmp/ingest
90+
chown root:root /tmp/lib/root-author
91+
echo fake > /tmp/ingest/b.epub
92+
chown abc:abc /tmp/ingest/b.epub
93+
s6-setuidgid abc cp /tmp/ingest/b.epub /tmp/lib/root-author/b.epub 2>&1
94+
echo EXIT=$?
95+
[ -f /tmp/lib/root-author/b.epub ] && echo MARKER=present || echo MARKER=absent
96+
""")
97+
# The cp itself should print "Permission denied" and exit nonzero,
98+
# the file should be absent.
99+
assert "Permission denied" in proc.stdout, \
100+
f"expected EACCES output, got: {proc.stdout}"
101+
assert "MARKER=absent" in proc.stdout, \
102+
"regression-canary file present — EACCES did not actually block write"
103+
104+
105+
def test_post37_abc_worker_unblocked_after_chown_migration():
106+
"""The fix — applying `chown -R abc:abc /calibre-library` once heals
107+
the regression and lets the abc-uid ingest worker write into every
108+
author directory regardless of prior ownership."""
109+
proc = _run("""
110+
set -e
111+
mkdir -p /tmp/lib/root-author /tmp/lib/abc-author /tmp/ingest
112+
chown root:root /tmp/lib/root-author
113+
chown abc:abc /tmp/lib/abc-author
114+
echo fake > /tmp/ingest/b.epub
115+
chown abc:abc /tmp/ingest/b.epub
116+
chown -R abc:abc /tmp/lib # the proposed migration
117+
s6-setuidgid abc cp /tmp/ingest/b.epub /tmp/lib/root-author/b.epub
118+
s6-setuidgid abc cp /tmp/ingest/b.epub /tmp/lib/abc-author/b.epub
119+
ls /tmp/lib/root-author/b.epub /tmp/lib/abc-author/b.epub
120+
""")
121+
assert proc.returncode == 0, \
122+
f"post-migration ingest FAILED: stderr={proc.stderr} stdout={proc.stdout}"
123+
assert "/tmp/lib/root-author/b.epub" in proc.stdout
124+
assert "/tmp/lib/abc-author/b.epub" in proc.stdout
125+
126+
127+
def test_post37_abc_worker_writes_into_clean_library():
128+
"""Confirm #37 does not regress the happy path: a clean library with
129+
all-abc-owned subdirectories accepts ingest writes from the abc
130+
worker."""
131+
proc = _run("""
132+
set -e
133+
mkdir -p /tmp/lib/abc-author /tmp/ingest
134+
chown abc:abc /tmp/lib/abc-author
135+
echo fake > /tmp/ingest/b.epub
136+
chown abc:abc /tmp/ingest/b.epub
137+
s6-setuidgid abc cp /tmp/ingest/b.epub /tmp/lib/abc-author/b.epub
138+
stat -c '%U:%G' /tmp/lib/abc-author/b.epub
139+
""")
140+
assert proc.returncode == 0
141+
assert "abc:abc" in proc.stdout, \
142+
f"new book should land abc-owned, got: {proc.stdout}"
143+
144+
145+
# ---------------------------------------------------------------------------
146+
# Migration script verification — runs the actual cwa-chown-library-migration
147+
# /run script that ships with v4.0.17. The script is copied INTO the test
148+
# container from the source tree so the test exercises the real artifact.
149+
# ---------------------------------------------------------------------------
150+
151+
import os
152+
from pathlib import Path
153+
154+
REPO_ROOT = Path(__file__).resolve().parents[2]
155+
MIGRATION_SCRIPT = REPO_ROOT / "root/etc/s6-overlay/s6-rc.d/cwa-chown-library-migration/run"
156+
157+
158+
_MIGRATION_BODY: str | None = None
159+
160+
161+
def _migration_body() -> str:
162+
"""Read the migration script text once. Embedded into test scripts via
163+
heredoc so the test works under both local Docker and Docker-over-SSH
164+
where bind-mounts of host paths don't resolve to the test runner's
165+
filesystem."""
166+
global _MIGRATION_BODY
167+
if _MIGRATION_BODY is None:
168+
_MIGRATION_BODY = MIGRATION_SCRIPT.read_text()
169+
return _MIGRATION_BODY
170+
171+
172+
def _run_with_migration_script(script: str,
173+
env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
174+
"""Run a bash script inside a disposable container after writing the
175+
real migration script to /usr/local/bin/cwa-chown-migrate from a
176+
heredoc. This intentionally avoids host-path bind mounts so the test
177+
behaves the same under local Docker and Docker-over-SSH."""
178+
body = _migration_body().replace("'", "'\\''")
179+
cmd = [
180+
"docker", "run", "--rm",
181+
"--entrypoint", "",
182+
]
183+
for k, v in (env or {}).items():
184+
cmd.extend(["-e", f"{k}={v}"])
185+
cmd.extend([
186+
IMAGE,
187+
"bash", "-c",
188+
f"cat > /usr/local/bin/cwa-chown-migrate <<'CWAMIG_EOF'\n"
189+
f"{_migration_body()}\nCWAMIG_EOF\n"
190+
f"chmod +x /usr/local/bin/cwa-chown-migrate\n"
191+
f"{textwrap.dedent(script)}",
192+
])
193+
return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
194+
195+
196+
def test_migration_script_chowns_library_and_writes_sentinel():
197+
"""When run on a fresh container with a mixed-ownership library, the
198+
real migration script aligns everything to abc:abc and drops the
199+
sentinel marker so subsequent boots no-op."""
200+
proc = _run_with_migration_script("""
201+
set -e
202+
mkdir -p /calibre-library/root-author /calibre-library/abc-author /cwa-book-ingest /config
203+
chown root:root /calibre-library/root-author
204+
chown abc:abc /calibre-library/abc-author
205+
chown root:root /cwa-book-ingest
206+
207+
# Run the real migration script
208+
bash /usr/local/bin/cwa-chown-migrate
209+
210+
# Verify
211+
stat -c '%U:%G %n' /calibre-library/root-author /calibre-library/abc-author /cwa-book-ingest
212+
[ -f /config/.cwa-chown-library-done ] && echo "sentinel: present" || echo "sentinel: ABSENT"
213+
""")
214+
assert proc.returncode == 0, f"migration FAILED: {proc.stderr}"
215+
assert "abc:abc /calibre-library/root-author" in proc.stdout, \
216+
f"root-author dir not migrated to abc:abc: {proc.stdout}"
217+
assert "abc:abc /cwa-book-ingest" in proc.stdout
218+
assert "sentinel: present" in proc.stdout
219+
220+
221+
def test_migration_script_is_idempotent():
222+
"""A second invocation of the migration script must no-op: the
223+
sentinel file stays, ownership is unchanged, and the script logs that
224+
it skipped."""
225+
proc = _run_with_migration_script("""
226+
set -e
227+
mkdir -p /calibre-library /config
228+
chown abc:abc /calibre-library
229+
touch /config/.cwa-chown-library-done # simulate prior run
230+
231+
# Drop a root-owned dir AFTER the sentinel is in place — script
232+
# must NOT chown it because the sentinel says we already migrated
233+
mkdir /calibre-library/post-migration-root
234+
chown root:root /calibre-library/post-migration-root
235+
236+
bash /usr/local/bin/cwa-chown-migrate 2>&1
237+
238+
stat -c '%U:%G %n' /calibre-library/post-migration-root
239+
""")
240+
assert proc.returncode == 0
241+
assert "sentinel present, skipping" in proc.stdout, \
242+
f"second run did not skip: {proc.stdout}"
243+
assert "root:root /calibre-library/post-migration-root" in proc.stdout, \
244+
"post-migration root-owned dir was unexpectedly chowned"
245+
246+
247+
def test_migration_script_skips_under_network_share_mode():
248+
"""When NETWORK_SHARE_MODE is true, the script must NOT chown over the
249+
network — it logs guidance and writes the sentinel so the skip is
250+
recorded."""
251+
proc = _run_with_migration_script("""
252+
set -e
253+
mkdir -p /calibre-library/root-author /config
254+
chown root:root /calibre-library/root-author
255+
256+
bash /usr/local/bin/cwa-chown-migrate 2>&1
257+
258+
stat -c '%U:%G %n' /calibre-library/root-author
259+
[ -f /config/.cwa-chown-library-done ] && echo "sentinel: present" || echo "sentinel: ABSENT"
260+
""", env={"NETWORK_SHARE_MODE": "true"})
261+
assert proc.returncode == 0
262+
assert "NETWORK_SHARE_MODE=true" in proc.stdout
263+
assert "skipping migration" in proc.stdout
264+
assert "root:root /calibre-library/root-author" in proc.stdout, \
265+
"NFS skip path should not have chowned anything"
266+
assert "sentinel: present" in proc.stdout
267+
268+
269+
def test_post_migration_ingest_into_root_owned_unblocked():
270+
"""End-to-end: run the real migration script over a mixed-ownership
271+
library, then prove the abc-uid ingest worker can now write into what
272+
used to be a root-owned author directory."""
273+
proc = _run_with_migration_script("""
274+
set -e
275+
mkdir -p /calibre-library/root-author /cwa-book-ingest /config
276+
chown root:root /calibre-library/root-author
277+
echo fake > /cwa-book-ingest/b.epub
278+
chown abc:abc /cwa-book-ingest/b.epub
279+
280+
bash /usr/local/bin/cwa-chown-migrate
281+
282+
s6-setuidgid abc cp /cwa-book-ingest/b.epub /calibre-library/root-author/b.epub
283+
stat -c '%U:%G %n' /calibre-library/root-author/b.epub
284+
""")
285+
assert proc.returncode == 0
286+
assert "abc:abc /calibre-library/root-author/b.epub" in proc.stdout, \
287+
f"post-migration ingest failed: {proc.stdout}\n{proc.stderr}"

0 commit comments

Comments
 (0)