- 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>
496 lines
12 KiB
Markdown
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
|
|
}
|
|
```
|