feat(gateway,storage): mosaic gateway doctor with tier health JSON (FED-M1-06) (#475)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #475.
This commit is contained in:
2026-04-20 01:00:39 +00:00
parent ccad30dd27
commit 1a4b1ebbf1
11 changed files with 1233 additions and 289 deletions

View File

@@ -0,0 +1,294 @@
/**
* Unit tests for gateway-doctor.ts (mosaic gateway doctor).
*
* All external I/O is mocked — no live services required.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { TierHealthReport } from '@mosaicstack/storage';
/* ------------------------------------------------------------------ */
/* Shared mock state */
/* ------------------------------------------------------------------ */
const mocks = vi.hoisted(() => {
const mockLoadConfig = vi.fn();
const mockProbeServiceHealth = vi.fn();
const mockExistsSync = vi.fn();
return { mockLoadConfig, mockProbeServiceHealth, mockExistsSync };
});
/* ------------------------------------------------------------------ */
/* Module mocks */
/* ------------------------------------------------------------------ */
vi.mock('@mosaicstack/config', () => ({
loadConfig: mocks.mockLoadConfig,
}));
vi.mock('@mosaicstack/storage', () => ({
probeServiceHealth: mocks.mockProbeServiceHealth,
}));
vi.mock('node:fs', () => ({
existsSync: mocks.mockExistsSync,
}));
/* ------------------------------------------------------------------ */
/* Import SUT */
/* ------------------------------------------------------------------ */
import { runGatewayDoctor } from './gateway-doctor.js';
import type { MosaicConfig } from '@mosaicstack/config';
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
const STANDALONE_CONFIG: MosaicConfig = {
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
queue: { type: 'bullmq', url: 'redis://localhost:6380' },
memory: { type: 'keyword' },
};
const GREEN_REPORT: TierHealthReport = {
tier: 'standalone',
configPath: '/some/mosaic.config.json',
overall: 'green',
services: [
{ name: 'postgres', status: 'ok', host: 'localhost', port: 5432, durationMs: 42 },
{ name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 10 },
{ name: 'pgvector', status: 'skipped', durationMs: 0 },
],
};
const RED_REPORT: TierHealthReport = {
tier: 'standalone',
configPath: '/some/mosaic.config.json',
overall: 'red',
services: [
{
name: 'postgres',
status: 'fail',
host: 'localhost',
port: 5432,
durationMs: 5001,
error: {
message: 'connection refused',
remediation: 'Start Postgres: `docker compose ...`',
},
},
{ name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 8 },
{ name: 'pgvector', status: 'skipped', durationMs: 0 },
],
};
const FEDERATED_GREEN_REPORT: TierHealthReport = {
tier: 'federated',
configPath: '/some/mosaic.config.json',
overall: 'green',
services: [
{ name: 'postgres', status: 'ok', host: 'localhost', port: 5433, durationMs: 30 },
{ name: 'valkey', status: 'ok', host: 'localhost', port: 6380, durationMs: 5 },
{ name: 'pgvector', status: 'ok', host: 'localhost', port: 5433, durationMs: 25 },
],
};
/* ------------------------------------------------------------------ */
/* Process helpers */
/* ------------------------------------------------------------------ */
let stdoutCapture = '';
let exitCode: number | undefined;
function captureOutput(): void {
stdoutCapture = '';
exitCode = undefined;
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutCapture += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
});
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
exitCode = typeof code === 'number' ? code : code != null ? Number(code) : undefined;
throw new Error(`process.exit(${String(code)})`);
});
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdoutCapture += args.join(' ') + '\n';
});
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('runGatewayDoctor', () => {
beforeEach(() => {
vi.clearAllMocks();
captureOutput();
// By default: no config file on disk (env-detection path)
mocks.mockExistsSync.mockReturnValue(false);
mocks.mockLoadConfig.mockReturnValue(STANDALONE_CONFIG);
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
});
afterEach(() => {
vi.restoreAllMocks();
});
/* ---------------------------------------------------------------- */
/* 1. JSON mode: parseable JSON matching the schema */
/* ---------------------------------------------------------------- */
it('JSON mode emits parseable JSON matching TierHealthReport schema', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
await runGatewayDoctor({ json: true });
const parsed = JSON.parse(stdoutCapture) as TierHealthReport;
expect(parsed.tier).toBe('standalone');
expect(parsed.overall).toBe('green');
expect(Array.isArray(parsed.services)).toBe(true);
expect(parsed.services).toHaveLength(3);
// Validate shape of each service check
for (const svc of parsed.services) {
expect(['postgres', 'valkey', 'pgvector']).toContain(svc.name);
expect(['ok', 'fail', 'skipped']).toContain(svc.status);
expect(typeof svc.durationMs).toBe('number');
}
// JSON mode must be silent on console.log — output goes to process.stdout only.
expect(console.log).not.toHaveBeenCalled();
});
it('JSON mode for federated with 3 ok services', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(FEDERATED_GREEN_REPORT);
await runGatewayDoctor({ json: true });
const parsed = JSON.parse(stdoutCapture) as TierHealthReport;
expect(parsed.tier).toBe('federated');
expect(parsed.overall).toBe('green');
expect(parsed.services.every((s) => s.status === 'ok')).toBe(true);
// JSON mode must be silent on console.log — output goes to process.stdout only.
expect(console.log).not.toHaveBeenCalled();
});
/* ---------------------------------------------------------------- */
/* 2. Plain text mode: service lines and overall verdict */
/* ---------------------------------------------------------------- */
it('plain text mode includes service lines for each service', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
await runGatewayDoctor({});
expect(stdoutCapture).toContain('postgres');
expect(stdoutCapture).toContain('valkey');
expect(stdoutCapture).toContain('pgvector');
});
it('plain text mode includes Overall verdict', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
await runGatewayDoctor({});
expect(stdoutCapture).toContain('Overall: GREEN');
});
it('plain text mode shows tier and config path in header', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
await runGatewayDoctor({});
expect(stdoutCapture).toContain('Tier: standalone');
});
it('plain text mode shows remediation for failed services', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT);
try {
await runGatewayDoctor({});
} catch {
// process.exit throws in test
}
expect(stdoutCapture).toContain('Remediations:');
expect(stdoutCapture).toContain('Start Postgres');
});
/* ---------------------------------------------------------------- */
/* 3. Exit codes */
/* ---------------------------------------------------------------- */
it('exits with code 1 when overall is red', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT);
await expect(runGatewayDoctor({})).rejects.toThrow('process.exit(1)');
expect(exitCode).toBe(1);
});
it('exits with code 0 (no exit call) when overall is green', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(GREEN_REPORT);
await runGatewayDoctor({});
// process.exit should NOT have been called for green.
expect(exitCode).toBeUndefined();
});
it('JSON mode exits with code 1 when overall is red', async () => {
mocks.mockProbeServiceHealth.mockResolvedValue(RED_REPORT);
await expect(runGatewayDoctor({ json: true })).rejects.toThrow('process.exit(1)');
expect(exitCode).toBe(1);
});
/* ---------------------------------------------------------------- */
/* 4. --config path override is honored */
/* ---------------------------------------------------------------- */
it('passes --config path to loadConfig when provided', async () => {
const customPath = '/custom/path/mosaic.config.json';
await runGatewayDoctor({ config: customPath });
// loadConfig should have been called with the resolved custom path.
expect(mocks.mockLoadConfig).toHaveBeenCalledWith(
expect.stringContaining('mosaic.config.json'),
);
// The exact call should include the custom path (resolved).
const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined];
expect(calledPath).toContain('custom/path/mosaic.config.json');
});
it('calls loadConfig without path when no --config and no file on disk', async () => {
mocks.mockExistsSync.mockReturnValue(false);
await runGatewayDoctor({});
const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined];
// When no file found, resolveConfigPath returns undefined, so loadConfig is called with undefined
expect(calledPath).toBeUndefined();
});
it('finds config from cwd when mosaic.config.json exists there', async () => {
// First candidate (cwd/mosaic.config.json) exists.
mocks.mockExistsSync.mockImplementation((p: unknown) => {
return typeof p === 'string' && p.endsWith('mosaic.config.json');
});
await runGatewayDoctor({});
const [calledPath] = mocks.mockLoadConfig.mock.calls[0] as [string | undefined];
expect(calledPath).toBeDefined();
expect(typeof calledPath).toBe('string');
expect(calledPath!.endsWith('mosaic.config.json')).toBe(true);
});
});

