mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,103 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// storage-writable reads the configured storage paths from config.js. Mock it
|
||||
// with a mutable env object so each test can point the paths at a temp dir.
|
||||
// vi.hoisted keeps the object available to the hoisted vi.mock factory.
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
STORAGE_MODE: "local",
|
||||
WORKSPACE_PATH: "",
|
||||
FILES_STORAGE_PATH: "",
|
||||
}));
|
||||
|
||||
vi.mock("../../../apps/api/src/config.js", () => ({ env: mockEnv }));
|
||||
|
||||
import {
|
||||
assertStorageWritable,
|
||||
isDirWritable,
|
||||
storagePermissionMessage,
|
||||
} from "../../../apps/api/src/lib/storage-writable.js";
|
||||
|
||||
// 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;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "storage-writable-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore perms so cleanup can recurse into the read-only dir.
|
||||
try {
|
||||
chmodSync(join(root, "readonly"), 0o755);
|
||||
} catch {
|
||||
/* may not exist */
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("isDirWritable", () => {
|
||||
it("returns true for an existing writable directory", async () => {
|
||||
const dir = join(root, "writable");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
expect(await isDirWritable(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a missing directory whose parent is writable (creates it)", async () => {
|
||||
const dir = join(root, "nested", "deep");
|
||||
expect(await isDirWritable(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)("returns false for a read-only directory", async () => {
|
||||
const dir = join(root, "readonly");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
chmodSync(dir, 0o555);
|
||||
expect(await isDirWritable(dir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storagePermissionMessage", () => {
|
||||
it("names the directory and gives an actionable chown remediation", () => {
|
||||
const msg = storagePermissionMessage("/tmp/workspace");
|
||||
expect(msg).toContain("/tmp/workspace");
|
||||
expect(msg.toLowerCase()).toContain("not writable");
|
||||
expect(msg).toContain("chown");
|
||||
// Includes the running uid so the operator knows what to chown to.
|
||||
expect(msg).toMatch(/uid=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertStorageWritable", () => {
|
||||
it("resolves when both storage paths are writable", async () => {
|
||||
mockEnv.STORAGE_MODE = "local";
|
||||
mockEnv.WORKSPACE_PATH = join(root, "ws-ok");
|
||||
mockEnv.FILES_STORAGE_PATH = join(root, "files-ok");
|
||||
await expect(assertStorageWritable()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)(
|
||||
"rejects with an actionable message when the workspace is not writable",
|
||||
async () => {
|
||||
const ws = join(root, "ws-ro");
|
||||
mkdirSync(ws, { recursive: true });
|
||||
chmodSync(ws, 0o555);
|
||||
mockEnv.STORAGE_MODE = "local";
|
||||
mockEnv.WORKSPACE_PATH = ws;
|
||||
mockEnv.FILES_STORAGE_PATH = join(root, "files-ok2");
|
||||
await expect(assertStorageWritable()).rejects.toThrow(/not writable/i);
|
||||
await expect(assertStorageWritable()).rejects.toThrow(ws);
|
||||
chmodSync(ws, 0o755);
|
||||
},
|
||||
);
|
||||
|
||||
it("is a no-op in S3 storage mode (does not touch the filesystem)", async () => {
|
||||
mockEnv.STORAGE_MODE = "s3";
|
||||
mockEnv.WORKSPACE_PATH = "/nonexistent/should-not-be-touched";
|
||||
mockEnv.FILES_STORAGE_PATH = "/nonexistent/should-not-be-touched";
|
||||
await expect(assertStorageWritable()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user