# Preview Environments
Los preview environments son deployments temporales y aislados para cada tarea, permitiendo testing independiente antes del merge.
## Arquitectura
```
Task Branch
↓
Build & Push Image
↓
Create K8s Namespace (preview-task-{id})
↓
Deploy App + Database (if needed)
↓
Create Ingress (https://task-{id}.preview.aiworker.dev)
↓
Ready for Testing
```
## Creación de Preview Environment
### 1. Trigger desde Agente
```typescript
// Agent completes task
await mcp.callTool('trigger_preview_deploy', {
taskId: task.id,
})
```
### 2. Backend Handler
```typescript
// services/mcp/handlers.ts
async function triggerPreviewDeploy(args: { taskId: string }) {
const task = await db.query.tasks.findFirst({
where: eq(tasks.id, args.taskId),
with: { project: true },
})
if (!task || !task.branchName) {
throw new Error('Task or branch not found')
}
const shortId = task.id.slice(0, 8)
const namespace = `preview-task-${shortId}`
const url = `https://task-${shortId}.preview.aiworker.dev`
// Create deployment job
const deploymentId = crypto.randomUUID()
await db.insert(deployments).values({
id: deploymentId,
projectId: task.projectId,
environment: 'preview',
branch: task.branchName,
commitHash: await getLatestCommit(task),
k8sNamespace: namespace,
status: 'pending',
})
// Enqueue
await enqueueDeploy({
deploymentId,
projectId: task.projectId,
taskId: task.id,
environment: 'preview',
branch: task.branchName,
namespace,
})
// Update task
await db.update(tasks)
.set({
state: 'ready_to_test',
previewNamespace: namespace,
previewUrl: url,
previewDeployedAt: new Date(),
})
.where(eq(tasks.id, task.id))
return {
content: [{
type: 'text',
text: JSON.stringify({ success: true, previewUrl: url, namespace }),
}],
}
}
```
### 3. Deploy Worker
```typescript
// services/queue/preview-deploy-worker.ts
export const previewDeployWorker = new Worker('deploys', async (job) => {
const { deploymentId, taskId, projectId, branch, namespace } = job.data
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
})
// 1. Create namespace with TTL annotation
await k8sClient.createNamespace(namespace, {
project: projectId,
environment: 'preview',
taskId,
ttl: '168h', // 7 days
'created-at': new Date().toISOString(),
})
job.updateProgress(20)
// 2. Build image (or use existing)
const imageTag = `${project.dockerImage}:${branch}`
job.updateProgress(40)
// 3. Deploy application
await k8sClient.createDeployment({
namespace,
name: `${project.name}-preview`,
image: imageTag,
replicas: 1,
envVars: {
...project.envVars,
NODE_ENV: 'preview',
PREVIEW_MODE: 'true',
},
resources: {
requests: { cpu: '250m', memory: '512Mi' },
limits: { cpu: '1', memory: '2Gi' },
},
})
job.updateProgress(60)
// 4. Create service
await k8sClient.createService({
namespace,
name: `${project.name}-preview`,
port: 3000,
})
job.updateProgress(70)
// 5. Create ingress with basic auth
const host = `task-${taskId.slice(0, 8)}.preview.aiworker.dev`
await k8sClient.createIngress({
namespace,
name: `${project.name}-preview`,
host,
serviceName: `${project.name}-preview`,
servicePort: 3000,
annotations: {
'nginx.ingress.kubernetes.io/auth-type': 'basic',
'nginx.ingress.kubernetes.io/auth-secret': 'preview-basic-auth',
'nginx.ingress.kubernetes.io/auth-realm': 'Preview Environment',
},
})
job.updateProgress(90)
// 6. Wait for ready
await k8sClient.waitForDeployment(namespace, `${project.name}-preview`, 300)
job.updateProgress(100)
// Update deployment record
await db.update(deployments)
.set({
status: 'completed',
url: `https://${host}`,
completedAt: new Date(),
})
.where(eq(deployments.id, deploymentId))
logger.info(`Preview environment ready: ${host}`)
return { success: true, url: `https://${host}` }
})
```
## Preview con Base de Datos
Para tareas que requieren DB, crear una instancia temporal:
```typescript
async function createPreviewWithDatabase(params: {
namespace: string
projectName: string
taskId: string
}) {
const { namespace, projectName } = params
// 1. Deploy MariaDB/PostgreSQL ephemeral
await k8sClient.createDeployment({
namespace,
name: 'db',
image: 'mysql:8.0',
replicas: 1,
envVars: {
MYSQL_ROOT_PASSWORD: 'preview123',
MYSQL_DATABASE: projectName,
},
resources: {
requests: { cpu: '250m', memory: '512Mi' },
limits: { cpu: '500m', memory: '1Gi' },
},
})
// 2. Create service
await k8sClient.createService({
namespace,
name: 'db',
port: 3306,
})
// 3. Run migrations
await k8sClient.runJob({
namespace,
name: 'db-migrate',
image: `${projectName}:latest`,
command: ['npm', 'run', 'migrate'],
envVars: {
DB_HOST: 'db',
DB_PORT: '3306',
DB_PASSWORD: 'preview123',
},
})
// 4. Seed data (optional)
await k8sClient.runJob({
namespace,
name: 'db-seed',
image: `${projectName}:latest`,
command: ['npm', 'run', 'seed'],
envVars: {
DB_HOST: 'db',
DB_PORT: '3306',
DB_PASSWORD: 'preview123',
},
})
}
```
## Basic Auth para Preview
```bash
# Create htpasswd file
htpasswd -c auth preview
# Password: preview123
# Create secret in all preview namespaces
kubectl create secret generic preview-basic-auth \
--from-file=auth \
-n preview-task-abc123
```
```typescript
// Auto-create in new preview namespaces
async function createPreviewAuthSecret(namespace: string) {
const htpasswd = 'preview:$apr1$...' // pre-generated
await k8sClient.createSecret({
namespace,
name: 'preview-basic-auth',
data: {
auth: Buffer.from(htpasswd).toString('base64'),
},
})
}
```
## Frontend: Preview URL Display
```typescript
// components/tasks/TaskCard.tsx
{task.previewUrl && (
e.stopPropagation()}
>