mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Add support for Docker Swarm style secrets (#11906)
* feat: Add support for Docker Swarm style secrets * fix: Handle empty-string env values and bare _FILE key in resolveFileSecrets Use undefined check instead of truthiness so empty-string values are treated as "already set" and not overridden by _FILE variants. Skip processing when the key is exactly "_FILE" to avoid creating an empty-key entry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+16
@@ -1,5 +1,21 @@
|
||||
NODE_ENV=production
|
||||
|
||||
# –––––––––––––––––––––––––––––––––––––––––
|
||||
# ––––––––––– FILE-BASED SECRETS ––––––––
|
||||
# –––––––––––––––––––––––––––––––––––––––––
|
||||
#
|
||||
# Any environment variable can be loaded from a file by appending _FILE to the
|
||||
# variable name and setting the value to the path of the file. This is useful
|
||||
# for Docker secrets and other file-based secret management systems.
|
||||
#
|
||||
# For example, instead of:
|
||||
# SECRET_KEY=your_secret_key
|
||||
# You can use:
|
||||
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
|
||||
#
|
||||
# The file contents will be trimmed of leading/trailing whitespace. If both the
|
||||
# variable and the _FILE variant are set, the direct variable takes precedence.
|
||||
|
||||
# This URL should point to the fully qualified, publicly accessible, URL. If using a
|
||||
# proxy this will be the proxy's URL.
|
||||
URL=
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveFileSecrets } from "./environment";
|
||||
|
||||
describe("resolveFileSecrets", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "outline-env-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should read env value from file when _FILE suffix is used", () => {
|
||||
const secretFile = path.join(tmpDir, "secret");
|
||||
fs.writeFileSync(secretFile, "my-secret-value");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_SECRET_FILE: secretFile,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_SECRET).toBe("my-secret-value");
|
||||
});
|
||||
|
||||
it("should trim whitespace and newlines from file contents", () => {
|
||||
const secretFile = path.join(tmpDir, "secret");
|
||||
fs.writeFileSync(secretFile, " my-secret-value\n\n");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_TRIM_FILE: secretFile,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_TRIM).toBe("my-secret-value");
|
||||
});
|
||||
|
||||
it("should not override existing env value with _FILE", () => {
|
||||
const secretFile = path.join(tmpDir, "secret");
|
||||
fs.writeFileSync(secretFile, "file-value");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_OVERRIDE: "direct-value",
|
||||
TEST_OVERRIDE_FILE: secretFile,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_OVERRIDE).toBe("direct-value");
|
||||
});
|
||||
|
||||
it("should not override empty-string env value with _FILE", () => {
|
||||
const secretFile = path.join(tmpDir, "secret");
|
||||
fs.writeFileSync(secretFile, "file-value");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_OVERRIDE_EMPTY: "",
|
||||
TEST_OVERRIDE_EMPTY_FILE: secretFile,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_OVERRIDE_EMPTY).toBe("");
|
||||
});
|
||||
|
||||
it("should skip a bare _FILE key with no base name", () => {
|
||||
const secretFile = path.join(tmpDir, "secret");
|
||||
fs.writeFileSync(secretFile, "value");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
_FILE: secretFile,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env[""]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle missing file gracefully", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_MISSING_FILE: path.join(tmpDir, "nonexistent"),
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_MISSING).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should skip _FILE entries with empty path", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
TEST_EMPTY_FILE: "",
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.TEST_EMPTY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should process multiple _FILE entries", () => {
|
||||
const file1 = path.join(tmpDir, "secret1");
|
||||
const file2 = path.join(tmpDir, "secret2");
|
||||
fs.writeFileSync(file1, "value1");
|
||||
fs.writeFileSync(file2, "value2");
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
SECRET_KEY_FILE: file1,
|
||||
DATABASE_PASSWORD_FILE: file2,
|
||||
};
|
||||
|
||||
resolveFileSecrets(env);
|
||||
|
||||
expect(env.SECRET_KEY).toBe("value1");
|
||||
expect(env.DATABASE_PASSWORD).toBe("value2");
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,45 @@ process.env = {
|
||||
...process.env,
|
||||
};
|
||||
|
||||
/**
|
||||
* Process environment variables with _FILE suffix by reading the referenced
|
||||
* file and setting the base variable. If the base variable is already set, the
|
||||
* file is not read. File contents are trimmed of leading/trailing whitespace.
|
||||
*
|
||||
* @param env - the environment record to process.
|
||||
*/
|
||||
export function resolveFileSecrets(
|
||||
env: Record<string, string | undefined>
|
||||
): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.endsWith("_FILE")) {
|
||||
const baseKey = key.slice(0, -5);
|
||||
if (!baseKey.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = env[key];
|
||||
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (env[baseKey] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
env[baseKey] = fs.readFileSync(filePath, "utf8").trim();
|
||||
} catch (err) {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to read file for ${key} (${filePath}): ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveFileSecrets(process.env);
|
||||
|
||||
export default process.env;
|
||||
|
||||
Reference in New Issue
Block a user