fix(gateway): load dotenv before federation tier gate (#1138)
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
interface ComposedModuleGraph {
|
||||||
|
imports: readonly unknown[];
|
||||||
|
federationModule: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function restoreEnvironmentVariable(name: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleGraphFromDotenv(
|
||||||
|
tier: 'local' | 'standalone' | 'federated',
|
||||||
|
): Promise<ComposedModuleGraph> {
|
||||||
|
const originalHome = process.env['HOME'];
|
||||||
|
const originalTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const originalDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
const fixtureRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-module-'));
|
||||||
|
const gatewayCwd = join(fixtureRoot, 'apps', 'gateway');
|
||||||
|
|
||||||
|
await mkdir(gatewayCwd, { recursive: true });
|
||||||
|
await writeFile(join(fixtureRoot, '.env'), `MOSAIC_STORAGE_TIER=${tier}\n`, 'utf8');
|
||||||
|
|
||||||
|
process.env['HOME'] = join(fixtureRoot, 'home');
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
vi.resetModules();
|
||||||
|
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(gatewayCwd);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||||
|
await import('./env.js');
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier);
|
||||||
|
|
||||||
|
const { AppModule } = await import('./app.module.js');
|
||||||
|
const { FederationModule } = await import('./federation/federation.module.js');
|
||||||
|
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(imports)) {
|
||||||
|
throw new Error('AppModule imports metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imports, federationModule: FederationModule };
|
||||||
|
} finally {
|
||||||
|
cwdSpy.mockRestore();
|
||||||
|
restoreEnvironmentVariable('HOME', originalHome);
|
||||||
|
restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier);
|
||||||
|
restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl);
|
||||||
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule federation gating', (): void => {
|
||||||
|
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||||
|
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||||
|
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||||
|
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||||
|
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||||
|
|
||||||
|
expect(envImportIndex).toBeGreaterThan(-1);
|
||||||
|
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||||
|
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['local', 'standalone'] as const)(
|
||||||
|
'does not register FederationModule for the %s tier',
|
||||||
|
async (tier): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv(tier);
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'registers FederationModule when federated tier is supplied only by a dotenv file',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv('federated');
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -31,7 +31,9 @@ import { loadConfig } from '@mosaicstack/config';
|
|||||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||||
// federation profile "must not start in non-federated dev").
|
// federation profile "must not start in non-federated dev"). The gateway
|
||||||
|
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||||
|
// configuration is visible here.
|
||||||
const federationEnabled = loadConfig().tier === 'federated';
|
const federationEnabled = loadConfig().tier === 'federated';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode).
|
||||||
|
// It takes precedence over file-based local-dev configuration.
|
||||||
|
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||||
|
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||||
|
|
||||||
|
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter),
|
||||||
|
// then fill any remaining values from apps/gateway/.env when present.
|
||||||
|
config({ path: resolve(process.cwd(), '../../.env') });
|
||||||
|
config();
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { config } from 'dotenv';
|
import './env.js';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode).
|
|
||||||
// Loaded first so monorepo .env can override for local dev.
|
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
|
||||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|||||||
Reference in New Issue
Block a user