# 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()} > Ver Preview )} {task.state === 'ready_to_test' && (

Preview Environment Ready!

Credentials: preview / preview123

Open Preview
)} ``` ## Cleanup de Preview Environments ### Automático (TTL) ```typescript // Cron job que corre cada hora async function cleanupExpiredPreviews() { const namespaces = await k8sClient.listNamespaces({ labelSelector: 'environment=preview', }) for (const ns of namespaces) { const createdAt = new Date(ns.metadata?.annotations?.['created-at']) const ttlHours = parseInt(ns.metadata?.labels?.ttl || '168') const ageHours = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60) if (ageHours > ttlHours) { logger.info(`Cleaning up expired preview: ${ns.metadata.name}`) // Delete namespace (cascades to all resources) await k8sClient.deleteNamespace(ns.metadata.name) // Update task await db.update(tasks) .set({ previewNamespace: null, previewUrl: null, }) .where(eq(tasks.previewNamespace, ns.metadata.name)) } } } // Schedule setInterval(cleanupExpiredPreviews, 3600000) // Every hour ``` ### Manual ```typescript // api/routes/tasks.ts router.delete('/tasks/:id/preview', async (req, res) => { const { id } = req.params const task = await db.query.tasks.findFirst({ where: eq(tasks.id, id), }) if (!task || !task.previewNamespace) { return res.status(404).json({ error: 'Preview not found' }) } // Delete namespace await k8sClient.deleteNamespace(task.previewNamespace) // Update task await db.update(tasks) .set({ previewNamespace: null, previewUrl: null, }) .where(eq(tasks.id, id)) res.json({ success: true }) }) ``` ## Resource Limits Para prevenir abuse, aplicar límites estrictos en previews: ```yaml apiVersion: v1 kind: ResourceQuota metadata: name: preview-quota namespace: preview-task-abc123 spec: hard: requests.cpu: "500m" requests.memory: "1Gi" limits.cpu: "1" limits.memory: "2Gi" pods: "5" services: "3" ``` ## Logs de Preview ```typescript // api/routes/tasks.ts router.get('/tasks/:id/preview-logs', async (req, res) => { const { id } = req.params const task = await db.query.tasks.findFirst({ where: eq(tasks.id, id), }) if (!task || !task.previewNamespace) { return res.status(404).json({ error: 'Preview not found' }) } const pods = await k8sClient.listPods(task.previewNamespace) const appPod = pods.find((p) => p.metadata.labels.app) if (!appPod) { return res.status(404).json({ error: 'App pod not found' }) } const logs = await k8sClient.getPodLogs( task.previewNamespace, appPod.metadata.name, 100 // tail lines ) res.json({ logs }) }) ``` ## Monitoring ```typescript // Get preview environments stats router.get('/previews/stats', async (req, res) => { const namespaces = await k8sClient.listNamespaces({ labelSelector: 'environment=preview', }) const stats = { total: namespaces.length, totalCost: 0, byAge: { '<1h': 0, '1-24h': 0, '1-7d': 0, '>7d': 0, }, } for (const ns of namespaces) { const createdAt = new Date(ns.metadata?.annotations?.['created-at']) const ageHours = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60) if (ageHours < 1) stats.byAge['<1h']++ else if (ageHours < 24) stats.byAge['1-24h']++ else if (ageHours < 168) stats.byAge['1-7d']++ else stats.byAge['>7d']++ // Estimate cost (example: $0.05/hour per namespace) stats.totalCost += ageHours * 0.05 } res.json(stats) }) ``` ## Best Practices 1. **TTL**: Siempre configurar TTL para auto-cleanup 2. **Resource Limits**: Limitar CPU/memoria por preview 3. **Security**: Basic auth o limitación por IP 4. **Monitoring**: Alertar si muchos previews activos 5. **Cost Control**: Límite máximo de previews concurrentes 6. **Quick Spin-up**: Optimizar para <2min de deployment time ## Troubleshooting ```bash # Ver todos los previews kubectl get namespaces -l environment=preview # Ver recursos de un preview kubectl get all -n preview-task-abc123 # Ver logs de un preview kubectl logs -n preview-task-abc123 deployment/app-preview # Eliminar preview manualmente kubectl delete namespace preview-task-abc123 ```