fix(gateway): anchor config discovery and isolate env tests (#1138)
This commit is contained in:
+230
-130
@@ -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,70 +120,96 @@ 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 {
|
||||||
@@ -197,14 +237,13 @@ async function loadModuleGraphFromDotenv(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
cwdSpy.mockRestore();
|
cwdSpy?.mockRestore();
|
||||||
}
|
vi.doUnmock('node:url');
|
||||||
} finally {
|
vi.doUnmock('node:os');
|
||||||
consoleInfoSpy.mockRestore();
|
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 ambientCwd = join(ambientRoot, 'sandbox', 'cwd');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath: ambientCwd,
|
|
||||||
rootTier: 'local',
|
rootTier: 'local',
|
||||||
setup: async (): Promise<void> => {
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
await writeFile(join(ambientCwd, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
|
await writeFixture(
|
||||||
await writeFile(join(ambientRoot, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
|
join(fixture.cwdPath, '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '..', '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
} finally {
|
},
|
||||||
await rm(ambientRoot, { recursive: true, force: true });
|
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(
|
||||||
|
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-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
} finally {
|
},
|
||||||
await rm(cwdPath, { recursive: true, force: true });
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
}
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
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,11 +411,7 @@ 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-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootTier: 'local',
|
rootTier: 'local',
|
||||||
gatewayLocalTier: 'federated',
|
gatewayLocalTier: 'federated',
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||||
@@ -281,9 +420,6 @@ describe('AppModule federation gating', (): void => {
|
|||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
} finally {
|
|
||||||
await rm(cwdPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
@@ -291,11 +427,7 @@ 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-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootTier: 'local',
|
rootTier: 'local',
|
||||||
gatewayLocalTier: 'federated',
|
gatewayLocalTier: 'federated',
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
@@ -305,9 +437,6 @@ describe('AppModule federation gating', (): void => {
|
|||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||||
} finally {
|
|
||||||
await rm(cwdPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
@@ -315,11 +444,7 @@ 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-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootEnvMode: 'absent',
|
rootEnvMode: 'absent',
|
||||||
gatewayLocalTier: 'federated',
|
gatewayLocalTier: 'federated',
|
||||||
expectedProcessTier: 'federated',
|
expectedProcessTier: 'federated',
|
||||||
@@ -327,9 +452,6 @@ describe('AppModule federation gating', (): void => {
|
|||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
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-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootTier: 'standalone',
|
rootTier: 'standalone',
|
||||||
gatewayLocalTier: 'federated',
|
gatewayLocalTier: 'federated',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
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 {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
cwdPath,
|
|
||||||
rootTier: tier,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
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';
|
||||||
|
|
||||||
try {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
cwdPath,
|
|
||||||
rootTier: 'federated',
|
rootTier: 'federated',
|
||||||
secret: 'super-secret-fixture-value',
|
redactionMarker,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain('super-secret-fixture-value');
|
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||||
} finally {
|
|
||||||
await rm(cwdPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
+72
-27
@@ -14,21 +14,54 @@ type TierSource =
|
|||||||
|
|
||||||
type BootSource = TierSource | 'mosaic.config.json';
|
type BootSource = TierSource | 'mosaic.config.json';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
export interface GatewayDotenvPaths {
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
daemonEnv: string;
|
||||||
const monorepoRootEnv = resolve(here, '../../..', '.env');
|
monorepoRootEnv: string;
|
||||||
const gatewayLocalEnv = resolve(here, '..', '.env');
|
gatewayLocalEnv: string;
|
||||||
|
}
|
||||||
|
|
||||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
|
||||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
export function resolveGatewayDotenvPaths(
|
||||||
let databaseUrlSource: TierSource =
|
anchor: string = here,
|
||||||
|
homeBase: string = homedir(),
|
||||||
|
): GatewayDotenvPaths {
|
||||||
|
return {
|
||||||
|
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||||
|
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
if (existsSync(monorepoRootConfig)) {
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(gatewayLocalConfig)) {
|
||||||
|
return gatewayLocalConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||||
|
|
||||||
function loadAnchoredDotenv(
|
function loadAnchoredDotenv(
|
||||||
path: string,
|
path: string,
|
||||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||||
): void {
|
): void {
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,29 +85,41 @@ function loadAnchoredDotenv(
|
|||||||
) {
|
) {
|
||||||
databaseUrlSource = sourceLabel;
|
databaseUrlSource = sourceLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||||
// It takes precedence over file-based local-dev configuration.
|
// It takes precedence over file-based local-dev configuration.
|
||||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||||
|
|
||||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||||
// from apps/gateway/.env when present.
|
// from apps/gateway/.env when present.
|
||||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||||
|
|
||||||
const envOnlyTier = detectFromEnv().tier;
|
const envOnlyTier = detectFromEnv().tier;
|
||||||
const resolvedTier = loadConfig().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;
|
let source: BootSource;
|
||||||
if (resolvedTier !== envOnlyTier) {
|
if (anchoredConfigExists) {
|
||||||
source = 'mosaic.config.json';
|
source = 'mosaic.config.json';
|
||||||
} else if (tierSource !== 'default') {
|
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||||
source = tierSource;
|
|
||||||
} else if (envOnlyTier === 'standalone' && databaseUrlSource !== 'default') {
|
|
||||||
source = databaseUrlSource;
|
source = databaseUrlSource;
|
||||||
} else {
|
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||||
|
source = tierSource;
|
||||||
|
} else {
|
||||||
source = 'default';
|
source = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
loadGatewayEnv();
|
||||||
|
|||||||
Reference in New Issue
Block a user