Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/selfhost/load-file-secrets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Resolve `<NAME>_FILE` env vars (Docker secrets / multi-line keys) into `<NAME>` at self-host startup.
// Extracted from server.ts (#4403) so this has a real test harness -- server.ts itself boots the whole
// app on import and is Codecov-ignored, so it has no runtime test coverage of its own.
//
// A missing or unreadable `<NAME>_FILE` fails the container fast (throws), matching the miner package's
// `loadMinerFileSecrets` behavior documented in packages/loopover-miner/DEPLOYMENT.md — rather than
// silently leaving the target env var unset and proceeding without the credential (#6284).
import { readFileSync } from "node:fs";

// Docker Compose's OWN reserved `_FILE`-suffixed environment variables -- never loopover's secret-file
Expand All @@ -22,16 +26,22 @@ export function loadFileSecrets(
if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) continue;
const target = key.slice(0, -"_FILE".length);
if (env[target]) continue; // an explicit value wins
const path = env[key] as string;
try {
env[target] = readFile(env[key] as string).trim();
} catch {
env[target] = readFile(path).trim();
} catch (error) {
console.error(
JSON.stringify({
level: "error",
event: "selfhost_secret_file_unreadable",
var: key,
}),
);
throw new Error(
`Failed to read secret file for ${key} (${path}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
46 changes: 37 additions & 9 deletions test/unit/selfhost-load-file-secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,58 @@ describe("loadFileSecrets (#4403)", () => {
expect(readFile).not.toHaveBeenCalled();
});

it("logs a structured error and leaves the target unset when the file read fails, for a genuine secret var", () => {
it("REGRESSION (#6284): throws (and logs) when a configured _FILE secret is missing/unreadable, instead of leaving the target unset", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const readFile = vi.fn(() => {
throw new Error("ENOENT");
});
const env: Record<string, string | undefined> = { SENTRY_DSN_FILE: "/run/secrets/missing" };
loadFileSecrets(env, readFile);
expect(() => loadFileSecrets(env, readFile)).toThrow(
"Failed to read secret file for SENTRY_DSN_FILE (/run/secrets/missing): ENOENT",
);
expect(env.SENTRY_DSN).toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
JSON.stringify({ level: "error", event: "selfhost_secret_file_unreadable", var: "SENTRY_DSN_FILE" }),
);
errorSpy.mockRestore();
});

it("formats a non-Error thrown value into the fail-fast error message", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const readFile = vi.fn(() => {
throw "boom";
});
const env: Record<string, string | undefined> = { TOKEN_ENCRYPTION_SECRET_FILE: "/run/secrets/missing" };
expect(() => loadFileSecrets(env, readFile)).toThrow(
"Failed to read secret file for TOKEN_ENCRYPTION_SECRET_FILE (/run/secrets/missing): boom",
);
expect(env.TOKEN_ENCRYPTION_SECRET).toBeUndefined();
errorSpy.mockRestore();
});

it("still starts normally when no _FILE secret is configured at all", () => {
const readFile = vi.fn(() => {
throw new Error("should never be called");
});
const env: Record<string, string | undefined> = { SENTRY_DSN: "already-set-inline" };
expect(() => loadFileSecrets(env, readFile)).not.toThrow();
expect(readFile).not.toHaveBeenCalled();
expect(env.SENTRY_DSN).toBe("already-set-inline");
});

it("defaults to process.env and the real node:fs reader when called with no arguments", () => {
const original = process.env.NOT_A_REAL_SECRET_FILE;
process.env.NOT_A_REAL_SECRET_FILE = "/definitely/does/not/exist";
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
loadFileSecrets();
expect(errorSpy).toHaveBeenCalledWith(
JSON.stringify({ level: "error", event: "selfhost_secret_file_unreadable", var: "NOT_A_REAL_SECRET_FILE" }),
);
errorSpy.mockRestore();
if (original === undefined) delete process.env.NOT_A_REAL_SECRET_FILE;
else process.env.NOT_A_REAL_SECRET_FILE = original;
try {
expect(() => loadFileSecrets()).toThrow(/NOT_A_REAL_SECRET_FILE/);
expect(errorSpy).toHaveBeenCalledWith(
JSON.stringify({ level: "error", event: "selfhost_secret_file_unreadable", var: "NOT_A_REAL_SECRET_FILE" }),
);
} finally {
errorSpy.mockRestore();
if (original === undefined) delete process.env.NOT_A_REAL_SECRET_FILE;
else process.env.NOT_A_REAL_SECRET_FILE = original;
}
});
});
Loading