Fix boot crash on compose-passed empty setup variables
All checks were successful
CD / Build and push images (push) Successful in 2m35s
CD / Deploy to Test (push) Successful in 8s
CI / Lint, typecheck, test (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 4m52s
CI / Import/export fidelity gate (push) Successful in 46s
All checks were successful
CD / Build and push images (push) Successful in 2m35s
CD / Deploy to Test (push) Successful in 8s
CI / Lint, typecheck, test (push) Successful in 3m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 4m52s
CI / Import/export fidelity gate (push) Successful in 46s
main.ts parsed process.env raw, so the compose passthroughs (SETUP_DEFAULT_LOCALE: "") failed the enum parse and crash-looped the Test api. Route main.ts through the same loadApiEnv as AppConfig, which overlays the secret store and drops empty strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
f0a82bad20
commit
224ae3e6db
@ -3,15 +3,24 @@ import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
|||||||
|
|
||||||
import { overlayEnv, readSecretsFile } from './secret-store';
|
import { overlayEnv, readSecretsFile } from './secret-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way to resolve the api environment: secrets the setup wizard
|
||||||
|
* persisted (SMTP credentials) extend the process env, explicit env always
|
||||||
|
* wins, and empty strings count as unset (secret-store.ts). main.ts and
|
||||||
|
* AppConfig MUST both use this — compose passes optional variables as ""
|
||||||
|
* and a raw `parseEnv(schema, process.env)` would fail on enum fields.
|
||||||
|
*/
|
||||||
|
export function loadApiEnv(): ApiEnv {
|
||||||
|
const secretsFile = apiEnvSchema.shape.SECRETS_FILE.parse(process.env.SECRETS_FILE || undefined);
|
||||||
|
const secrets = readSecretsFile(secretsFile);
|
||||||
|
return parseEnv(apiEnvSchema, overlayEnv(process.env, secrets));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppConfig {
|
export class AppConfig {
|
||||||
readonly env: ApiEnv;
|
readonly env: ApiEnv;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Secrets the setup wizard persisted (SMTP credentials) extend the
|
this.env = loadApiEnv();
|
||||||
// environment; explicit process env always wins (secret-store.ts).
|
|
||||||
const secretsFile = apiEnvSchema.shape.SECRETS_FILE.parse(process.env.SECRETS_FILE);
|
|
||||||
const secrets = readSecretsFile(secretsFile);
|
|
||||||
this.env = parseEnv(apiEnvSchema, overlayEnv(process.env, secrets));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/api/src/config/app-config.test.ts
Normal file
44
apps/api/src/config/app-config.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { loadApiEnv } from './app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the Test-stage boot crash after #80: compose passes
|
||||||
|
* optional variables as empty strings (`SETUP_DEFAULT_LOCALE: ${…:-}`),
|
||||||
|
* and a raw parse of process.env fails on enum fields. loadApiEnv must
|
||||||
|
* treat them as unset — in main.ts exactly like in AppConfig.
|
||||||
|
*/
|
||||||
|
describe('loadApiEnv', () => {
|
||||||
|
const touched = [
|
||||||
|
'SETUP_DEFAULT_LOCALE',
|
||||||
|
'SETUP_REGISTRATION_MODE',
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_PORT',
|
||||||
|
'SECRETS_FILE',
|
||||||
|
'DATABASE_URL',
|
||||||
|
] as const;
|
||||||
|
const saved = Object.fromEntries(touched.map((key) => [key, process.env[key]]));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of touched) {
|
||||||
|
if (saved[key] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = saved[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats compose-passed empty strings as unset', () => {
|
||||||
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db';
|
||||||
|
process.env.SETUP_DEFAULT_LOCALE = '';
|
||||||
|
process.env.SETUP_REGISTRATION_MODE = '';
|
||||||
|
process.env.SMTP_HOST = '';
|
||||||
|
process.env.SMTP_PORT = '';
|
||||||
|
process.env.SECRETS_FILE = '';
|
||||||
|
|
||||||
|
const env = loadApiEnv();
|
||||||
|
expect(env.SETUP_DEFAULT_LOCALE).toBeUndefined();
|
||||||
|
expect(env.SETUP_REGISTRATION_MODE).toBeUndefined();
|
||||||
|
expect(env.SMTP_HOST).toBe('localhost'); // schema default, not ""
|
||||||
|
expect(env.SMTP_PORT).toBe(1025);
|
||||||
|
expect(env.SECRETS_FILE).toBe('./data/secrets.env');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,11 +3,10 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig, loadApiEnv } from './config/app-config.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply pending migrations before the application accepts traffic, so
|
* Apply pending migrations before the application accepts traffic, so
|
||||||
@ -22,7 +21,7 @@ function runMigrations(): void {
|
|||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
// Validate the environment before doing anything with it; this throws a
|
// Validate the environment before doing anything with it; this throws a
|
||||||
// readable list of problems and prevents a half-started process.
|
// readable list of problems and prevents a half-started process.
|
||||||
const env = parseEnv(apiEnvSchema, process.env);
|
const env = loadApiEnv();
|
||||||
if (env.MIGRATE_ON_START) {
|
if (env.MIGRATE_ON_START) {
|
||||||
runMigrations();
|
runMigrations();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user