mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(config): read secrets from NAME_FILE variables (#1421)
Co-authored-by: Hampus <[email protected]>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
@@ -80,6 +83,24 @@ describe('ConfigLoader', () => {
|
||||
expect((await loadConfig()).domain.base_domain).toBe('localhost');
|
||||
});
|
||||
|
||||
test('loadConfig reads secrets from NAME_FILE', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fluxer-config-file-'));
|
||||
try {
|
||||
const path = join(dir, 'postgres_password');
|
||||
writeFileSync(path, 'from-secret-file\n');
|
||||
stubMinimalEnv({FLUXER_POSTGRES_PASSWORD: '', FLUXER_POSTGRES_PASSWORD_FILE: path});
|
||||
const config = await loadConfig();
|
||||
expect(config.database.postgres.password).toBe('from-secret-file');
|
||||
} finally {
|
||||
rmSync(dir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('loadConfig rejects NAME and NAME_FILE together', async () => {
|
||||
stubMinimalEnv({FLUXER_SUDO_MODE_SECRET_FILE: '/run/secrets/sudo'});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_SUDO_MODE_SECRET and FLUXER_SUDO_MODE_SECRET_FILE are both set');
|
||||
});
|
||||
|
||||
test('getConfig throws when config is not loaded', () => {
|
||||
expect(() => getConfig()).toThrow('Config not loaded');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {buildNamedFluxerEnvOverrides, setNestedValue} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {
|
||||
buildNamedFluxerEnvOverrides,
|
||||
readEnvValue,
|
||||
setNestedValue,
|
||||
} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {afterAll, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('setNestedValue', () => {
|
||||
test('sets a top-level key', () => {
|
||||
@@ -316,3 +323,69 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEnvValue with NAME_FILE', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fluxer-env-file-'));
|
||||
afterAll(() => rmSync(dir, {recursive: true, force: true}));
|
||||
|
||||
function secretFile(name: string, contents: string): string {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
test('reads the value from NAME_FILE when NAME is unset', () => {
|
||||
const path = secretFile('plain', 'from-file\n');
|
||||
expect(readEnvValue({FLUXER_SUDO_MODE_SECRET_FILE: path}, 'FLUXER_SUDO_MODE_SECRET')).toBe('from-file');
|
||||
});
|
||||
|
||||
test('reads the value from NAME_FILE when NAME is blank', () => {
|
||||
const path = secretFile('blank', 'from-file');
|
||||
expect(
|
||||
readEnvValue({FLUXER_SUDO_MODE_SECRET: ' ', FLUXER_SUDO_MODE_SECRET_FILE: path}, 'FLUXER_SUDO_MODE_SECRET'),
|
||||
).toBe('from-file');
|
||||
});
|
||||
|
||||
test('trims only one trailing newline', () => {
|
||||
const crlf = secretFile('crlf', 'value\r\n');
|
||||
const multi = secretFile('multi', '-----BEGIN-----\nabc\n-----END-----\n\n');
|
||||
expect(readEnvValue({X_FILE: crlf}, 'X')).toBe('value');
|
||||
expect(readEnvValue({X_FILE: multi}, 'X')).toBe('-----BEGIN-----\nabc\n-----END-----\n');
|
||||
});
|
||||
|
||||
test('treats an empty file as unset', () => {
|
||||
const path = secretFile('empty', '\n');
|
||||
expect(readEnvValue({X_FILE: path}, 'X')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps NAME when NAME_FILE is blank', () => {
|
||||
expect(readEnvValue({X: 'direct', X_FILE: ''}, 'X')).toBe('direct');
|
||||
});
|
||||
|
||||
test('rejects NAME and NAME_FILE together', () => {
|
||||
const path = secretFile('both', 'from-file');
|
||||
expect(() => readEnvValue({X: 'direct', X_FILE: path}, 'X')).toThrow('X and X_FILE are both set, set only one');
|
||||
});
|
||||
|
||||
test('names NAME_FILE and the path when the file is missing', () => {
|
||||
const path = join(dir, 'missing');
|
||||
expect(() => readEnvValue({X_FILE: path}, 'X')).toThrow(`X_FILE could not read ${path} (ENOENT)`);
|
||||
});
|
||||
|
||||
test('rejects a file that is not valid UTF-8', () => {
|
||||
const path = join(dir, 'binary');
|
||||
writeFileSync(path, Buffer.from([0xff, 0x61]));
|
||||
expect(() => readEnvValue({X_FILE: path}, 'X')).toThrow(`X_FILE could not read ${path} (`);
|
||||
});
|
||||
|
||||
test('feeds named overrides and aliases', () => {
|
||||
const secret = secretFile('stripe', 'sk_test_file\n');
|
||||
const nats = secretFile('nats', 'nats://nats:4222\n');
|
||||
expect(
|
||||
buildNamedFluxerEnvOverrides({FLUXER_STRIPE_SECRET_KEY_FILE: secret, FLUXER_NATS_CORE_URL_FILE: nats}),
|
||||
).toMatchObject({
|
||||
integrations: {stripe: {secret_key: 'sk_test_file'}},
|
||||
services: {nats: {core_url: 'nats://nats:4222'}},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {type ConfigObject, isConfigObject} from '@fluxer/config/src/config_loader/ConfigObject';
|
||||
|
||||
type ConfigPathKey = string | number;
|
||||
@@ -420,11 +421,29 @@ const NAMED_FLUXER_ENV_ALIASES: Record<string, string | undefined> = {
|
||||
|
||||
export const NAMED_FLUXER_ENV_NAMES = Object.keys(NAMED_FLUXER_ENV_OVERRIDES);
|
||||
|
||||
export function readEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const value = env[name];
|
||||
function nonBlank(value: string | undefined): string | undefined {
|
||||
return value === undefined || value.trim().length === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
export function readEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const value = nonBlank(env[name]);
|
||||
const filePath = nonBlank(env[`${name}_FILE`]);
|
||||
if (filePath === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new Error(`${name} and ${name}_FILE are both set, set only one`);
|
||||
}
|
||||
let contents: string;
|
||||
try {
|
||||
contents = new TextDecoder('utf-8', {fatal: true}).decode(readFileSync(filePath));
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error && 'code' in error ? String(error.code) : 'unreadable';
|
||||
throw new Error(`${name}_FILE could not read ${filePath} (${reason})`);
|
||||
}
|
||||
return nonBlank(contents.replace(/\r?\n$/, ''));
|
||||
}
|
||||
|
||||
export function buildNamedFluxerEnvOverrides(env: NodeJS.ProcessEnv): ConfigObject {
|
||||
const overrides: ConfigObject = {};
|
||||
for (const [envKey, mapping] of Object.entries(NAMED_FLUXER_ENV_OVERRIDES)) {
|
||||
|
||||
Reference in New Issue
Block a user