diff --git a/src/selfhost/load-file-secrets.ts b/src/selfhost/load-file-secrets.ts index f622ebe387..740bc6bd46 100644 --- a/src/selfhost/load-file-secrets.ts +++ b/src/selfhost/load-file-secrets.ts @@ -1,6 +1,10 @@ // Resolve `_FILE` env vars (Docker secrets / multi-line keys) into `` 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 `_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 @@ -22,9 +26,10 @@ 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", @@ -32,6 +37,11 @@ export function loadFileSecrets( var: key, }), ); + throw new Error( + `Failed to read secret file for ${key} (${path}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); } } } diff --git a/test/unit/selfhost-load-file-secrets.test.ts b/test/unit/selfhost-load-file-secrets.test.ts index 42fb79d43a..295bfbb3a7 100644 --- a/test/unit/selfhost-load-file-secrets.test.ts +++ b/test/unit/selfhost-load-file-secrets.test.ts @@ -49,13 +49,15 @@ 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 = { 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" }), @@ -63,16 +65,42 @@ describe("loadFileSecrets (#4403)", () => { 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 = { 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 = { 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; + } }); });