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
This commit is contained in:
Lukas May
2026-03-05 12:22:29 +01:00
parent 0ff65b0b02
commit 143aad58e8
21 changed files with 1198 additions and 721 deletions

View File

@@ -4,27 +4,92 @@
## Overview
When a phase enters `pending_review`, reviewers can spin up the app at a specific branch in local Docker containers, accessible through a single port via a Caddy reverse proxy.
Preview deployments let reviewers spin up a branch in local Docker containers. A single shared **Caddy gateway** handles subdomain routing for all active previews, accessed at `http://<previewId>.localhost:<gatewayPort>`.
Two modes:
- **Preview mode**: Checks out target branch into a git worktree, builds Docker images, serves production-like output.
- **Dev mode**: Mounts the agent's worktree into a container with a dev server image (e.g. `node:20-alpine`), enabling hot reload.
**Auto-start**: When a phase enters `pending_review`, a preview is automatically started for the phase's branch (if the initiative has exactly one project).
**Key design decision: No database table.** Docker IS the source of truth. Instead of persisting rows, we query Docker directly via compose project names, container labels, and `docker compose` CLI commands.
## Architecture
```
.cw-previews/
gateway/
docker-compose.yml ← single Caddy container, one port
Caddyfile ← regenerated on each preview add/remove
<previewId>/
docker-compose.yml ← per-preview stack (no published ports)
source/ ← git worktree (preview mode only)
Docker:
network: cw-preview-net ← external, shared by gateway + all previews
cw-preview-gateway ← Caddy on one port, subdomain routing
cw-preview-<id> ← per-preview compose project (services only)
Routing:
<previewId>.localhost:<gatewayPort> → cw-preview-<id>-<service>:<port>
```
```
PreviewManager
├── GatewayManager (shared Caddy gateway lifecycle + Caddyfile generation)
├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile)
├── ComposeGenerator (generate docker-compose.yml + Caddyfile)
├── DockerClient (thin wrapper around docker compose CLI)
├── HealthChecker (poll service healthcheck endpoints)
── PortAllocator (find next available port 9100-9200)
├── ComposeGenerator (generate per-preview docker-compose.yml)
├── DockerClient (thin wrapper around docker compose CLI + network ops)
├── HealthChecker (poll service healthcheck endpoints via subdomain URL)
── PortAllocator (find next available port 9100-9200 for gateway)
└── Worktree helper (git worktree add/remove for preview mode)
```
### Lifecycle
1. **Start**: discover config → allocate port → generate compose + Caddyfile `docker compose up --build -d` → health check → emit `preview:ready`
2. **Stop**: `docker compose down --volumes --remove-orphans` → clean up `.cw-previews/<id>/` → emit `preview:stopped`
3. **List**: `docker compose ls --filter name=cw-preview` → parse container labels → reconstruct status
4. **Shutdown**: `stopAll()` called on server shutdown to prevent orphaned containers
1. **Start**: ensure gateway → discover config → create worktree (preview) or use provided path (dev) → generate compose → `docker compose up --build -d` update gateway routes → health check → emit `preview:ready`
2. **Stop**: `docker compose down --volumes --remove-orphans` remove worktree → clean up `.cw-previews/<id>/` update gateway routes → stop gateway if no more previews → emit `preview:stopped`
3. **List**: `docker compose ls --filter name=cw-preview` skip gateway project → parse container labels → reconstruct status
4. **Shutdown**: `stopAll()` called on server shutdown — stops all previews, then stops gateway
### Gateway
The `GatewayManager` class manages a single shared Caddy container:
- **`ensureGateway()`** — idempotent. Creates the `cw-preview-net` Docker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with `--watch` flag.
- **`updateRoutes()`** — regenerates the full Caddyfile from all active previews. Caddy's `--watch` flag auto-reloads on file change (no `docker exec` needed).
- **`stopGateway()`** — composes down the gateway, removes the Docker network, cleans up the gateway directory.
Gateway Caddyfile format:
```
{
auto_https off
}
abc123.localhost:9100 {
handle_path /api/* {
reverse_proxy cw-preview-abc123-backend:8080
}
handle {
reverse_proxy cw-preview-abc123-frontend:3000
}
}
xyz789.localhost:9100 {
handle {
reverse_proxy cw-preview-xyz789-app:3000
}
}
```
Routes are sorted by specificity (longer paths first) to ensure correct matching.
### Subdomain Routing
Previews are accessed at `http://<previewId>.localhost:<gatewayPort>`.
- **Chrome / Firefox**: Resolve `*.localhost` to `127.0.0.1` natively. No DNS config needed.
- **Safari**: Requires a `/etc/hosts` entry: `127.0.0.1 <previewId>.localhost` for each preview.
### Docker Labels
@@ -37,12 +102,21 @@ All preview containers get `cw.*` labels for metadata retrieval:
| `cw.phase-id` | Phase ID (optional) |
| `cw.project-id` | Project ID |
| `cw.branch` | Branch name |
| `cw.port` | Host port |
| `cw.port` | Gateway port |
| `cw.preview-id` | Nanoid for this deployment |
| `cw.mode` | `"preview"` or `"dev"` |
### Compose Project Naming
Project names follow `cw-preview-<nanoid>` convention. This enables filtering via `docker compose ls --filter name=cw-preview`.
- **Gateway**: `cw-preview-gateway` (single instance)
- **Previews**: `cw-preview-<nanoid>` — filtered via `docker compose ls --filter name=cw-preview`, gateway excluded from listings
- **Container names**: `cw-preview-<id>-<service>` — unique DNS names on the shared network
### Networking
- **`cw-preview-net`** — external Docker bridge network shared by gateway + all preview stacks
- **`internal`** — per-preview bridge network for inter-service communication
- Public services join both networks; internal services (e.g. databases) only join `internal`
## Configuration
@@ -65,6 +139,10 @@ services:
retries: 10
env:
VITE_API_URL: /api
dev:
image: node:20-alpine
command: npm run dev -- --host 0.0.0.0
workdir: /app
backend:
build:
@@ -85,64 +163,68 @@ services:
POSTGRES_PASSWORD: preview
```
The `dev` section is optional per service. When present and mode is `dev`:
- `image` (required) — Docker image to run
- `command` — override entrypoint
- `workdir` — container working directory (default `/app`)
In dev mode, the project directory is volume-mounted into the container and `node_modules` gets an anonymous volume to prevent host overwrite.
### 2. `docker-compose.yml` / `compose.yml` (existing compose passthrough)
If found, the existing compose file is wrapped with a Caddy sidecar.
If found, the existing compose file is used with gateway network injection.
### 3. `Dockerfile` (single-service fallback)
If only a Dockerfile exists, creates a single `app` service building from `.` with port 3000.
## Reverse Proxy: Caddy
Caddy runs as a container in the same Docker network. Only Caddy publishes a port to the host. Generated Caddyfile:
```
:80 {
handle_path /api/* {
reverse_proxy backend:8080
}
handle {
reverse_proxy frontend:3000
}
}
```
## Module Files
| File | Purpose |
|------|---------|
| `types.ts` | PreviewConfig, PreviewStatus, labels, constants |
| `config-reader.ts` | Discovery + YAML parsing |
| `compose-generator.ts` | Docker Compose YAML + Caddyfile generation |
| `docker-client.ts` | Docker CLI wrapper (execa) |
| `health-checker.ts` | Service readiness polling |
| `port-allocator.ts` | Port 9100-9200 allocation with bind test |
| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll) |
| `types.ts` | PreviewConfig, PreviewStatus, labels, constants, dev config types |
| `config-reader.ts` | Discovery + YAML parsing (including `dev` section) |
| `compose-generator.ts` | Per-preview Docker Compose YAML + label generation |
| `gateway.ts` | GatewayManager class + Caddyfile generation |
| `worktree.ts` | Git worktree create/remove helpers |
| `docker-client.ts` | Docker CLI wrapper (execa) + network operations |
| `health-checker.ts` | Service readiness polling via subdomain URL |
| `port-allocator.ts` | Port 9100-9200 allocation with TCP bind test |
| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll + auto-start) |
| `index.ts` | Barrel exports |
## Events
| Event | Payload |
|-------|---------|
| `preview:building` | `{previewId, initiativeId, branch, port}` |
| `preview:ready` | `{previewId, initiativeId, branch, port, url}` |
| `preview:building` | `{previewId, initiativeId, branch, gatewayPort, mode, phaseId?}` |
| `preview:ready` | `{previewId, initiativeId, branch, gatewayPort, url, mode, phaseId?}` |
| `preview:stopped` | `{previewId, initiativeId}` |
| `preview:failed` | `{previewId, initiativeId, error}` |
## Auto-Start
`PreviewManager.setupEventListeners()` listens for `phase:pending_review` events:
1. Loads the initiative and its projects
2. If exactly one project: auto-starts a preview in `preview` mode
3. Branch is derived from `phaseBranchName(initiative.branch, phase.name)`
4. Errors are caught and logged (best-effort, never blocks the phase transition)
## tRPC Procedures
| Procedure | Type | Input |
|-----------|------|-------|
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch}` |
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}` |
| `stopPreview` | mutation | `{previewId}` |
| `listPreviews` | query | `{initiativeId?}` |
| `getPreviewStatus` | query | `{previewId}` |
`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode.
## CLI Commands
```
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>] [--mode preview|dev]
cw preview stop <previewId>
cw preview list [--initiative <id>]
cw preview status <previewId>
@@ -150,17 +232,17 @@ cw preview status <previewId>
## Frontend
`PreviewPanel` component in the Review tab:
The Review tab shows preview status inline:
- **No preview**: "Start Preview" button
- **Building**: Spinner + "Building preview..."
- **Running**: Green dot + `http://localhost:<port>` link + Stop button
- **Running**: Green dot + `http://<id>.localhost:<port>` link + Stop button
- **Failed**: Error message + Retry button
Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
## Container Wiring
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot)`
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)`
- Added to `Container` interface and `toContextDeps()`
- `GracefulShutdown` calls `previewManager.stopAll()` during shutdown
- `requirePreviewManager(ctx)` helper in `apps/server/trpc/routers/_helpers.ts`
@@ -168,4 +250,5 @@ Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
## Dependencies
- `js-yaml` + `@types/js-yaml` — for parsing `.cw-preview.yml`
- `simple-git` — for git worktree operations
- Docker must be installed and running on the host