Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34e92cf39 | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 |
@@ -1,624 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { mkdtemp, mkdir, readFile, 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 { 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;
|
|
||||||
resolvedConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
|
||||||
|
|
||||||
interface ModuleGraphFixture {
|
|
||||||
tempRoot: string;
|
|
||||||
anchor: string;
|
|
||||||
homePath: string;
|
|
||||||
cwdPath: string;
|
|
||||||
monorepoRootEnvPath: string;
|
|
||||||
gatewayLocalEnvPath: string;
|
|
||||||
daemonEnvPath: string;
|
|
||||||
monorepoRootConfigPath: string;
|
|
||||||
gatewayLocalConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModuleGraphFixtureOptions {
|
|
||||||
rootEnvMode?: 'present' | 'absent';
|
|
||||||
rootTier?: StorageTier;
|
|
||||||
rootEnvContents?: string;
|
|
||||||
redactionMarker?: string;
|
|
||||||
gatewayLocalTier?: StorageTier;
|
|
||||||
gatewayLocalEnvContents?: string;
|
|
||||||
daemonEnvContents?: string;
|
|
||||||
inheritedTier?: StorageTier;
|
|
||||||
expectedProcessTier?: string;
|
|
||||||
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
|
||||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
|
||||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
|
||||||
|
|
||||||
function configJson(tier: StorageTier): string {
|
|
||||||
if (tier === 'local') {
|
|
||||||
return JSON.stringify({
|
|
||||||
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> {
|
|
||||||
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 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;
|
|
||||||
if (bootLogLine === undefined) {
|
|
||||||
throw new Error('Expected a single boot log line');
|
|
||||||
}
|
|
||||||
|
|
||||||
return bootLogLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectBootLogLine(
|
|
||||||
bootLogLines: readonly string[],
|
|
||||||
tier: StorageTier,
|
|
||||||
source: string,
|
|
||||||
): void {
|
|
||||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
|
||||||
|
|
||||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
|
||||||
expect(bootLogLine).toContain(`source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModuleGraphFromDotenv(
|
|
||||||
options: ModuleGraphFixtureOptions,
|
|
||||||
): Promise<ComposedModuleGraph> {
|
|
||||||
const originalEnv = snapshotProcessEnv();
|
|
||||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
|
||||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
|
|
||||||
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.rootEnvContents !== undefined ||
|
|
||||||
options.rootTier !== undefined ||
|
|
||||||
options.redactionMarker !== undefined
|
|
||||||
) {
|
|
||||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
|
||||||
throw new Error('Expected rootTier or rootEnvContents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
|
||||||
const rootFixtureWithMarker = options.redactionMarker
|
|
||||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
|
||||||
: rootFixture;
|
|
||||||
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.daemonEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.gatewayLocalEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
|
||||||
} else if (options.gatewayLocalTier !== undefined) {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalEnvPath,
|
|
||||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
|
||||||
tempRoot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env['HOME'] = homePath;
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
delete process.env['MOSAIC_GATEWAY_HOME'];
|
|
||||||
|
|
||||||
await options.setup?.(fixture);
|
|
||||||
|
|
||||||
if (options.inheritedTier !== undefined) {
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (options.inheritedTier === undefined) {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envModule = 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
cwdSpy?.mockRestore();
|
|
||||||
vi.doUnmock('node:url');
|
|
||||||
vi.doUnmock('node:os');
|
|
||||||
vi.resetModules();
|
|
||||||
consoleInfoSpy?.mockRestore();
|
|
||||||
restoreProcessEnv(originalEnv);
|
|
||||||
await rm(tempRoot, { 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(
|
|
||||||
'ignores ambient cwd/.env and cwd/../.env files',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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(
|
|
||||||
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 gateway-local config wins monorepo-root config and registers FederationModule',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let gatewayLocalConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let daemonConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
|
||||||
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
|
||||||
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('standalone'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
|
||||||
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
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');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
expectedProcessTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'monorepo-root .env wins over gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'standalone',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['local', 'standalone'] as const)(
|
|
||||||
'does not register FederationModule for the %s tier',
|
|
||||||
async (tier): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const redactionMarker = 'redaction-fixture-marker';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'federated',
|
|
||||||
redactionMarker,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -26,16 +26,6 @@ import { WorkspaceModule } from './workspace/workspace.module.js';
|
|||||||
import { QueueModule } from './queue/queue.module.js';
|
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 { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
|
||||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
|
||||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
|
||||||
// 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(resolveGatewayConfigPath()).tier === 'federated';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -63,7 +53,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
import { resolveGatewayConfigPath } from '../env.js';
|
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
useFactory: (): MosaicConfig => loadConfig(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
type TierSource =
|
|
||||||
| 'process environment'
|
|
||||||
| 'daemon .env'
|
|
||||||
| 'monorepo-root .env'
|
|
||||||
| 'gateway-local .env'
|
|
||||||
| 'default';
|
|
||||||
|
|
||||||
type BootSource = TierSource | 'mosaic.config.json';
|
|
||||||
|
|
||||||
export interface GatewayDotenvPaths {
|
|
||||||
daemonEnv: string;
|
|
||||||
monorepoRootEnv: string;
|
|
||||||
gatewayLocalEnv: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export function resolveGatewayDotenvPaths(
|
|
||||||
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 {
|
|
||||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
|
||||||
const gatewayHome = resolve(
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
|
||||||
);
|
|
||||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
|
||||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
|
||||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
if (existsSync(daemonConfig)) {
|
|
||||||
return daemonConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(gatewayLocalConfig)) {
|
|
||||||
return gatewayLocalConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(monorepoRootConfig)) {
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
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();
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import './env.js';
|
import { config } from 'dotenv';
|
||||||
|
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';
|
||||||
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
const mosaicConfig = loadConfig();
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
|
||||||
|
const bootstrapMock = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
projectId: 'project-1',
|
||||||
|
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('POST /api/workspaces repoUrl validation', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [WorkspaceController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ProjectBootstrapService,
|
||||||
|
useValue: { bootstrap: bootstrapMock },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bootstrapMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||||
|
name: 'Example',
|
||||||
|
description: undefined,
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: undefined,
|
||||||
|
repoUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
import {
|
||||||
|
ProjectBootstrapService,
|
||||||
|
type BootstrapProjectResult,
|
||||||
|
} from './project-bootstrap.service.js';
|
||||||
|
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -11,20 +15,14 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body()
|
@Body() dto: CreateWorkspaceDto,
|
||||||
body: {
|
): Promise<BootstrapProjectResult> {
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
teamId?: string;
|
|
||||||
repoUrl?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: body.name,
|
name: dto.name,
|
||||||
description: body.description,
|
description: dto.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: body.teamId,
|
teamId: dto.teamId,
|
||||||
repoUrl: body.repoUrl,
|
repoUrl: dto.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
teamId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(?:https|git):\/\//i, {
|
||||||
|
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||||
|
})
|
||||||
|
@IsUrl(
|
||||||
|
{
|
||||||
|
protocols: ['https', 'git'],
|
||||||
|
require_host: true,
|
||||||
|
require_protocol: true,
|
||||||
|
require_tld: false,
|
||||||
|
require_valid_protocol: true,
|
||||||
|
},
|
||||||
|
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||||
|
)
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
type ExecFileMock = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({
|
||||||
|
execFileMock: vi.fn<ExecFileMock>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||||
|
callback(null, '', '');
|
||||||
|
});
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const project = {
|
||||||
|
id: 'project-1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let originalRoot: string | undefined;
|
||||||
|
let temporaryRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||||
|
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||||
|
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(execFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||||
|
const workspacePath = await service.create(project, repoUrl);
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalledOnce();
|
||||||
|
expect(execFileMock).toHaveBeenCalledWith(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||||
|
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||||
|
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||||
|
|
||||||
|
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||||
|
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(repoUrl);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,14 +59,32 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
if (repoUrl !== undefined) {
|
||||||
|
assertAllowedRepositoryUrl(repoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl) {
|
if (repoUrl !== undefined) {
|
||||||
// Clone existing repo
|
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
// disabled and terminates option parsing before positional arguments.
|
||||||
|
await execFileAsync(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
);
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# WorkspaceService shells out to git at runtime and roots workspaces at
|
||||||
|
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||||
|
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||||
|
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||||
|
# git was declined so routine base-image security updates remain maintainable.
|
||||||
|
RUN apk add --no-cache git \
|
||||||
|
&& mkdir -p /opt/mosaic/.workspaces \
|
||||||
|
&& chown -R node:node /opt/mosaic /app
|
||||||
|
ENV MOSAIC_ROOT=/opt/mosaic
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||||
EXPOSE 4000
|
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||||
|
EXPOSE 14242
|
||||||
|
USER node
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/web build
|
RUN pnpm --filter @mosaicstack/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ export async function installHooks({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['--version']);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
console.warn('git not found; skipping hook installation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const huskyDir = path.join(root, '.husky');
|
const huskyDir = path.join(root, '.husky');
|
||||||
const active = path.join(huskyDir, '_');
|
const active = path.join(huskyDir, '_');
|
||||||
const nonce = `${Date.now()}-${process.pid}`;
|
const nonce = `${Date.now()}-${process.pid}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user