From 714262fb83665f8aff4155ff59c3df3437db3b0d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 12:39:02 +0100 Subject: [PATCH] feat: Add seed command support to preview deployments Run project-specific initialization commands (DB migrations, fixture loading, etc.) automatically after containers are healthy, before the preview is marked ready. Configured via per-service `seed` arrays in .cw-preview.yml. --- apps/server/preview/config-reader.test.ts | 27 +++++++++ apps/server/preview/config-reader.ts | 1 + apps/server/preview/docker-client.ts | 17 ++++++ apps/server/preview/index.ts | 1 + apps/server/preview/manager.test.ts | 73 +++++++++++++++++++++++ apps/server/preview/manager.ts | 21 ++++++- apps/server/preview/types.ts | 1 + docs/preview.md | 19 +++++- 8 files changed, 158 insertions(+), 2 deletions(-) diff --git a/apps/server/preview/config-reader.test.ts b/apps/server/preview/config-reader.test.ts index 1a6c5e7..35f260a 100644 --- a/apps/server/preview/config-reader.test.ts +++ b/apps/server/preview/config-reader.test.ts @@ -133,6 +133,33 @@ services: expect(config.services.frontend.dev!.workdir).toBe('/app'); }); + it('parses seed array on a service', () => { + const raw = ` +version: 1 +services: + app: + build: "." + port: 3000 + seed: + - npm run db:migrate + - npm run db:seed +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.app.seed).toEqual(['npm run db:migrate', 'npm run db:seed']); + }); + + it('omits seed when not specified', () => { + const raw = ` +version: 1 +services: + app: + build: "." + port: 3000 +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.app.seed).toBeUndefined(); + }); + it('parses dev section with only image', () => { const raw = ` version: 1 diff --git a/apps/server/preview/config-reader.ts b/apps/server/preview/config-reader.ts index a40d30d..fafaab4 100644 --- a/apps/server/preview/config-reader.ts +++ b/apps/server/preview/config-reader.ts @@ -101,6 +101,7 @@ export function parseCwPreviewConfig(raw: string): PreviewConfig { ...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }), ...(svc.env !== undefined && { env: svc.env as Record }), ...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }), + ...(Array.isArray(svc.seed) && { seed: svc.seed as string[] }), ...(svc.dev !== undefined && { dev: { image: (svc.dev as Record).image as string, diff --git a/apps/server/preview/docker-client.ts b/apps/server/preview/docker-client.ts index 38ee3d3..52b7862 100644 --- a/apps/server/preview/docker-client.ts +++ b/apps/server/preview/docker-client.ts @@ -120,6 +120,23 @@ export async function composeDown(projectName: string): Promise { }); } +/** + * Execute a command inside a running service container. + * Used for seed commands (migrations, fixture loading, etc.) after health checks pass. + */ +export async function execInContainer( + projectName: string, + serviceName: string, + command: string, +): Promise<{ stdout: string; stderr: string }> { + const result = await execa('docker', [ + 'compose', '-p', projectName, + 'exec', '-T', serviceName, + 'sh', '-c', command, + ], { timeout: 300000 }); // 5 min per seed command + return { stdout: result.stdout, stderr: result.stderr }; +} + /** * Get service statuses for a compose project. */ diff --git a/apps/server/preview/index.ts b/apps/server/preview/index.ts index cac08f1..65d4638 100644 --- a/apps/server/preview/index.ts +++ b/apps/server/preview/index.ts @@ -21,6 +21,7 @@ export { isDockerAvailable, composeUp, composeDown, + execInContainer, composePs, listPreviewProjects, getContainerLabels, diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index b1e5975..14dc0cd 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -10,6 +10,7 @@ vi.mock('./docker-client.js', () => ({ isDockerAvailable: vi.fn(), composeUp: vi.fn(), composeDown: vi.fn(), + execInContainer: vi.fn(), composePs: vi.fn(), listPreviewProjects: vi.fn(), getContainerLabels: vi.fn(), @@ -74,6 +75,7 @@ import { isDockerAvailable, composeUp, composeDown, + execInContainer, composePs, listPreviewProjects, getContainerLabels, @@ -91,6 +93,7 @@ const mockComposeDown = vi.mocked(composeDown); const mockComposePs = vi.mocked(composePs); const mockListPreviewProjects = vi.mocked(listPreviewProjects); const mockGetContainerLabels = vi.mocked(getContainerLabels); +const mockExecInContainer = vi.mocked(execInContainer); const mockDiscoverConfig = vi.mocked(discoverConfig); const mockAllocatePort = vi.mocked(allocatePort); const mockWaitForHealthy = vi.mocked(waitForHealthy); @@ -302,6 +305,76 @@ describe('PreviewManager', () => { expect(failedEvent).toBeDefined(); }); + it('runs seed commands after health check and before preview:ready', async () => { + const seededConfig: PreviewConfig = { + version: 1, + services: { + app: { + name: 'app', + build: '.', + port: 3000, + seed: ['npm run db:migrate', 'npm run db:seed'], + }, + }, + }; + + mockIsDockerAvailable.mockResolvedValue(true); + mockComposeUp.mockResolvedValue(undefined); + mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]); + mockDiscoverConfig.mockResolvedValue(seededConfig); + mockCreatePreviewWorktree.mockResolvedValue(undefined); + mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); + mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await manager.start({ + initiativeId: 'init-1', + projectId: 'proj-1', + branch: 'feature-x', + }); + + expect(result.status).toBe('running'); + expect(mockExecInContainer).toHaveBeenCalledTimes(2); + expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:migrate'); + expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:seed'); + + const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); + expect(readyEvent).toBeDefined(); + }); + + it('emits preview:failed and cleans up when seed command fails', async () => { + const seededConfig: PreviewConfig = { + version: 1, + services: { + app: { + name: 'app', + build: '.', + port: 3000, + seed: ['npm run db:migrate'], + }, + }, + }; + + mockIsDockerAvailable.mockResolvedValue(true); + mockComposeUp.mockResolvedValue(undefined); + mockDiscoverConfig.mockResolvedValue(seededConfig); + mockCreatePreviewWorktree.mockResolvedValue(undefined); + mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); + mockExecInContainer.mockRejectedValue(new Error('migration failed: relation already exists')); + mockComposeDown.mockResolvedValue(undefined); + + await expect( + manager.start({ + initiativeId: 'init-1', + projectId: 'proj-1', + branch: 'main', + }), + ).rejects.toThrow('Preview build failed'); + + const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed'); + expect(failedEvent).toBeDefined(); + expect(mockComposeDown).toHaveBeenCalled(); + }); + it('succeeds when no healthcheck endpoints are configured', async () => { const noHealthConfig: PreviewConfig = { version: 1, diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index b2b602e..30214de 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -25,6 +25,7 @@ import { isDockerAvailable, composeUp, composeDown, + execInContainer, composePs, listPreviewProjects, getContainerLabels, @@ -230,7 +231,10 @@ export class PreviewManager { ); } - // 10. Success + // 10. Run seed commands + await this.runSeeds(projectName, config); + + // 11. Success const url = `http://${id}.localhost:${gatewayPort}`; log.info({ id, url }, 'preview deployment ready'); @@ -477,6 +481,21 @@ export class PreviewManager { ); } + /** + * Run seed commands for each service that has them configured. + * Executes after health checks pass, before the preview is marked ready. + */ + private async runSeeds(projectName: string, config: PreviewConfig): Promise { + for (const [serviceName, svc] of Object.entries(config.services)) { + if (!svc.seed?.length) continue; + log.info({ projectName, service: serviceName, count: svc.seed.length }, 'running seed commands'); + for (const cmd of svc.seed) { + log.info({ service: serviceName, cmd }, 'executing seed'); + await execInContainer(projectName, serviceName, cmd); + } + } + } + /** * Build gateway routes from a preview config. */ diff --git a/apps/server/preview/types.ts b/apps/server/preview/types.ts index 412bf07..8b95e21 100644 --- a/apps/server/preview/types.ts +++ b/apps/server/preview/types.ts @@ -29,6 +29,7 @@ export interface PreviewServiceConfig { env?: Record; volumes?: string[]; dev?: PreviewServiceDevConfig; + seed?: string[]; } /** diff --git a/docs/preview.md b/docs/preview.md index 91967d6..66e9d03 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -111,11 +111,28 @@ Each service in `.cw-preview.yml` supports: | `healthcheck` | no | `{path, interval?, retries?}` — polled before marking ready | | `env` | no | Environment variables passed to the container | | `volumes` | no | Additional volume mounts | +| `seed` | no | Array of shell commands to run inside the container after health checks pass | | `dev` | no | Dev mode overrides: `{image, command?, workdir?}` | \* Provide either `build` or `image`, not both. \** Required unless `internal: true`. +### Seeding + +If a service needs initialization (database migrations, fixture loading, etc.), add a `seed` array. Commands run inside the container via `docker compose exec` after all health checks pass, before the preview is marked ready. + +```yaml +services: + app: + build: . + port: 3000 + seed: + - npm run db:migrate + - npm run db:seed +``` + +Seeds execute in service definition order. Each command has a 5-minute timeout. If any seed command fails (non-zero exit), the preview fails and all containers are cleaned up. + ### Dev Mode Dev mode skips the Docker build and instead mounts your source code into a container running a dev server. Useful for hot reload during active development. @@ -193,7 +210,7 @@ PreviewManager ### Lifecycle -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` +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 → run seed commands → emit `preview:ready` 2. **Stop**: `docker compose down --volumes --remove-orphans` → remove worktree → clean up `.cw-previews//` → 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