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.
This commit is contained in:
Lukas May
2026-03-05 12:39:02 +01:00
parent 3913aa2e28
commit 714262fb83
8 changed files with 158 additions and 2 deletions

View File

@@ -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

View File

@@ -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<string, string> }),
...(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<string, unknown>).image as string,

View File

@@ -120,6 +120,23 @@ export async function composeDown(projectName: string): Promise<void> {
});
}
/**
* 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.
*/

View File

@@ -21,6 +21,7 @@ export {
isDockerAvailable,
composeUp,
composeDown,
execInContainer,
composePs,
listPreviewProjects,
getContainerLabels,

View File

@@ -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,

View File

@@ -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<void> {
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.
*/

View File

@@ -29,6 +29,7 @@ export interface PreviewServiceConfig {
env?: Record<string, string>;
volumes?: string[];
dev?: PreviewServiceDevConfig;
seed?: string[];
}
/**

View File

@@ -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/<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