Files
Codewalkers/apps/server/preview/health-checker.ts
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
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
2026-03-05 12:22:29 +01:00

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));
}