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:
165
docs/preview.md
165
docs/preview.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user