Refactor preview deployments to use a single shared Caddy gateway container with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy sidecar and one port per preview. Adds dev/preview modes, git worktree support for branch checkouts, and auto-start on phase:pending_review. - Add GatewayManager for shared Caddy lifecycle + Caddyfile generation - Add git worktree helpers for preview mode branch checkouts - Add dev mode: volume-mount + dev server image instead of build - Remove per-preview Caddy sidecar and port publishing - Use shared cw-preview-net Docker network with container name DNS - Auto-start previews when phase enters pending_review - Delete unused PreviewPanel.tsx - Update all tests (40 pass), docs, events, CLI, tRPC, frontend
104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
/**
|
|
* Health Checker
|
|
*
|
|
* Polls service healthcheck endpoints through the gateway's subdomain routing
|
|
* to verify that preview services are ready.
|
|
*/
|
|
|
|
import type { PreviewConfig, HealthResult } from './types.js';
|
|
import { createModuleLogger } from '../logger/index.js';
|
|
|
|
const log = createModuleLogger('preview:health');
|
|
|
|
/** Default timeout for health checks (120 seconds) */
|
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
|
/** Default polling interval (3 seconds) */
|
|
const DEFAULT_INTERVAL_MS = 3_000;
|
|
|
|
/**
|
|
* Wait for all non-internal services to become healthy by polling their
|
|
* healthcheck endpoints through the gateway's subdomain routing.
|
|
*
|
|
* @param previewId - The preview deployment ID (used as subdomain)
|
|
* @param gatewayPort - The gateway's host port
|
|
* @param config - Preview config with service definitions
|
|
* @param timeoutMs - Maximum time to wait (default: 120s)
|
|
* @returns Per-service health results
|
|
*/
|
|
export async function waitForHealthy(
|
|
previewId: string,
|
|
gatewayPort: number,
|
|
config: PreviewConfig,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
): Promise<HealthResult[]> {
|
|
const services = Object.values(config.services).filter((svc) => {
|
|
if (svc.internal) return false;
|
|
if (!svc.healthcheck?.path) return false;
|
|
return true;
|
|
});
|
|
|
|
if (services.length === 0) {
|
|
log.info('no healthcheck endpoints configured, skipping health wait');
|
|
return [];
|
|
}
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
const results = new Map<string, HealthResult>();
|
|
|
|
// Initialize all as unhealthy
|
|
for (const svc of services) {
|
|
results.set(svc.name, { name: svc.name, healthy: false });
|
|
}
|
|
|
|
while (Date.now() < deadline) {
|
|
const pending = services.filter((svc) => !results.get(svc.name)!.healthy);
|
|
if (pending.length === 0) break;
|
|
|
|
await Promise.all(
|
|
pending.map(async (svc) => {
|
|
const route = svc.route ?? '/';
|
|
const healthPath = svc.healthcheck!.path;
|
|
const basePath = route === '/' ? '' : route;
|
|
const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`;
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (response.ok) {
|
|
log.info({ service: svc.name, url }, 'service healthy');
|
|
results.set(svc.name, { name: svc.name, healthy: true });
|
|
}
|
|
} catch {
|
|
// Not ready yet
|
|
}
|
|
}),
|
|
);
|
|
|
|
const stillPending = services.filter((svc) => !results.get(svc.name)!.healthy);
|
|
if (stillPending.length === 0) break;
|
|
|
|
log.debug(
|
|
{ pending: stillPending.map((s) => s.name) },
|
|
'waiting for services to become healthy',
|
|
);
|
|
await sleep(DEFAULT_INTERVAL_MS);
|
|
}
|
|
|
|
// Mark timed-out services
|
|
for (const svc of services) {
|
|
const result = results.get(svc.name)!;
|
|
if (!result.healthy) {
|
|
result.error = 'health check timed out';
|
|
log.warn({ service: svc.name }, 'service health check timed out');
|
|
}
|
|
}
|
|
|
|
return Array.from(results.values());
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|