feat(deploy): make the all-in-one image installable without a shell (#1124)

The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.

wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.

Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.

Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.

Reviewed over three rounds. Co-authored by @Luca-Timo.
This commit is contained in:
Luca
2026-08-22 19:37:12 +03:00
committed by GitHub
parent 8f23118782
commit 7223118b89
9 changed files with 475 additions and 45 deletions
+57
View File
@@ -1005,6 +1005,63 @@ jobs:
docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \
|| { echo "::error::/backup/database is not writable by the app user"; exit 1; }
- name: Assert a no-JWT_SECRET install boots and keeps its generated secret
run: |
# The boot above passes JWT_SECRET explicitly, so it cannot catch the case
# this image is actually deployed in: a NAS Container Manager / Container
# Station form has no shell to run `openssl rand`, and validateEnv treats a
# missing JWT_SECRET as critical and exits. wait-for-db.sh generates one
# into the data volume when neither the environment nor /run/secrets
# supplies it (#705).
docker volume create aio-nojwt > /dev/null
docker run -d --name aio-nojwt -p 3100:3000 -v aio-nojwt:/data picpeak-aio:smoke > /dev/null
for i in $(seq 1 60); do
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
sleep 2
done
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|| { echo "::error::container with no JWT_SECRET never became healthy"; docker logs aio-nojwt | tail -50; exit 1; }
mode=$(docker exec aio-nojwt stat -c '%a' /data/db/jwt.secret 2>/dev/null || echo missing)
[ "$mode" = "600" ] \
|| { echo "::error::/data/db/jwt.secret missing or not 0600 (got: $mode)"; exit 1; }
# The secret has to survive a restart. If it did not, every admin session
# and gallery link would be signed out on each container recreate — which
# is worse than failing to boot, because it looks like it works.
first=$(docker exec aio-nojwt cat /data/db/jwt.secret)
[ -n "$first" ] || { echo "::error::generated jwt.secret is empty"; exit 1; }
docker restart aio-nojwt > /dev/null
for i in $(seq 1 60); do
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
sleep 2
done
# Assert recovery rather than falling through to `docker exec`, which
# would report a confusing cat failure for a container that never came
# back up.
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|| { echo "::error::container did not become healthy again after restart"; docker logs aio-nojwt | tail -50; exit 1; }
second=$(docker exec aio-nojwt cat /data/db/jwt.secret)
[ "$first" = "$second" ] \
|| { echo "::error::jwt.secret changed across a restart — sessions would not survive"; exit 1; }
# An explicit secret must still win over the generated one. The $ is
# escaped so the comparison happens in the INNERMOST shell, after
# wait-for-db.sh has run. Unescaped it is the CONTAINER's outer `sh -c`
# that expands it — the runner's single quotes do protect it, but the
# container shell then substitutes the value while parsing the
# double-quoted region, before the script runs at all. The test reduces
# to `[ x = x ]` and passes even against a script that clobbers the
# variable: an assertion that cannot fail.
docker run --rm -v aio-nojwt:/data -e JWT_SECRET=explicit-secret-at-least-32-characters-long \
--entrypoint sh picpeak-aio:smoke -c \
'./wait-for-db.sh sh -c "[ \"\$JWT_SECRET\" = explicit-secret-at-least-32-characters-long ]"' \
|| { echo "::error::an explicit JWT_SECRET did not override the generated file"; exit 1; }
docker rm -f aio-nojwt > /dev/null
# -f alone can return before the volume is released
for i in $(seq 1 10); do docker volume rm aio-nojwt > /dev/null 2>&1 && break; sleep 1; done
- name: Assert the sqlite3 CLI the backup service shells out to
run: |
# DatabaseBackupService spawns `sqlite3` for .backup and integrity_check;