144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|