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');
|
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', () => {
|
it('parses dev section with only image', () => {
|
||||||
const raw = `
|
const raw = `
|
||||||
version: 1
|
version: 1
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function parseCwPreviewConfig(raw: string): PreviewConfig {
|
|||||||
...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }),
|
...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }),
|
||||||
...(svc.env !== undefined && { env: svc.env as Record<string, string> }),
|
...(svc.env !== undefined && { env: svc.env as Record<string, string> }),
|
||||||
...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }),
|
...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }),
|
||||||
|
...(Array.isArray(svc.seed) && { seed: svc.seed as string[] }),
|
||||||
...(svc.dev !== undefined && {
|
...(svc.dev !== undefined && {
|
||||||
dev: {
|
dev: {
|
||||||
image: (svc.dev as Record<string, unknown>).image as string,
|
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.
|
* Get service statuses for a compose project.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
isDockerAvailable,
|
isDockerAvailable,
|
||||||
composeUp,
|
composeUp,
|
||||||
composeDown,
|
composeDown,
|
||||||
|
execInContainer,
|
||||||
composePs,
|
composePs,
|
||||||
listPreviewProjects,
|
listPreviewProjects,
|
||||||
getContainerLabels,
|
getContainerLabels,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock('./docker-client.js', () => ({
|
|||||||
isDockerAvailable: vi.fn(),
|
isDockerAvailable: vi.fn(),
|
||||||
composeUp: vi.fn(),
|
composeUp: vi.fn(),
|
||||||
composeDown: vi.fn(),
|
composeDown: vi.fn(),
|
||||||
|
execInContainer: vi.fn(),
|
||||||
composePs: vi.fn(),
|
composePs: vi.fn(),
|
||||||
listPreviewProjects: vi.fn(),
|
listPreviewProjects: vi.fn(),
|
||||||
getContainerLabels: vi.fn(),
|
getContainerLabels: vi.fn(),
|
||||||
@@ -74,6 +75,7 @@ import {
|
|||||||
isDockerAvailable,
|
isDockerAvailable,
|
||||||
composeUp,
|
composeUp,
|
||||||
composeDown,
|
composeDown,
|
||||||
|
execInContainer,
|
||||||
composePs,
|
composePs,
|
||||||
listPreviewProjects,
|
listPreviewProjects,
|
||||||
getContainerLabels,
|
getContainerLabels,
|
||||||
@@ -91,6 +93,7 @@ const mockComposeDown = vi.mocked(composeDown);
|
|||||||
const mockComposePs = vi.mocked(composePs);
|
const mockComposePs = vi.mocked(composePs);
|
||||||
const mockListPreviewProjects = vi.mocked(listPreviewProjects);
|
const mockListPreviewProjects = vi.mocked(listPreviewProjects);
|
||||||
const mockGetContainerLabels = vi.mocked(getContainerLabels);
|
const mockGetContainerLabels = vi.mocked(getContainerLabels);
|
||||||
|
const mockExecInContainer = vi.mocked(execInContainer);
|
||||||
const mockDiscoverConfig = vi.mocked(discoverConfig);
|
const mockDiscoverConfig = vi.mocked(discoverConfig);
|
||||||
const mockAllocatePort = vi.mocked(allocatePort);
|
const mockAllocatePort = vi.mocked(allocatePort);
|
||||||
const mockWaitForHealthy = vi.mocked(waitForHealthy);
|
const mockWaitForHealthy = vi.mocked(waitForHealthy);
|
||||||
@@ -302,6 +305,76 @@ describe('PreviewManager', () => {
|
|||||||
expect(failedEvent).toBeDefined();
|
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 () => {
|
it('succeeds when no healthcheck endpoints are configured', async () => {
|
||||||
const noHealthConfig: PreviewConfig = {
|
const noHealthConfig: PreviewConfig = {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
isDockerAvailable,
|
isDockerAvailable,
|
||||||
composeUp,
|
composeUp,
|
||||||
composeDown,
|
composeDown,
|
||||||
|
execInContainer,
|
||||||
composePs,
|
composePs,
|
||||||
listPreviewProjects,
|
listPreviewProjects,
|
||||||
getContainerLabels,
|
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}`;
|
const url = `http://${id}.localhost:${gatewayPort}`;
|
||||||
log.info({ id, url }, 'preview deployment ready');
|
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.
|
* Build gateway routes from a preview config.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface PreviewServiceConfig {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
volumes?: string[];
|
volumes?: string[];
|
||||||
dev?: PreviewServiceDevConfig;
|
dev?: PreviewServiceDevConfig;
|
||||||
|
seed?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -111,11 +111,28 @@ Each service in `.cw-preview.yml` supports:
|
|||||||
| `healthcheck` | no | `{path, interval?, retries?}` — polled before marking ready |
|
| `healthcheck` | no | `{path, interval?, retries?}` — polled before marking ready |
|
||||||
| `env` | no | Environment variables passed to the container |
|
| `env` | no | Environment variables passed to the container |
|
||||||
| `volumes` | no | Additional volume mounts |
|
| `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?}` |
|
| `dev` | no | Dev mode overrides: `{image, command?, workdir?}` |
|
||||||
|
|
||||||
\* Provide either `build` or `image`, not both.
|
\* Provide either `build` or `image`, not both.
|
||||||
\** Required unless `internal: true`.
|
\** 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
|
||||||
|
|
||||||
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.
|
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
|
### 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`
|
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
|
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
|
4. **Shutdown**: `stopAll()` called on server shutdown — stops all previews, then stops gateway
|
||||||
|
|||||||
Reference in New Issue
Block a user