Files
SnapOtter/tests/unit/security/entrypoint-permissions.test.ts
T
SnapOtterandGitHub 1fec97111b 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
2026-06-22 16:58:59 +08:00

75 lines
2.7 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// Exercises the REAL docker/entrypoint-lib.sh functions (sourced, not mirrored)
// so the test cannot drift from what ships in the image. Mirrors the approach
// in docker-file-secrets.test.ts.
const here = dirname(fileURLToPath(import.meta.url));
const LIB = resolve(here, "../../../docker/entrypoint-lib.sh");
// A read-only directory does not block writes for root (DAC_OVERRIDE), so the
// "not writable" assertions only hold for an unprivileged user.
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
let root: string;
let writable: string;
let readonly: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "entrypoint-perms-"));
writable = join(root, "writable");
mkdirSync(writable, { recursive: true });
readonly = join(root, "readonly");
mkdirSync(readonly, { recursive: true });
chmodSync(readonly, 0o555);
});
afterAll(() => {
try {
chmodSync(readonly, 0o755);
} catch {
/* may not exist */
}
rmSync(root, { recursive: true, force: true });
});
// Sources the lib, runs `snippet`, and returns its exit code + captured output
// without throwing on non-zero exit.
function runLib(snippet: string): { status: number; stdout: string; stderr: string } {
const res = spawnSync("/bin/sh", ["-c", `. "${LIB}"\n${snippet}`], { encoding: "utf-8" });
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
}
describe("entrypoint-lib.sh dir_writable", () => {
it("succeeds for a writable directory", () => {
expect(runLib(`dir_writable '${writable}'`).status).toBe(0);
});
it("creates and succeeds for a missing directory under a writable parent", () => {
const fresh = join(root, "fresh", "nested");
expect(runLib(`dir_writable '${fresh}'`).status).toBe(0);
});
it.skipIf(isRoot)("fails for a read-only directory", () => {
expect(runLib(`dir_writable '${readonly}'`).status).not.toBe(0);
});
});
describe("entrypoint-lib.sh ensure_writable", () => {
it("succeeds when all directories are writable", () => {
expect(runLib(`ensure_writable '${writable}'`).status).toBe(0);
});
it.skipIf(isRoot)("fails with actionable guidance for a read-only directory", () => {
const { status, stderr } = runLib(`ensure_writable '${readonly}'`);
expect(status).not.toBe(0);
expect(stderr).toContain(readonly);
expect(stderr.toLowerCase()).toContain("not writable");
expect(stderr).toContain("chown");
});
});