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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
isDockerAvailable,
|
||||
composeUp,
|
||||
composeDown,
|
||||
execInContainer,
|
||||
composePs,
|
||||
listPreviewProjects,
|
||||
getContainerLabels,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface PreviewServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
volumes?: string[];
|
||||
dev?: PreviewServiceDevConfig;
|
||||
seed?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user