- 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>
501 lines
11 KiB
Markdown
501 lines
11 KiB
Markdown
# 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 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
|
|
|
|
```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 && (
|
|
<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)
|
|
|
|
```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
|
|
```
|