fix(gateway): anchor remaining config loads (#1138)
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
2d5a8c81ec
commit
bd0ef2ab25
@@ -5,11 +5,13 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
interface ComposedModuleGraph {
|
||||
imports: readonly unknown[];
|
||||
federationModule: unknown;
|
||||
bootLogLines: readonly string[];
|
||||
mosaicConfig: MosaicConfig;
|
||||
}
|
||||
|
||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||
@@ -95,6 +97,23 @@ async function writeFixture(path: string, contents: string, tempRoot: string): P
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface ConfigModuleProvider {
|
||||
provide: string;
|
||||
useFactory: () => MosaicConfig;
|
||||
}
|
||||
|
||||
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||
}
|
||||
|
||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||
expect(bootLogLines).toHaveLength(1);
|
||||
const [bootLogLine] = bootLogLines;
|
||||
@@ -229,12 +248,28 @@ async function loadModuleGraphFromDotenv(
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
}
|
||||
|
||||
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||
|
||||
if (!Array.isArray(providers)) {
|
||||
throw new Error('ConfigModule providers metadata is not an array');
|
||||
}
|
||||
|
||||
const configProvider = providers
|
||||
.filter(isConfigModuleProvider)
|
||||
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||
|
||||
if (!configProvider) {
|
||||
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||
}
|
||||
|
||||
return {
|
||||
imports,
|
||||
federationModule: FederationModule,
|
||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||
args.map((value: unknown): string => String(value)).join(' '),
|
||||
),
|
||||
mosaicConfig: configProvider.useFactory(),
|
||||
};
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
@@ -496,4 +531,57 @@ describe('AppModule federation gating', (): void => {
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||
JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
}),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('local');
|
||||
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
fixture.monorepoRootConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.mosaicConfig.storage).toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||
import { resolveGatewayConfigPath } from '../env.js';
|
||||
|
||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
|
||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
providers: [
|
||||
{
|
||||
provide: MOSAIC_CONFIG,
|
||||
useFactory: (): MosaicConfig => loadConfig(),
|
||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||
},
|
||||
],
|
||||
exports: [MOSAIC_CONFIG],
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||
const relativePath = relative(tempRoot, path);
|
||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface BootstrapPreflightResult {
|
||||
capturedConfig: MosaicConfig | undefined;
|
||||
}
|
||||
|
||||
async function runBootstrapPreflight(
|
||||
anchoredConfigContents: string,
|
||||
ambientConfigContents: string,
|
||||
): Promise<BootstrapPreflightResult> {
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let capturedConfig: MosaicConfig | undefined;
|
||||
|
||||
try {
|
||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||
const homePath = join(tempRoot, 'home');
|
||||
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||
|
||||
await mkdir(anchor, { recursive: true });
|
||||
await mkdir(cwdPath, { recursive: true });
|
||||
|
||||
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||
|
||||
process.env['HOME'] = homePath;
|
||||
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
const exitMock = vi.fn<typeof process.exit>();
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||
|
||||
vi.resetModules();
|
||||
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);
|
||||
vi.doMock('./tracing.js', () => ({}));
|
||||
|
||||
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||
vi.doMock('@mosaicstack/storage', async () => {
|
||||
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||
return {
|
||||
...actual,
|
||||
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||
capturedConfig = config;
|
||||
throw preflightSentinel;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await import('./main.js');
|
||||
await vi.waitFor((): void => {
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
return { capturedConfig };
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
exitSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
vi.doUnmock('@mosaicstack/storage');
|
||||
vi.doUnmock('./tracing.js');
|
||||
vi.doUnmock('node:url');
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('main bootstrap preflight config anchoring', (): void => {
|
||||
it(
|
||||
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||
async (): Promise<void> => {
|
||||
const anchoredConfig = JSON.stringify({
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
});
|
||||
const ambientConfig = JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
});
|
||||
|
||||
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||
|
||||
expect(capturedConfig?.tier).toBe('local');
|
||||
expect(capturedConfig?.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
@@ -24,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
||||
// Pre-flight: assert all external services required by the configured tier
|
||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||
// immediately with actionable remediation hints.
|
||||
const mosaicConfig = loadConfig();
|
||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||
try {
|
||||
await detectAndAssertTier(mosaicConfig);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user