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:
495
docs/06-deployment/ci-cd.md
Normal file
495
docs/06-deployment/ci-cd.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# 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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user