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
241 lines
6.9 KiB
TypeScript
241 lines
6.9 KiB
TypeScript
/**
|
|
* Gateway Manager
|
|
*
|
|
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
|
* subdomain requests to per-preview compose stacks on a shared Docker network.
|
|
*
|
|
* Architecture:
|
|
* .cw-previews/gateway/
|
|
* docker-compose.yml ← single Caddy container
|
|
* Caddyfile ← regenerated on each preview add/remove
|
|
*
|
|
* Caddy runs with `--watch` so it auto-reloads when the Caddyfile changes on disk.
|
|
*/
|
|
|
|
import { join } from 'node:path';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import yaml from 'js-yaml';
|
|
import { GATEWAY_PROJECT_NAME, GATEWAY_NETWORK } from './types.js';
|
|
import {
|
|
ensureDockerNetwork,
|
|
removeDockerNetwork,
|
|
composeUp,
|
|
composeDown,
|
|
composePs,
|
|
} from './docker-client.js';
|
|
import { allocatePort } from './port-allocator.js';
|
|
import { createModuleLogger } from '../logger/index.js';
|
|
|
|
const log = createModuleLogger('preview:gateway');
|
|
|
|
/** Directory for preview deployment artifacts (relative to workspace root) */
|
|
const PREVIEWS_DIR = '.cw-previews';
|
|
|
|
/**
|
|
* A route entry for the gateway Caddyfile.
|
|
*/
|
|
export interface GatewayRoute {
|
|
containerName: string;
|
|
port: number;
|
|
route: string;
|
|
}
|
|
|
|
export class GatewayManager {
|
|
private readonly workspaceRoot: string;
|
|
private cachedPort: number | null = null;
|
|
|
|
constructor(workspaceRoot: string) {
|
|
this.workspaceRoot = workspaceRoot;
|
|
}
|
|
|
|
/**
|
|
* Ensure the gateway is running. Idempotent — returns the port if already up.
|
|
*
|
|
* 1. Create the shared Docker network
|
|
* 2. Check if gateway already running → return existing port
|
|
* 3. Allocate a port, write compose + empty Caddyfile, start
|
|
*/
|
|
async ensureGateway(): Promise<number> {
|
|
await ensureDockerNetwork(GATEWAY_NETWORK);
|
|
|
|
// Check if already running
|
|
const existingPort = await this.getPort();
|
|
if (existingPort !== null) {
|
|
this.cachedPort = existingPort;
|
|
log.info({ port: existingPort }, 'gateway already running');
|
|
return existingPort;
|
|
}
|
|
|
|
// Allocate a port for the gateway
|
|
const port = await allocatePort();
|
|
this.cachedPort = port;
|
|
|
|
// Write gateway compose + empty Caddyfile
|
|
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
|
await mkdir(gatewayDir, { recursive: true });
|
|
|
|
const composeContent = this.generateGatewayCompose(port);
|
|
await writeFile(join(gatewayDir, 'docker-compose.yml'), composeContent, 'utf-8');
|
|
|
|
// Start with an empty Caddyfile — will be populated by updateRoutes()
|
|
const emptyCaddyfile = '{\n auto_https off\n}\n';
|
|
await writeFile(join(gatewayDir, 'Caddyfile'), emptyCaddyfile, 'utf-8');
|
|
|
|
const composePath = join(gatewayDir, 'docker-compose.yml');
|
|
await composeUp(composePath, GATEWAY_PROJECT_NAME);
|
|
|
|
log.info({ port }, 'gateway started');
|
|
return port;
|
|
}
|
|
|
|
/**
|
|
* Regenerate the Caddyfile from all active previews.
|
|
* Caddy's `--watch` flag picks up the file change automatically.
|
|
*/
|
|
async updateRoutes(previews: Map<string, GatewayRoute[]>): Promise<void> {
|
|
const port = this.cachedPort ?? (await this.getPort());
|
|
if (port === null) {
|
|
log.warn('cannot update routes — gateway not running');
|
|
return;
|
|
}
|
|
|
|
const caddyfile = generateGatewayCaddyfile(previews, port);
|
|
const caddyfilePath = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway', 'Caddyfile');
|
|
await writeFile(caddyfilePath, caddyfile, 'utf-8');
|
|
|
|
log.info({ previewCount: previews.size }, 'gateway routes updated');
|
|
}
|
|
|
|
/**
|
|
* Stop the gateway and remove the shared network.
|
|
*/
|
|
async stopGateway(): Promise<void> {
|
|
await composeDown(GATEWAY_PROJECT_NAME).catch(() => {});
|
|
await removeDockerNetwork(GATEWAY_NETWORK);
|
|
|
|
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
|
await rm(gatewayDir, { recursive: true, force: true }).catch(() => {});
|
|
|
|
this.cachedPort = null;
|
|
log.info('gateway stopped');
|
|
}
|
|
|
|
/**
|
|
* Check if the gateway compose project is running.
|
|
*/
|
|
async isRunning(): Promise<boolean> {
|
|
const services = await composePs(GATEWAY_PROJECT_NAME);
|
|
return services.some((s) => s.state === 'running');
|
|
}
|
|
|
|
/**
|
|
* Read the gateway port from the running container's labels.
|
|
* Returns null if the gateway isn't running.
|
|
*/
|
|
async getPort(): Promise<number | null> {
|
|
if (this.cachedPort !== null) {
|
|
// Verify the container is still running
|
|
if (await this.isRunning()) {
|
|
return this.cachedPort;
|
|
}
|
|
this.cachedPort = null;
|
|
}
|
|
|
|
try {
|
|
const { execa } = await import('execa');
|
|
const result = await execa('docker', [
|
|
'ps',
|
|
'--filter', `label=cw.gateway=true`,
|
|
'--format', `{{.Label "cw.gateway-port"}}`,
|
|
], { timeout: 15000 });
|
|
|
|
if (!result.stdout.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const port = parseInt(result.stdout.trim().split('\n')[0], 10);
|
|
if (isNaN(port)) return null;
|
|
|
|
this.cachedPort = port;
|
|
return port;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the gateway docker-compose.yml content.
|
|
*/
|
|
private generateGatewayCompose(port: number): string {
|
|
const compose = {
|
|
services: {
|
|
caddy: {
|
|
image: 'caddy:2-alpine',
|
|
command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile', '--watch'],
|
|
ports: [`${port}:80`],
|
|
volumes: ['./Caddyfile:/etc/caddy/Caddyfile:rw'],
|
|
networks: [GATEWAY_NETWORK],
|
|
labels: {
|
|
'cw.gateway': 'true',
|
|
'cw.gateway-port': String(port),
|
|
},
|
|
},
|
|
},
|
|
networks: {
|
|
[GATEWAY_NETWORK]: {
|
|
external: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a Caddyfile for the gateway from all active preview routes.
|
|
*
|
|
* Each preview gets a subdomain block: `<previewId>.localhost:<port>`
|
|
* Routes within a preview are sorted by specificity (longest path first).
|
|
*/
|
|
export function generateGatewayCaddyfile(
|
|
previews: Map<string, GatewayRoute[]>,
|
|
port: number,
|
|
): string {
|
|
const lines: string[] = [
|
|
'{',
|
|
' auto_https off',
|
|
'}',
|
|
'',
|
|
];
|
|
|
|
for (const [previewId, routes] of previews) {
|
|
// Sort routes by specificity (longer paths first, root last)
|
|
const sorted = [...routes].sort((a, b) => {
|
|
if (a.route === '/') return 1;
|
|
if (b.route === '/') return -1;
|
|
return b.route.length - a.route.length;
|
|
});
|
|
|
|
lines.push(`${previewId}.localhost:${port} {`);
|
|
|
|
for (const route of sorted) {
|
|
if (route.route === '/') {
|
|
lines.push(` handle {`);
|
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
|
lines.push(` }`);
|
|
} else {
|
|
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route;
|
|
lines.push(` handle_path ${path}/* {`);
|
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
|
lines.push(` }`);
|
|
}
|
|
}
|
|
|
|
lines.push('}');
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|