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>
This commit is contained in:
500
docs/06-deployment/preview-envs.md
Normal file
500
docs/06-deployment/preview-envs.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user