Files
aiworker/docs/06-deployment/preview-envs.md
Hector Ros db71705842 Complete documentation for future sessions
- CLAUDE.md for AI agents to understand the codebase
- GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth)
- DEVELOPMENT-WORKFLOW.md explains complete dev process
- ROADMAP.md, NEXT-SESSION.md for planning
- QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use
- 40+ detailed docs in /docs folder
- Backend as submodule from Gitea

Everything documented for autonomous operation.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 00:37:19 +01:00

11 KiB

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

// Agent completes task
await mcp.callTool('trigger_preview_deploy', {
  taskId: task.id,
})

2. Backend Handler

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

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

async function createPreviewWithDatabase(params: {
  namespace: string
  projectName: string
  taskId: string
}) {
  const { namespace, projectName } = params

  // 1. Deploy MySQL/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

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

// components/tasks/TaskCard.tsx
{task.previewUrl && (
  <a
    href={task.previewUrl}
    target="_blank"
    rel="noopener noreferrer"
    className="mt-2 flex items-center gap-2 text-sm text-primary-600 hover:underline"
    onClick={(e) => e.stopPropagation()}
  >
    <ExternalLink className="w-4 h-4" />
    Ver Preview
  </a>
)}

{task.state === 'ready_to_test' && (
  <div className="mt-3 p-3 bg-purple-50 border border-purple-200 rounded-lg">
    <p className="text-sm font-medium text-purple-900">
      Preview Environment Ready!
    </p>
    <p className="text-xs text-purple-700 mt-1">
      Credentials: preview / preview123
    </p>
    <div className="flex gap-2 mt-2">
      <a
        href={task.previewUrl}
        target="_blank"
        rel="noopener noreferrer"
        className="btn-primary text-xs"
      >
        Open Preview
      </a>
      <button
        onClick={() => approveTask(task.id)}
        className="btn-secondary text-xs"
      >
        Approve
      </button>
    </div>
  </div>
)}

Cleanup de Preview Environments

Automático (TTL)

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

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

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

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

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

# 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