fix(docker): make storage writable under non-root/foreign UIDs (TrueNAS, OpenShift) (#299)

The entrypoint only fixed volume permissions when started as root (chown +
gosu-drop to snapotter). Launched under a non-root/foreign UID (TrueNAS app
user, Kubernetes runAsUser, OpenShift) it did no permission setup, so /data and
/tmp/workspace -- owned by uid 999 from the image -- were not writable by the
running user. Uploads and processing then failed with a cryptic EACCES
("workspace folder is not writable") and AI bundle installs failed the same way,
while health checks still reported the container healthy.

- entrypoint: source new entrypoint-lib.sh; verify writability up front when
  non-root, and as snapotter after chown when root (catches root-squashed
  mounts), failing fast with an actionable message (which dir, uid/gid, how to
  fix) instead of a late, cryptic EACCES
- Dockerfile: own /data and /tmp/workspace as snapotter:0, group-writable with
  setgid, so an arbitrary UID with the root supplementary group (OpenShift /
  Kubernetes fsGroup) can write; keep /opt/venv world-readable for the AI venv
  bootstrap under arbitrary UIDs
- api: assert storage writability at boot (lib/storage-writable.ts), failing
  fast with the same guidance even when the entrypoint is bypassed
- docs: add a Storage permissions section (named volumes, bind mounts, TrueNAS,
  Kubernetes/OpenShift) and cross-link it from the security guide

Fixes #230
This commit is contained in:
SnapOtter
2026-06-22 16:58:59 +08:00
committed by GitHub
parent 5a6368db89
commit 1fec97111b
9 changed files with 392 additions and 3 deletions
+13
View File
@@ -25,6 +25,7 @@ import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.j
import { logger } from "./lib/logger.js";
import { requestDuration } from "./lib/metrics.js";
import { getSettingString } from "./lib/settings-helpers.js";
import { assertStorageWritable } from "./lib/storage-writable.js";
import { requirePermission } from "./permissions.js";
import {
authMiddleware,
@@ -87,6 +88,18 @@ try {
}
console.log("Redis connected");
// Verify the local storage directories are writable before serving. A non-root
// container launched against a volume it cannot write (TrueNAS, Kubernetes
// runAsUser / OpenShift, or a bind mount owned by another user) would otherwise
// boot "healthy" and fail with a cryptic EACCES on the first file operation.
try {
await assertStorageWritable();
console.log("Storage directories writable");
} catch (err) {
console.error(`FATAL: ${(err as Error).message}`);
process.exit(1);
}
// Auto-import 1.x SQLite database on first boot (before default user creation)
if (env.SQLITE_MIGRATE_PATH) {
const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`);