fix(gateway): anchor config discovery and isolate env tests (#1138)

This commit is contained in:
shaggy (mosaic-dev box)
2026-08-09 23:11:09 -05:00
parent 884d527cc8
commit 2d5a8c81ec
3 changed files with 404 additions and 258 deletions
+301 -201
View File
@@ -1,9 +1,8 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { existsSync } from 'node:fs';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import * as nodeOs from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import * as nodeUrl from 'node:url';
import { MODULE_METADATA } from '@nestjs/common/constants.js'; import { MODULE_METADATA } from '@nestjs/common/constants.js';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
@@ -13,35 +12,54 @@ interface ComposedModuleGraph {
bootLogLines: readonly string[]; bootLogLines: readonly string[];
} }
interface FileSnapshot {
exists: boolean;
contents: Buffer | null;
}
type StorageTier = 'local' | 'standalone' | 'federated'; type StorageTier = 'local' | 'standalone' | 'federated';
interface ModuleGraphFixtureOptions { interface ModuleGraphFixture {
tempRoot: string;
anchor: string;
homePath: string;
cwdPath: string; cwdPath: string;
monorepoRootEnvPath: string;
gatewayLocalEnvPath: string;
daemonEnvPath: string;
monorepoRootConfigPath: string;
gatewayLocalConfigPath: string;
}
interface ModuleGraphFixtureOptions {
rootEnvMode?: 'present' | 'absent'; rootEnvMode?: 'present' | 'absent';
rootTier?: StorageTier; rootTier?: StorageTier;
rootEnvContents?: string; rootEnvContents?: string;
secret?: string; redactionMarker?: string;
gatewayLocalTier?: StorageTier; gatewayLocalTier?: StorageTier;
gatewayLocalEnvContents?: string; gatewayLocalEnvContents?: string;
daemonEnvContents?: string; daemonEnvContents?: string;
inheritedTier?: StorageTier; inheritedTier?: StorageTier;
expectedProcessTier?: StorageTier; expectedProcessTier?: string;
setup?: () => Promise<void>; setup?: (fixture: ModuleGraphFixture) => Promise<void>;
} }
const MODULE_IMPORT_TIMEOUT_MS = 30_000; const MODULE_IMPORT_TIMEOUT_MS = 30_000;
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env'; const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
const DAEMON_DOTENV_LABEL = 'daemon .env'; const DAEMON_DOTENV_LABEL = 'daemon .env';
const specDir = dirname(fileURLToPath(import.meta.url));
const monorepoRootDir = resolve(specDir, '../../..'); function configJson(tier: StorageTier): string {
const gatewayDir = resolve(specDir, '..'); if (tier === 'local') {
const rootEnvPath = join(monorepoRootDir, '.env'); return JSON.stringify({
const gatewayLocalEnvPath = join(gatewayDir, '.env'); tier,
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
}
return JSON.stringify({
tier,
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
queue: { type: 'bullmq' },
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
});
}
function snapshotProcessEnv(): Record<string, string | undefined> { function snapshotProcessEnv(): Record<string, string | undefined> {
return { ...process.env }; return { ...process.env };
@@ -64,21 +82,17 @@ function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
} }
} }
async function snapshotFile(path: string): Promise<FileSnapshot> { function expectPathUnderTempRoot(path: string, tempRoot: string): void {
if (!existsSync(path)) { const relativePath = relative(tempRoot, path);
return { exists: false, contents: null }; expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
} true,
);
return { exists: true, contents: await readFile(path) };
} }
async function restoreFile(path: string, snapshot: FileSnapshot): Promise<void> { async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
if (!snapshot.exists) { expectPathUnderTempRoot(path, tempRoot);
await rm(path, { force: true }); await mkdir(dirname(path), { recursive: true });
return; await writeFile(path, contents, 'utf8');
}
await writeFile(path, snapshot.contents ?? Buffer.alloc(0));
} }
function singleBootLogLine(bootLogLines: readonly string[]): string { function singleBootLogLine(bootLogLines: readonly string[]): string {
@@ -106,105 +120,130 @@ async function loadModuleGraphFromDotenv(
options: ModuleGraphFixtureOptions, options: ModuleGraphFixtureOptions,
): Promise<ComposedModuleGraph> { ): Promise<ComposedModuleGraph> {
const originalEnv = snapshotProcessEnv(); const originalEnv = snapshotProcessEnv();
const rootSnapshot = await snapshotFile(rootEnvPath); const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
const gatewayLocalSnapshot = await snapshotFile(gatewayLocalEnvPath); let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
const isolatedHome = await mkdtemp(join(tmpdir(), 'mosaic-gateway-home-')); let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
await mkdir(options.cwdPath, { recursive: true });
try { try {
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
const homePath = join(tempRoot, 'home');
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
const fixture: ModuleGraphFixture = {
tempRoot,
anchor,
homePath,
cwdPath,
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
};
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
for (const path of Object.values(fixture)) {
expectPathUnderTempRoot(path, tempRoot);
}
await mkdir(anchor, { recursive: true });
await mkdir(cwdPath, { recursive: true });
if ((options.rootEnvMode ?? 'present') === 'absent') { if ((options.rootEnvMode ?? 'present') === 'absent') {
if ( if (
options.rootEnvContents !== undefined || options.rootEnvContents !== undefined ||
options.rootTier !== undefined || options.rootTier !== undefined ||
options.secret !== undefined options.redactionMarker !== undefined
) { ) {
throw new Error('Expected no root env fixture values when rootEnvMode is absent'); throw new Error('Expected no root env fixture values when rootEnvMode is absent');
} }
await rm(rootEnvPath, { force: true });
} else { } else {
if (options.rootEnvContents === undefined && options.rootTier === undefined) { if (options.rootEnvContents === undefined && options.rootTier === undefined) {
throw new Error('Expected rootTier or rootEnvContents'); throw new Error('Expected rootTier or rootEnvContents');
} }
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`; const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
const rootFixtureWithSecret = options.secret const rootFixtureWithMarker = options.redactionMarker
? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n` ? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
: rootFixture; : rootFixture;
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8');
} }
if (options.daemonEnvContents !== undefined) { if (options.daemonEnvContents !== undefined) {
const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env'); await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
await mkdir(dirname(daemonEnvPath), { recursive: true });
await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8');
} }
if (options.gatewayLocalEnvContents !== undefined) { if (options.gatewayLocalEnvContents !== undefined) {
await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8'); await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
} else if (options.gatewayLocalTier !== undefined) { } else if (options.gatewayLocalTier !== undefined) {
await writeFile( await writeFixture(
gatewayLocalEnvPath, fixture.gatewayLocalEnvPath,
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`, `MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
'utf8', tempRoot,
); );
} else if (existsSync(gatewayLocalEnvPath)) {
await rm(gatewayLocalEnvPath, { force: true });
} }
process.env['HOME'] = isolatedHome; process.env['HOME'] = homePath;
delete process.env['MOSAIC_STORAGE_TIER']; delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL']; delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL']; delete process.env['VALKEY_URL'];
await options.setup?.(); await options.setup?.(fixture);
if (options.inheritedTier !== undefined) { if (options.inheritedTier !== undefined) {
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier; process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
} }
vi.resetModules(); vi.resetModules();
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.cwdPath); vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
vi.doMock('node:url', () => ({
...nodeUrl,
fileURLToPath: (url: string | URL): string => {
const actualPath = nodeUrl.fileURLToPath(url);
if (
actualPath.endsWith('/apps/gateway/src/env.ts') ||
actualPath.endsWith('/apps/gateway/src/env.js')
) {
return join(anchor, 'env.ts');
}
return actualPath;
},
}));
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
try { if (options.inheritedTier === undefined) {
if (options.inheritedTier === undefined) { expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); } else {
} else { expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
}
await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
options.expectedProcessTier ?? options.rootTier,
);
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,
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
args.map((value: unknown): string => String(value)).join(' '),
),
};
} finally {
cwdSpy.mockRestore();
} }
await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
options.expectedProcessTier ?? options.rootTier,
);
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,
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
args.map((value: unknown): string => String(value)).join(' '),
),
};
} finally { } finally {
consoleInfoSpy.mockRestore(); cwdSpy?.mockRestore();
vi.doUnmock('node:url');
vi.doUnmock('node:os');
vi.resetModules();
consoleInfoSpy?.mockRestore();
restoreProcessEnv(originalEnv); restoreProcessEnv(originalEnv);
await restoreFile(rootEnvPath, rootSnapshot); await rm(tempRoot, { recursive: true, force: true });
await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot);
await rm(isolatedHome, { recursive: true, force: true });
} }
} }
@@ -221,26 +260,107 @@ describe('AppModule federation gating', (): void => {
}); });
it( it(
'ignores attacker-writable ambient cwd dotenv files', 'ignores ambient cwd/.env and cwd/../.env files',
async (): Promise<void> => { async (): Promise<void> => {
const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-')); const graph = await loadModuleGraphFromDotenv({
const ambientCwd = join(ambientRoot, 'sandbox', 'cwd'); rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
await writeFixture(
resolve(fixture.cwdPath, '..', '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
},
});
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
cwdPath: ambientCwd, },
rootTier: 'local', MODULE_IMPORT_TIMEOUT_MS,
setup: async (): Promise<void> => { );
await writeFile(join(ambientCwd, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
await writeFile(join(ambientRoot, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
},
});
expect(graph.imports).not.toContain(graph.federationModule); it(
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL); 'ignores an ambient cwd/mosaic.config.json federated config',
} finally { async (): Promise<void> => {
await rm(ambientRoot, { recursive: true, force: true }); const graph = await loadModuleGraphFromDotenv({
} rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'ignores an ambient cwd/../../mosaic.config.json federated config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'anchored monorepo-root config wins gateway-local config and registers FederationModule',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
await writeFixture(fixture.gatewayLocalConfigPath, configJson('local'), fixture.tempRoot);
},
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'logs mosaic.config.json when anchored config and env tiers are both federated',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -248,19 +368,42 @@ describe('AppModule federation gating', (): void => {
it( it(
'logs standalone from a monorepo-root .env DATABASE_URL fallback', 'logs standalone from a monorepo-root .env DATABASE_URL fallback',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
});
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
cwdPath, },
rootEnvContents: 'DATABASE_URL=fixture-database-url\n', MODULE_IMPORT_TIMEOUT_MS,
}); );
expect(graph.imports).not.toContain(graph.federationModule); it(
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); 'attributes an invalid monorepo-root dotenv tier to the default',
} finally { async (): Promise<void> => {
await rm(cwdPath, { recursive: true, force: true }); const graph = await loadModuleGraphFromDotenv({
} rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
expectedProcessTier: 'invalid',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', 'default');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
inheritedTier: 'local',
expectedProcessTier: 'local',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -268,22 +411,15 @@ describe('AppModule federation gating', (): void => {
it( it(
'daemon .env wins over monorepo-root and gateway-local tier values', 'daemon .env wins over monorepo-root and gateway-local tier values',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
expectedProcessTier: 'standalone',
});
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
cwdPath,
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -291,23 +427,16 @@ describe('AppModule federation gating', (): void => {
it( it(
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values', 'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
inheritedTier: 'standalone',
expectedProcessTier: 'standalone',
});
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
cwdPath,
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
inheritedTier: 'standalone',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -315,21 +444,14 @@ describe('AppModule federation gating', (): void => {
it( it(
'gateway-local .env configures the tier and source when the monorepo-root .env is absent', 'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
gatewayLocalTier: 'federated',
expectedProcessTier: 'federated',
});
try { expect(graph.imports).toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
cwdPath,
rootEnvMode: 'absent',
gatewayLocalTier: 'federated',
expectedProcessTier: 'federated',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -337,20 +459,13 @@ describe('AppModule federation gating', (): void => {
it( it(
'monorepo-root .env wins over gateway-local tier values', 'monorepo-root .env wins over gateway-local tier values',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({
rootTier: 'standalone',
gatewayLocalTier: 'federated',
});
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
cwdPath,
rootTier: 'standalone',
gatewayLocalTier: 'federated',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -358,19 +473,10 @@ describe('AppModule federation gating', (): void => {
it.each(['local', 'standalone'] as const)( it.each(['local', 'standalone'] as const)(
'does not register FederationModule for the %s tier', 'does not register FederationModule for the %s tier',
async (tier): Promise<void> => { async (tier): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
try { expect(graph.imports).not.toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
cwdPath,
rootTier: tier,
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
@@ -378,21 +484,15 @@ describe('AppModule federation gating', (): void => {
it( it(
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env', 'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
async (): Promise<void> => { async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); const redactionMarker = 'redaction-fixture-marker';
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
redactionMarker,
});
try { expect(graph.imports).toContain(graph.federationModule);
const graph = await loadModuleGraphFromDotenv({ expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
cwdPath, expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
rootTier: 'federated',
secret: 'super-secret-fixture-value',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
expect(singleBootLogLine(graph.bootLogLines)).not.toContain('super-secret-fixture-value');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
+2 -1
View File
@@ -27,6 +27,7 @@ import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js'; import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { loadConfig } from '@mosaicstack/config'; import { loadConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from './env.js';
// 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
@@ -34,7 +35,7 @@ import { loadConfig } from '@mosaicstack/config';
// federation profile "must not start in non-federated dev"). The gateway // federation profile "must not start in non-federated dev"). The gateway
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier // entrypoint loads env.ts before evaluating this module so dotenv-backed tier
// configuration is visible here. // configuration is visible here.
const federationEnabled = loadConfig().tier === 'federated'; const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
@Module({ @Module({
imports: [ imports: [
+101 -56
View File
@@ -14,67 +14,112 @@ type TierSource =
type BootSource = TierSource | 'mosaic.config.json'; type BootSource = TierSource | 'mosaic.config.json';
export interface GatewayDotenvPaths {
daemonEnv: string;
monorepoRootEnv: string;
gatewayLocalEnv: string;
}
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
const monorepoRootEnv = resolve(here, '../../..', '.env');
const gatewayLocalEnv = resolve(here, '..', '.env');
const inheritedTier = process.env['MOSAIC_STORAGE_TIER']; export function resolveGatewayDotenvPaths(
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment'; anchor: string = here,
const inheritedDatabaseUrl = process.env['DATABASE_URL']; homeBase: string = homedir(),
let databaseUrlSource: TierSource = ): GatewayDotenvPaths {
inheritedDatabaseUrl === undefined ? 'default' : 'process environment'; return {
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
function loadAnchoredDotenv( monorepoRootEnv: resolve(anchor, '../../..', '.env'),
path: string, gatewayLocalEnv: resolve(anchor, '..', '.env'),
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>, };
): void {
if (!existsSync(path)) {
return;
}
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
const beforeDatabaseUrl = process.env['DATABASE_URL'];
config({ path, quiet: true });
if (
beforeTier === undefined &&
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
tierSource === 'default'
) {
tierSource = sourceLabel;
}
if (
beforeDatabaseUrl === undefined &&
process.env['DATABASE_URL'] !== undefined &&
databaseUrlSource === 'default'
) {
databaseUrlSource = sourceLabel;
}
} }
// Load .env from daemon config dir (global install / daemon mode) first. export function resolveGatewayConfigPath(anchor: string = here): string {
// It takes precedence over file-based local-dev configuration. const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
loadAnchoredDotenv(daemonEnv, 'daemon .env'); const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
// Load .env from the anchored monorepo root, then fill any remaining values if (existsSync(monorepoRootConfig)) {
// from apps/gateway/.env when present. return monorepoRootConfig;
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env'); }
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env'); if (existsSync(gatewayLocalConfig)) {
return gatewayLocalConfig;
}
const envOnlyTier = detectFromEnv().tier; return monorepoRootConfig;
const resolvedTier = loadConfig().tier;
let source: BootSource;
if (resolvedTier !== envOnlyTier) {
source = 'mosaic.config.json';
} else if (tierSource !== 'default') {
source = tierSource;
} else if (envOnlyTier === 'standalone' && databaseUrlSource !== 'default') {
source = databaseUrlSource;
} else {
source = 'default';
} }
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`); export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
anchor,
homeBase,
);
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
let databaseUrlSource: TierSource =
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
function loadAnchoredDotenv(
path: string,
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
): void {
if (!existsSync(path)) {
return;
}
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
const beforeDatabaseUrl = process.env['DATABASE_URL'];
config({ path, quiet: true });
if (
beforeTier === undefined &&
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
tierSource === 'default'
) {
tierSource = sourceLabel;
}
if (
beforeDatabaseUrl === undefined &&
process.env['DATABASE_URL'] !== undefined &&
databaseUrlSource === 'default'
) {
databaseUrlSource = sourceLabel;
}
}
// Load .env from daemon config dir (global install / daemon mode) first.
// It takes precedence over file-based local-dev configuration.
loadAnchoredDotenv(daemonEnv, 'daemon .env');
// Load .env from the anchored monorepo root, then fill any remaining values
// from apps/gateway/.env when present.
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
const envOnlyTier = detectFromEnv().tier;
const configPath = resolveGatewayConfigPath(anchor);
const anchoredConfigExists = existsSync(configPath);
const resolvedTier = loadConfig(configPath).tier;
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
const recognizedTierDeterminesTier =
(configuredTier === 'federated' ||
configuredTier === 'standalone' ||
configuredTier === 'local') &&
configuredTier === envOnlyTier;
let source: BootSource;
if (anchoredConfigExists) {
source = 'mosaic.config.json';
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
source = databaseUrlSource;
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
source = tierSource;
} else {
source = 'default';
}
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
}
loadGatewayEnv();