- 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>
11 KiB
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
- TTL: Siempre configurar TTL para auto-cleanup
- Resource Limits: Limitar CPU/memoria por preview
- Security: Basic auth o limitación por IP
- Monitoring: Alertar si muchos previews activos
- Cost Control: Límite máximo de previews concurrentes
- 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