View File

@@ -0,0 +1,143 @@
/**
* gateway-doctor.ts — `mosaic gateway doctor` implementation.
*
* Reports current tier and per-service health (PG, Valkey, pgvector) for the
* Mosaic gateway. Supports machine-readable JSON output for CI.
*
* Exit codes:
* 0 — overall green or yellow
* 1 — overall red (at least one required service failed)
*/
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig } from '@mosaicstack/config';
import { probeServiceHealth } from '@mosaicstack/storage';
import type { TierHealthReport, ServiceCheck } from '@mosaicstack/storage';
/* ------------------------------------------------------------------ */
/* Config resolution */
/* ------------------------------------------------------------------ */
const CONFIG_CANDIDATES = [
resolve(process.cwd(), 'mosaic.config.json'),
join(homedir(), '.mosaic', 'mosaic.config.json'),
];
/**
* Resolve the config path to report in output.
*
* Priority:
* 1. Explicit `--config <path>` flag
* 2. `./mosaic.config.json` (cwd)
* 3. `~/.mosaic/mosaic.config.json`
* 4. undefined — `loadConfig()` falls back to env-var detection
*
* `loadConfig()` itself already handles priority 1-3 when passed an explicit
* path, and falls back to env-detection when none exists. We resolve here
* only so we can surface the path in the health report.
*/
function resolveConfigPath(explicit?: string): string | undefined {
if (explicit) return resolve(explicit);
for (const candidate of CONFIG_CANDIDATES) {
if (existsSync(candidate)) return candidate;
}
return undefined;
}
/* ------------------------------------------------------------------ */
/* Output helpers */
/* ------------------------------------------------------------------ */
const TICK = '\u2713'; // ✓
const CROSS = '\u2717'; // ✗
const SKIP = '-';
function padRight(s: string, n: number): string {
return s + ' '.repeat(Math.max(0, n - s.length));
}
function serviceLabel(svc: ServiceCheck): string {
const hostPort =
svc.host !== undefined && svc.port !== undefined ? `${svc.host}:${svc.port.toString()}` : '';
const duration = `(${svc.durationMs.toString()}ms)`;
switch (svc.status) {
case 'ok':
return ` ${TICK} ${padRight(svc.name, 10)} ${padRight(hostPort, 22)} ${duration}`;
case 'fail': {
const errMsg = svc.error?.message ?? 'unknown error';
return ` ${CROSS} ${padRight(svc.name, 10)} ${padRight(hostPort, 22)} ${duration} \u2192 ${errMsg}`;
}
case 'skipped':
return ` ${SKIP} ${padRight(svc.name, 10)} (skipped)`;
}
}
function printReport(report: TierHealthReport): void {
const configDisplay = report.configPath ?? '(auto-detected)';
console.log(`Tier: ${report.tier} Config: ${configDisplay}`);
console.log('');
for (const svc of report.services) {
console.log(serviceLabel(svc));
}
console.log('');
// Print remediations for failed services.
const failed = report.services.filter((s) => s.status === 'fail' && s.error);
if (failed.length > 0) {
console.log('Remediations:');
for (const svc of failed) {
if (svc.error) {
console.log(` ${svc.name}: ${svc.error.remediation}`);
}
}
console.log('');
}
console.log(`Overall: ${report.overall.toUpperCase()}`);
}
/* ------------------------------------------------------------------ */
/* Main runner */
/* ------------------------------------------------------------------ */
export interface GatewayDoctorOptions {
json?: boolean;
config?: string;
}
export async function runGatewayDoctor(opts: GatewayDoctorOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
let mosaicConfig;
try {
mosaicConfig = loadConfig(configPath);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
process.stdout.write(
JSON.stringify({ error: `Failed to load config: ${msg}` }, null, 2) + '\n',
);
} else {
process.stderr.write(`Error: Failed to load config: ${msg}\n`);
}
process.exit(1);
}
const report = await probeServiceHealth(mosaicConfig, configPath);
if (opts.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
printReport(report);
}
// Exit 1 if overall is red.
if (report.overall === 'red') {
process.exit(1);
}
}

View File

@@ -206,4 +206,15 @@ export function registerGatewayCommand(program: Command): void {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
});
// ─── doctor ─────────────────────────────────────────────────────────────────
gw.command('doctor')
.description('Check gateway tier and per-service health (PG, Valkey, pgvector)')
.option('--json', 'Emit TierHealthReport as JSON to stdout (suppresses all other output)')
.option('--config <path>', 'Path to mosaic.config.json (defaults to cwd or ~/.mosaic/)')
.action(async (cmdOpts: { json?: boolean; config?: string }) => {
const { runGatewayDoctor } = await import('./gateway-doctor.js');
await runGatewayDoctor({ json: cmdOpts.json, config: cmdOpts.config });
});
}