# 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, 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 { 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 } ```