Files
aiworker/docs/06-deployment/ci-cd.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

496 lines
12 KiB
Markdown

# CI/CD Pipeline
## Arquitectura CI/CD
```
Git Push → Gitea Webhook → Backend → BullMQ → Deploy Worker → K8s
Notifications
```
## Gitea Actions (GitHub Actions compatible)
### Workflow para Backend
```yaml
# .gitea/workflows/backend.yml
name: Backend CI/CD
on:
push:
branches: [main, develop, staging]
paths:
- 'backend/**'
pull_request:
branches: [main, develop]
paths:
- 'backend/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.6
- name: Install dependencies
working-directory: ./backend
run: bun install
- name: Run linter
working-directory: ./backend
run: bun run lint
- name: Run tests
working-directory: ./backend
run: bun test
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:${{ github.sha }}
${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:latest
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/aiworker-backend:buildcache,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Trigger deployment
run: |
curl -X POST ${{ secrets.AIWORKER_API_URL }}/api/deployments \
-H "Authorization: Bearer ${{ secrets.AIWORKER_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"projectId": "backend",
"environment": "${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}",
"commitHash": "${{ github.sha }}",
"branch": "${{ github.ref_name }}"
}'
```
### Workflow para Frontend
```yaml
# .gitea/workflows/frontend.yml
name: Frontend CI/CD
on:
push:
branches: [main, staging]
paths:
- 'frontend/**'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.6
- name: Install and build
working-directory: ./frontend
run: |
bun install
bun run build
- name: Build Docker image
run: |
docker build -t aiworker-frontend:${{ github.sha }} ./frontend
docker tag aiworker-frontend:${{ github.sha }} aiworker-frontend:latest
- name: Push to registry
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
docker push aiworker-frontend:${{ github.sha }}
docker push aiworker-frontend:latest
- name: Deploy
run: |
kubectl set image deployment/frontend frontend=aiworker-frontend:${{ github.sha }} -n control-plane
```
## Webhooks Handler
```typescript
// services/gitea/webhooks.ts
export async function handlePushEvent(payload: any) {
const { ref, commits, repository } = payload
const branch = ref.replace('refs/heads/', '')
logger.info(`Push to ${repository.full_name}:${branch}`, {
commits: commits.length,
})
// Find project by repo
const project = await db.query.projects.findFirst({
where: eq(projects.giteaRepoUrl, repository.clone_url),
})
if (!project) {
logger.warn('Project not found for repo:', repository.clone_url)
return
}
// Determine environment based on branch
let environment: 'dev' | 'staging' | 'production' | null = null
if (branch === 'main' || branch === 'master') {
environment = 'production'
} else if (branch === 'staging') {
environment = 'staging'
} else if (branch === 'develop' || branch === 'dev') {
environment = 'dev'
}
if (!environment) {
logger.debug('Ignoring push to non-deployment branch:', branch)
return
}
// Create deployment
const deploymentId = crypto.randomUUID()
const commitHash = commits[commits.length - 1].id
await db.insert(deployments).values({
id: deploymentId,
projectId: project.id,
environment,
deploymentType: 'automatic',
branch,
commitHash,
status: 'pending',
})
// Enqueue deployment job
await enqueueDeploy({
deploymentId,
projectId: project.id,
environment,
branch,
commitHash,
})
logger.info(`Deployment queued: ${environment} for ${project.name}`)
}
```
## Manual Deployment
```typescript
// api/routes/deployments.ts
router.post('/deployments', async (req, res) => {
const { projectId, environment, commitHash, branch } = req.body
// Validate
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
})
if (!project) {
return res.status(404).json({ error: 'Project not found' })
}
// Create deployment record
const deploymentId = crypto.randomUUID()
await db.insert(deployments).values({
id: deploymentId,
projectId,
environment,
deploymentType: 'manual',
branch,
commitHash,
status: 'pending',
triggeredBy: req.user?.id,
})
// Enqueue
await enqueueDeploy({
deploymentId,
projectId,
environment,
branch,
commitHash,
})
res.status(201).json({
deploymentId,
status: 'pending',
})
})
```
## Deployment Worker
```typescript
// services/queue/deploy-worker.ts
import { Worker } from 'bullmq'
import { K8sClient } from '../kubernetes/client'
import { db } from '../../db/client'
import { deployments } from '../../db/schema'
import { eq } from 'drizzle-orm'
const k8sClient = new K8sClient()
export const deployWorker = new Worker(
'deploys',
async (job) => {
const { deploymentId, projectId, environment, branch, commitHash } = job.data
logger.info(`Starting deployment: ${environment}`, { deploymentId })
// Update status
await db.update(deployments)
.set({
status: 'in_progress',
startedAt: new Date(),
})
.where(eq(deployments.id, deploymentId))
job.updateProgress(10)
try {
// Get project config
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
})
if (!project) {
throw new Error('Project not found')
}
job.updateProgress(20)
// Build image tag
const imageTag = `${project.dockerImage}:${commitHash.slice(0, 7)}`
// Determine namespace
const namespace =
environment === 'production'
? `${project.k8sNamespace}-prod`
: environment === 'staging'
? `${project.k8sNamespace}-staging`
: `${project.k8sNamespace}-dev`
job.updateProgress(30)
// Create/update deployment
await k8sClient.createOrUpdateDeployment({
namespace,
name: `${project.name}-${environment}`,
image: imageTag,
envVars: project.envVars as Record<string, string>,
replicas: environment === 'production' ? project.replicas : 1,
resources: {
cpu: project.cpuLimit || '500m',
memory: project.memoryLimit || '512Mi',
},
})
job.updateProgress(70)
// Create/update service
await k8sClient.createOrUpdateService({
namespace,
name: `${project.name}-${environment}`,
port: 3000,
})
job.updateProgress(80)
// Create/update ingress
const host =
environment === 'production'
? `${project.name}.aiworker.dev`
: `${environment}-${project.name}.aiworker.dev`
const url = await k8sClient.createOrUpdateIngress({
namespace,
name: `${project.name}-${environment}`,
host,
serviceName: `${project.name}-${environment}`,
servicePort: 3000,
})
job.updateProgress(90)
// Wait for deployment to be ready
await k8sClient.waitForDeployment(namespace, `${project.name}-${environment}`, 300)
job.updateProgress(100)
// Update deployment as completed
const completedAt = new Date()
const durationSeconds = Math.floor(
(completedAt.getTime() - job.processedOn!) / 1000
)
await db.update(deployments)
.set({
status: 'completed',
completedAt,
url,
durationSeconds,
})
.where(eq(deployments.id, deploymentId))
// Emit event
emitWebSocketEvent('deploy:completed', {
deploymentId,
environment,
url,
})
logger.info(`Deployment completed: ${environment}${url}`)
return { success: true, url }
} catch (error: any) {
logger.error('Deployment failed:', error)
// Update as failed
await db.update(deployments)
.set({
status: 'failed',
errorMessage: error.message,
completedAt: new Date(),
})
.where(eq(deployments.id, deploymentId))
// Emit event
emitWebSocketEvent('deploy:failed', {
deploymentId,
environment,
error: error.message,
})
throw error
}
},
{
connection: getRedis(),
concurrency: 3,
}
)
```
## Rollback
```typescript
// api/routes/deployments.ts
router.post('/deployments/:id/rollback', async (req, res) => {
const { id } = req.params
// Get deployment
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.id, id),
})
if (!deployment) {
return res.status(404).json({ error: 'Deployment not found' })
}
// Get previous successful deployment
const previousDeployment = await db.query.deployments.findFirst({
where: and(
eq(deployments.projectId, deployment.projectId),
eq(deployments.environment, deployment.environment),
eq(deployments.status, 'completed'),
lt(deployments.createdAt, deployment.createdAt)
),
orderBy: [desc(deployments.createdAt)],
})
if (!previousDeployment) {
return res.status(400).json({ error: 'No previous deployment to rollback to' })
}
// Create rollback deployment
const rollbackId = crypto.randomUUID()
await db.insert(deployments).values({
id: rollbackId,
projectId: deployment.projectId,
environment: deployment.environment,
deploymentType: 'rollback',
branch: previousDeployment.branch,
commitHash: previousDeployment.commitHash,
status: 'pending',
triggeredBy: req.user?.id,
})
// Enqueue
await enqueueDeploy({
deploymentId: rollbackId,
projectId: deployment.projectId,
environment: deployment.environment,
branch: previousDeployment.branch!,
commitHash: previousDeployment.commitHash!,
})
res.json({
deploymentId: rollbackId,
rollingBackTo: previousDeployment.commitHash,
})
})
```
## Health Checks Post-Deploy
```typescript
async function verifyDeployment(url: string): Promise<boolean> {
const maxAttempts = 10
const delayMs = 3000
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`${url}/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
})
if (response.ok) {
logger.info(`Deployment healthy: ${url}`)
return true
}
} catch (error) {
logger.debug(`Health check attempt ${i + 1} failed`)
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
logger.error(`Deployment failed health checks: ${url}`)
return false
}
```