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:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Credentials (NEVER commit)
|
||||
CLUSTER-CREDENTIALS.md
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
*.env
|
||||
.env.local
|
||||
|
||||
# Cluster config
|
||||
.kube/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Backend (already has own .gitignore)
|
||||
backend/node_modules/
|
||||
backend/dist/
|
||||
backend/bun.lock
|
||||
|
||||
# Frontend (when created)
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "backend"]
|
||||
path = backend
|
||||
url = https://git.fuq.tv/admin/aiworker-backend.git
|
||||
855
AGENT-GUIDE.md
Normal file
855
AGENT-GUIDE.md
Normal file
@@ -0,0 +1,855 @@
|
||||
# Guía para Agentes IA - Gestión del Cluster Kubernetes
|
||||
|
||||
Este documento contiene toda la información necesaria para que agentes IA puedan gestionar y operar el cluster de Kubernetes de AiWorker.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Acceso al Cluster
|
||||
|
||||
### Kubeconfig
|
||||
```bash
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
Todos los comandos kubectl deben usar:
|
||||
```bash
|
||||
kubectl --kubeconfig ~/.kube/aiworker-config <comando>
|
||||
```
|
||||
|
||||
O con el alias:
|
||||
```bash
|
||||
alias k='kubectl --kubeconfig ~/.kube/aiworker-config'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Comandos Esenciales
|
||||
|
||||
### Verificación del Cluster
|
||||
```bash
|
||||
# Estado de nodos
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Todos los pods
|
||||
kubectl get pods -A
|
||||
|
||||
# Pods por namespace
|
||||
kubectl get pods -n <namespace>
|
||||
|
||||
# Recursos del cluster
|
||||
kubectl top nodes
|
||||
kubectl top pods -A
|
||||
|
||||
# Eventos recientes
|
||||
kubectl get events -A --sort-by='.lastTimestamp' | tail -20
|
||||
```
|
||||
|
||||
### Gestión de Deployments
|
||||
```bash
|
||||
# Ver deployments
|
||||
kubectl get deployments -A
|
||||
|
||||
# Detalles de un deployment
|
||||
kubectl describe deployment <name> -n <namespace>
|
||||
|
||||
# Escalar deployment
|
||||
kubectl scale deployment <name> -n <namespace> --replicas=3
|
||||
|
||||
# Restart deployment
|
||||
kubectl rollout restart deployment <name> -n <namespace>
|
||||
|
||||
# Ver historial
|
||||
kubectl rollout history deployment <name> -n <namespace>
|
||||
|
||||
# Rollback
|
||||
kubectl rollout undo deployment <name> -n <namespace>
|
||||
```
|
||||
|
||||
### Gestión de Pods
|
||||
```bash
|
||||
# Ver logs
|
||||
kubectl logs -f <pod-name> -n <namespace>
|
||||
|
||||
# Logs de contenedor específico
|
||||
kubectl logs -f <pod-name> -c <container-name> -n <namespace>
|
||||
|
||||
# Ejecutar comando en pod
|
||||
kubectl exec -n <namespace> <pod-name> -- <command>
|
||||
|
||||
# Shell interactivo
|
||||
kubectl exec -it -n <namespace> <pod-name> -- /bin/bash
|
||||
|
||||
# Copiar archivos
|
||||
kubectl cp <namespace>/<pod>:/path/to/file ./local-file
|
||||
kubectl cp ./local-file <namespace>/<pod>:/path/to/file
|
||||
```
|
||||
|
||||
### Gestión de Services
|
||||
```bash
|
||||
# Ver servicios
|
||||
kubectl get svc -A
|
||||
|
||||
# Port-forward para testing
|
||||
kubectl port-forward -n <namespace> svc/<service-name> 8080:80
|
||||
|
||||
# Endpoints de un servicio
|
||||
kubectl get endpoints -n <namespace> <service-name>
|
||||
```
|
||||
|
||||
### Ingress y TLS
|
||||
```bash
|
||||
# Ver ingress
|
||||
kubectl get ingress -A
|
||||
|
||||
# Ver certificados
|
||||
kubectl get certificate -A
|
||||
|
||||
# Detalles de certificado
|
||||
kubectl describe certificate <name> -n <namespace>
|
||||
|
||||
# Ver CertificateRequests
|
||||
kubectl get certificaterequest -A
|
||||
```
|
||||
|
||||
### Storage y PVCs
|
||||
```bash
|
||||
# Ver PVCs
|
||||
kubectl get pvc -A
|
||||
|
||||
# Ver PVs
|
||||
kubectl get pv
|
||||
|
||||
# Longhorn volumes
|
||||
kubectl get volumes.longhorn.io -n longhorn-system
|
||||
|
||||
# Réplicas de storage
|
||||
kubectl get replicas.longhorn.io -n longhorn-system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Desplegar Aplicaciones
|
||||
|
||||
### Crear Deployment Básico
|
||||
```bash
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: control-plane
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: myapp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: myapp
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
EOF
|
||||
```
|
||||
|
||||
### Crear Service
|
||||
```bash
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: control-plane
|
||||
spec:
|
||||
selector:
|
||||
app: myapp
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
EOF
|
||||
```
|
||||
|
||||
### Crear Ingress con TLS
|
||||
```bash
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- myapp.fuq.tv
|
||||
secretName: myapp-fuq-tv-tls
|
||||
rules:
|
||||
- host: myapp.fuq.tv
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: myapp
|
||||
port:
|
||||
number: 80
|
||||
EOF
|
||||
```
|
||||
|
||||
### Crear PVC con Longhorn HA
|
||||
```bash
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: myapp-data
|
||||
namespace: control-plane
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Acceso a Bases de Datos
|
||||
|
||||
### MariaDB (Internal)
|
||||
|
||||
**Connection String:**
|
||||
```
|
||||
Host: mariadb.control-plane.svc.cluster.local
|
||||
Port: 3306
|
||||
Database: aiworker
|
||||
User: aiworker
|
||||
Password: AiWorker2026_UserPass!
|
||||
```
|
||||
|
||||
**Root Access:**
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- mariadb -uroot -pAiWorker2026_RootPass!
|
||||
```
|
||||
|
||||
**Crear nueva base de datos:**
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- mariadb -uroot -pAiWorker2026_RootPass! -e "CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
```
|
||||
|
||||
**Backup:**
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- mariadb-dump -uroot -pAiWorker2026_RootPass! --all-databases > backup.sql
|
||||
```
|
||||
|
||||
**Restore:**
|
||||
```bash
|
||||
cat backup.sql | kubectl exec -i -n control-plane mariadb-0 -- mariadb -uroot -pAiWorker2026_RootPass!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Pod no arranca
|
||||
```bash
|
||||
# Ver eventos
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
|
||||
# Ver logs
|
||||
kubectl logs <pod-name> -n <namespace>
|
||||
|
||||
# Logs del contenedor anterior (si crasheó)
|
||||
kubectl logs <pod-name> -n <namespace> --previous
|
||||
|
||||
# Shell en pod fallido
|
||||
kubectl debug -it <pod-name> -n <namespace> --image=busybox
|
||||
```
|
||||
|
||||
### Ingress no funciona
|
||||
```bash
|
||||
# Verificar Ingress
|
||||
kubectl get ingress -n <namespace>
|
||||
kubectl describe ingress <name> -n <namespace>
|
||||
|
||||
# Ver logs de Nginx Ingress
|
||||
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller --tail=100
|
||||
|
||||
# Verificar certificado
|
||||
kubectl get certificate -n <namespace>
|
||||
kubectl describe certificate <name> -n <namespace>
|
||||
|
||||
# Si TLS falla, ver CertificateRequest
|
||||
kubectl get certificaterequest -A
|
||||
```
|
||||
|
||||
### Storage/PVC issues
|
||||
```bash
|
||||
# Ver PVC
|
||||
kubectl get pvc -n <namespace>
|
||||
kubectl describe pvc <name> -n <namespace>
|
||||
|
||||
# Ver Longhorn volumes
|
||||
kubectl get volumes.longhorn.io -n longhorn-system
|
||||
|
||||
# Longhorn UI
|
||||
https://longhorn.fuq.tv (admin / aiworker2026)
|
||||
|
||||
# Ver réplicas
|
||||
kubectl get replicas.longhorn.io -n longhorn-system
|
||||
```
|
||||
|
||||
### Nodo con problemas
|
||||
```bash
|
||||
# Cordon (no asignar nuevos pods)
|
||||
kubectl cordon <node-name>
|
||||
|
||||
# Drain (mover pods a otros nodos)
|
||||
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
|
||||
|
||||
# Uncordon (volver a habilitar)
|
||||
kubectl uncordon <node-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Workflows Comunes
|
||||
|
||||
### Desplegar nueva aplicación completa
|
||||
|
||||
```bash
|
||||
# 1. Crear namespace si no existe
|
||||
kubectl create namespace myapp
|
||||
|
||||
# 2. Crear secret si necesita
|
||||
kubectl create secret generic myapp-secret -n myapp \
|
||||
--from-literal=api-key=xxx
|
||||
|
||||
# 3. Aplicar manifests
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f service.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
|
||||
# 4. Verificar
|
||||
kubectl get all -n myapp
|
||||
kubectl get ingress -n myapp
|
||||
kubectl get certificate -n myapp
|
||||
|
||||
# 5. Ver logs
|
||||
kubectl logs -f -n myapp deployment/myapp
|
||||
```
|
||||
|
||||
### Actualizar imagen de deployment
|
||||
|
||||
```bash
|
||||
# Opción 1: Imperativa
|
||||
kubectl set image deployment/<name> <container>=<new-image>:<tag> -n <namespace>
|
||||
|
||||
# Opción 2: Patch
|
||||
kubectl patch deployment <name> -n <namespace> \
|
||||
-p '{"spec":{"template":{"spec":{"containers":[{"name":"<container>","image":"<new-image>:<tag>"}]}}}}'
|
||||
|
||||
# Opción 3: Edit
|
||||
kubectl edit deployment <name> -n <namespace>
|
||||
```
|
||||
|
||||
### Preview Environment (nuevo namespace temporal)
|
||||
|
||||
```bash
|
||||
# 1. Crear namespace
|
||||
kubectl create namespace preview-task-123
|
||||
|
||||
# 2. Label para cleanup automático
|
||||
kubectl label namespace preview-task-123 environment=preview ttl=168h
|
||||
|
||||
# 3. Deploy app
|
||||
kubectl apply -f app.yaml -n preview-task-123
|
||||
|
||||
# 4. Crear ingress
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: preview
|
||||
namespace: preview-task-123
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- task-123.r.fuq.tv
|
||||
secretName: preview-task-123-tls
|
||||
rules:
|
||||
- host: task-123.r.fuq.tv
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: myapp
|
||||
port:
|
||||
number: 80
|
||||
EOF
|
||||
|
||||
# 5. Cleanup cuando termine
|
||||
kubectl delete namespace preview-task-123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Seguridad
|
||||
|
||||
### Secrets Management
|
||||
```bash
|
||||
# Crear secret
|
||||
kubectl create secret generic mysecret -n <namespace> \
|
||||
--from-literal=username=admin \
|
||||
--from-literal=password=xxx
|
||||
|
||||
# Ver secrets (no muestra valores)
|
||||
kubectl get secrets -n <namespace>
|
||||
|
||||
# Ver secret value
|
||||
kubectl get secret mysecret -n <namespace> -o jsonpath='{.data.password}' | base64 -d
|
||||
```
|
||||
|
||||
### RBAC
|
||||
```bash
|
||||
# Ver service accounts
|
||||
kubectl get sa -A
|
||||
|
||||
# Ver roles
|
||||
kubectl get roles -A
|
||||
kubectl get clusterroles
|
||||
|
||||
# Ver bindings
|
||||
kubectl get rolebindings -A
|
||||
kubectl get clusterrolebindings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Resource Usage
|
||||
```bash
|
||||
# Uso por nodo
|
||||
kubectl top nodes
|
||||
|
||||
# Uso por pod
|
||||
kubectl top pods -A
|
||||
|
||||
# Uso en namespace específico
|
||||
kubectl top pods -n control-plane
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Componentes del sistema
|
||||
kubectl get componentstatuses
|
||||
|
||||
# API server health
|
||||
kubectl get --raw='/readyz?verbose'
|
||||
|
||||
# etcd health (desde control plane)
|
||||
ssh root@108.165.47.233 "k3s kubectl get endpoints -n kube-system kube-apiserver"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 GitOps con ArgoCD
|
||||
|
||||
### Acceso
|
||||
- **URL**: https://argocd.fuq.tv
|
||||
- **User**: admin
|
||||
- **Pass**: LyPF4Hy0wvp52IoU
|
||||
|
||||
### Crear Application
|
||||
```bash
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.fuq.tv/aiworker/myapp
|
||||
targetRevision: HEAD
|
||||
path: k8s
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: control-plane
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📍 Información del Cluster
|
||||
|
||||
### URLs de Servicios
|
||||
- **Gitea**: https://git.fuq.tv
|
||||
- **ArgoCD**: https://argocd.fuq.tv
|
||||
- **Longhorn**: https://longhorn.fuq.tv
|
||||
- **Test App**: https://test.fuq.tv
|
||||
|
||||
### Conexiones Internas
|
||||
- **MariaDB**: `mariadb.control-plane.svc.cluster.local:3306`
|
||||
- **Gitea**: `gitea.gitea.svc.cluster.local:3000`
|
||||
- **ArgoCD API**: `argocd-server.argocd.svc.cluster.local:443`
|
||||
|
||||
### SSH a Nodos
|
||||
```bash
|
||||
# Control planes
|
||||
ssh root@108.165.47.233 # k8s-cp-01
|
||||
ssh root@108.165.47.235 # k8s-cp-02
|
||||
ssh root@108.165.47.215 # k8s-cp-03
|
||||
|
||||
# Workers
|
||||
ssh root@108.165.47.225 # k8s-worker-01
|
||||
ssh root@108.165.47.224 # k8s-worker-02
|
||||
ssh root@108.165.47.222 # k8s-worker-03
|
||||
|
||||
# Load balancers
|
||||
ssh root@108.165.47.221 # k8s-lb-01
|
||||
ssh root@108.165.47.203 # k8s-lb-02
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Tareas Comunes para Agentes
|
||||
|
||||
### 1. Desplegar nueva versión de app
|
||||
|
||||
```bash
|
||||
# Actualizar imagen
|
||||
kubectl set image deployment/<name> <container>=<image>:<new-tag> -n <namespace>
|
||||
|
||||
# Verificar rollout
|
||||
kubectl rollout status deployment/<name> -n <namespace>
|
||||
|
||||
# Si falla, rollback
|
||||
kubectl rollout undo deployment/<name> -n <namespace>
|
||||
```
|
||||
|
||||
### 2. Crear preview environment
|
||||
|
||||
```bash
|
||||
# Namespace
|
||||
kubectl create namespace preview-<task-id>
|
||||
|
||||
# Deploy
|
||||
kubectl apply -f manifests/ -n preview-<task-id>
|
||||
|
||||
# Ingress
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: preview
|
||||
namespace: preview-<task-id>
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- <task-id>.r.fuq.tv
|
||||
secretName: preview-tls
|
||||
rules:
|
||||
- host: <task-id>.r.fuq.tv
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: app
|
||||
port:
|
||||
number: 80
|
||||
EOF
|
||||
|
||||
# Verificar URL
|
||||
curl https://<task-id>.r.fuq.tv
|
||||
```
|
||||
|
||||
### 3. Escalar aplicación
|
||||
|
||||
```bash
|
||||
# Auto-scaling
|
||||
kubectl autoscale deployment <name> -n <namespace> --cpu-percent=80 --min=2 --max=10
|
||||
|
||||
# Manual
|
||||
kubectl scale deployment <name> -n <namespace> --replicas=5
|
||||
```
|
||||
|
||||
### 4. Investigar problema
|
||||
|
||||
```bash
|
||||
# 1. Ver estado general
|
||||
kubectl get pods -n <namespace>
|
||||
|
||||
# 2. Describir pod con problema
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
|
||||
# 3. Ver logs
|
||||
kubectl logs <pod-name> -n <namespace> --tail=100
|
||||
|
||||
# 4. Ver eventos
|
||||
kubectl get events -n <namespace> --sort-by='.lastTimestamp'
|
||||
|
||||
# 5. Si es storage
|
||||
kubectl get pvc -n <namespace>
|
||||
kubectl describe pvc <pvc-name> -n <namespace>
|
||||
|
||||
# 6. Si es networking
|
||||
kubectl get svc,endpoints -n <namespace>
|
||||
kubectl get ingress -n <namespace>
|
||||
```
|
||||
|
||||
### 5. Backup de configuración
|
||||
|
||||
```bash
|
||||
# Exportar todos los recursos
|
||||
kubectl get all,ingress,certificate,pvc -n <namespace> -o yaml > backup.yaml
|
||||
|
||||
# Backup específico
|
||||
kubectl get deployment <name> -n <namespace> -o yaml > deployment-backup.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Estructura de Manifests
|
||||
|
||||
### Template Completo
|
||||
```yaml
|
||||
---
|
||||
# Namespace
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: myapp
|
||||
|
||||
---
|
||||
# ConfigMap
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: myapp-config
|
||||
namespace: myapp
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
---
|
||||
# Secret
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: myapp-secret
|
||||
namespace: myapp
|
||||
type: Opaque
|
||||
stringData:
|
||||
api-key: "your-api-key"
|
||||
|
||||
---
|
||||
# PVC (si necesita storage)
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: myapp-data
|
||||
namespace: myapp
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
---
|
||||
# Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: myapp
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: myapp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: myapp
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: myapp-config
|
||||
key: NODE_ENV
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: myapp-secret
|
||||
key: api-key
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: myapp-data
|
||||
|
||||
---
|
||||
# Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: myapp
|
||||
spec:
|
||||
selector:
|
||||
app: myapp
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# Ingress
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: myapp
|
||||
namespace: myapp
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- myapp.fuq.tv
|
||||
secretName: myapp-tls
|
||||
rules:
|
||||
- host: myapp.fuq.tv
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: myapp
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Recursos de Referencia
|
||||
|
||||
### Documentación del Proyecto
|
||||
- `CLUSTER-CREDENTIALS.md` - Credenciales y tokens
|
||||
- `CLUSTER-READY.md` - Estado del cluster
|
||||
- `docs/` - Documentación completa del proyecto
|
||||
|
||||
### Comandos Útiles
|
||||
|
||||
```bash
|
||||
# Ver todo en un namespace
|
||||
kubectl get all -n <namespace>
|
||||
|
||||
# Aplicar un directorio completo
|
||||
kubectl apply -f ./k8s/ -R
|
||||
|
||||
# Diff antes de aplicar
|
||||
kubectl diff -f manifest.yaml
|
||||
|
||||
# Validar YAML
|
||||
kubectl apply --dry-run=client -f manifest.yaml
|
||||
|
||||
# Formatear output
|
||||
kubectl get pods -o wide
|
||||
kubectl get pods -o json
|
||||
kubectl get pods -o yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Reference
|
||||
|
||||
### Namespaces del Proyecto
|
||||
- `control-plane` - Backend, API, MySQL, Redis
|
||||
- `agents` - Claude Code agents
|
||||
- `gitea` - Git server
|
||||
- `monitoring` - Metrics, logs
|
||||
- `argocd` - GitOps
|
||||
|
||||
### StorageClass
|
||||
- `longhorn` (default) - HA storage con 3 réplicas
|
||||
|
||||
### ClusterIssuers
|
||||
- `letsencrypt-prod` - Certificados producción
|
||||
- `letsencrypt-staging` - Certificados testing
|
||||
|
||||
### IngressClass
|
||||
- `nginx` - Usar para todos los Ingress
|
||||
|
||||
---
|
||||
|
||||
**Con esta guía, cualquier agente IA puede operar el cluster de forma autónoma.**
|
||||
273
CLAUDE.md
Normal file
273
CLAUDE.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**AiWorker** is an AI agent orchestration platform that uses Claude Code agents running in Kubernetes pods to autonomously complete development tasks. The system manages a full workflow from task creation to production deployment.
|
||||
|
||||
**Core Flow**: Task → Agent (via MCP) → Code → PR → Preview Deploy → Approval → Staging → Production
|
||||
|
||||
**Current Status**: Infrastructure complete (K8s HA cluster), backend initialized (20% done), frontend and agents pending.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Tier System
|
||||
1. **Infrastructure Layer**: K3s HA cluster (8 VPS servers in Houston)
|
||||
- 3 control planes with etcd HA
|
||||
- 3 workers with Longhorn distributed storage (3 replicas)
|
||||
- 2 HAProxy load balancers for HTTP/HTTPS
|
||||
- Private network (10.100.0.0/24) for inter-node communication
|
||||
|
||||
2. **Platform Layer**: MariaDB, Redis, Gitea, ArgoCD
|
||||
- MariaDB 11.4 LTS with HA storage (database: `aiworker`)
|
||||
- Gitea 1.25.3 with built-in container registry
|
||||
- Gitea Actions for CI/CD (runner in K8s)
|
||||
- TLS automatic via Cert-Manager + Let's Encrypt
|
||||
|
||||
3. **Application Layer**: Backend (Bun), Frontend (React), Agents (Claude Code pods)
|
||||
- Backend uses **Bun.serve()** native API (NOT Express despite dependency)
|
||||
- Drizzle ORM with auto-migrations on startup
|
||||
- MCP protocol for agent communication
|
||||
|
||||
### Data Model (Drizzle schema in `backend/src/db/schema.ts`)
|
||||
- **projects**: User projects linked to Gitea repos and K8s namespaces
|
||||
- **agents**: Claude Code pods running in K8s (status: idle/busy/error/offline)
|
||||
- **tasks**: Development tasks with state machine (backlog → in_progress → needs_input → ready_to_test → approved → staging → production)
|
||||
|
||||
Relations: projects → many tasks, tasks → one agent, agents → one current task
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (Bun 1.3.6)
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Development with hot-reload
|
||||
bun run dev
|
||||
|
||||
# Start production
|
||||
bun run start
|
||||
|
||||
# Database migrations
|
||||
bun run db:generate # Generate new migration from schema changes
|
||||
bun run db:migrate # Apply migrations (also runs on app startup)
|
||||
bun run db:studio # Visual database explorer
|
||||
|
||||
# Code quality
|
||||
bun run lint
|
||||
bun run format
|
||||
```
|
||||
|
||||
**IMPORTANT**: Use Bun native APIs:
|
||||
- `Bun.serve()` for HTTP server (NOT Express)
|
||||
- `Bun.sql()` or `mysql2` for MariaDB (decision pending)
|
||||
- Native WebSocket support in `Bun.serve()`
|
||||
- `.env` is auto-loaded by Bun
|
||||
|
||||
### Kubernetes Operations
|
||||
```bash
|
||||
# Set kubeconfig (ALWAYS required)
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
|
||||
# Cluster status
|
||||
kubectl get nodes
|
||||
kubectl get pods -A
|
||||
|
||||
# Deploy to K8s
|
||||
kubectl apply -f k8s/backend/
|
||||
kubectl apply -f k8s/frontend/
|
||||
|
||||
# Logs
|
||||
kubectl logs -f -n control-plane deployment/backend
|
||||
kubectl logs -n gitea gitea-0
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner
|
||||
```
|
||||
|
||||
### CI/CD Workflow
|
||||
Push to main branch triggers automatic build:
|
||||
1. Git push → Gitea receives webhook
|
||||
2. Gitea Actions Runner (in K8s) picks up job
|
||||
3. Docker build inside runner pod (DinD)
|
||||
4. Push to `git.fuq.tv/admin/<repo>:latest`
|
||||
5. View progress: https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
|
||||
**Registry format**: `git.fuq.tv/<owner>/<package>:<tag>`
|
||||
|
||||
---
|
||||
|
||||
## Critical Architecture Details
|
||||
|
||||
### Database Migrations
|
||||
**Migrations run automatically on app startup** in `src/index.ts`:
|
||||
```typescript
|
||||
await runMigrations() // First thing on startup
|
||||
await testConnection()
|
||||
```
|
||||
|
||||
**Never** manually port-forward to run migrations. The app handles this in production when pods start.
|
||||
|
||||
### Bun.serve() Routing Pattern
|
||||
Unlike Express, Bun.serve() uses a single `fetch(req)` function:
|
||||
```typescript
|
||||
Bun.serve({
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === '/api/health') {
|
||||
return Response.json({ status: 'ok' })
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/projects')) {
|
||||
return handleProjectRoutes(req, url)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Route handlers should be organized in `src/api/routes/` and imported into main fetch.
|
||||
|
||||
### MCP Communication Flow
|
||||
Agents communicate via Model Context Protocol:
|
||||
1. Agent calls MCP tool (e.g., `get_next_task`)
|
||||
2. Backend MCP server (port 3100) handles request
|
||||
3. Backend queries database, performs actions
|
||||
4. Returns result to agent
|
||||
5. Agent continues work autonomously
|
||||
|
||||
MCP tools to implement (see `docs/05-agents/mcp-tools.md`):
|
||||
- `get_next_task`, `update_task_status`, `ask_user_question`, `create_branch`, `create_pull_request`, `trigger_preview_deploy`
|
||||
|
||||
### Preview Environments
|
||||
Each task gets isolated namespace: `preview-task-{taskId}`
|
||||
- Auto-deploy on PR creation
|
||||
- Accessible at `task-{shortId}.r.fuq.tv`
|
||||
- Auto-cleanup after 7 days (TTL label)
|
||||
|
||||
---
|
||||
|
||||
## Key Environment Variables
|
||||
|
||||
**Backend** (`.env` file):
|
||||
```bash
|
||||
# Database (MariaDB in K8s)
|
||||
DB_HOST=mariadb.control-plane.svc.cluster.local
|
||||
DB_USER=aiworker
|
||||
DB_PASSWORD=AiWorker2026_UserPass!
|
||||
DB_NAME=aiworker
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis.control-plane.svc.cluster.local
|
||||
|
||||
# Gitea
|
||||
GITEA_URL=https://git.fuq.tv
|
||||
GITEA_TOKEN=159a5de2a16d15f33e388b55b1276e431dbca3f3
|
||||
|
||||
# Kubernetes
|
||||
K8S_IN_CLUSTER=false # true when running in K8s
|
||||
K8S_CONFIG_PATH=~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
**Local development**: Port-forward services from K8s
|
||||
```bash
|
||||
kubectl port-forward -n control-plane svc/mariadb 3306:3306 &
|
||||
kubectl port-forward -n control-plane svc/redis 6379:6379 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Important Constraints
|
||||
|
||||
### Storage HA Strategy
|
||||
All stateful data uses Longhorn with **3 replicas** for high availability:
|
||||
- MariaDB PVC: 20Gi replicated across 3 workers
|
||||
- Gitea PVC: 50Gi replicated across 3 workers
|
||||
- Can tolerate 2 worker node failures without data loss
|
||||
|
||||
### DNS and Domains
|
||||
All services use `*.fuq.tv` with DNS round-robin pointing to 2 load balancers:
|
||||
- `api.fuq.tv` → Backend API
|
||||
- `app.fuq.tv` → Frontend dashboard
|
||||
- `git.fuq.tv` → Gitea
|
||||
- `*.r.fuq.tv` → Preview environments (e.g., `task-abc.r.fuq.tv`)
|
||||
|
||||
Load balancers (108.165.47.221, 108.165.47.203) run HAProxy balancing to worker NodePorts.
|
||||
|
||||
### Namespace Organization
|
||||
- `control-plane`: Backend API, MariaDB, Redis
|
||||
- `agents`: Claude Code agent pods
|
||||
- `gitea`: Git server
|
||||
- `gitea-actions`: CI/CD runner with Docker-in-Docker
|
||||
- `preview-*`: Temporary namespaces for preview deployments
|
||||
|
||||
---
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
Extensive documentation in `/docs` (40+ files):
|
||||
- **Start here**: `ROADMAP.md`, `NEXT-SESSION.md`, `QUICK-REFERENCE.md`
|
||||
- **Infrastructure**: `CLUSTER-READY.md`, `AGENT-GUIDE.md`, `TROUBLESHOOTING.md`
|
||||
- **Gitea**: `GITEA-GUIDE.md` - Complete guide for Git, Registry, API, CI/CD, and webhooks
|
||||
- **Detailed**: `docs/01-arquitectura/` through `docs/06-deployment/`
|
||||
|
||||
**For agent AI operations**: Read `AGENT-GUIDE.md` - contains all kubectl commands and workflows needed to manage the cluster autonomously.
|
||||
|
||||
**For Gitea operations**: Read `GITEA-GUIDE.md` - complete API usage, registry, tokens, webhooks, and CI/CD setup.
|
||||
|
||||
**For credentials**: See `CLUSTER-CREDENTIALS.md` (not in git, local only)
|
||||
|
||||
---
|
||||
|
||||
## Next Development Steps
|
||||
|
||||
Current phase: **Backend API implementation** (see `NEXT-SESSION.md` for detailed checklist)
|
||||
|
||||
Priority order:
|
||||
1. Verify CI/CD build successful → image in registry
|
||||
2. Implement REST API routes (`/api/projects`, `/api/tasks`, `/api/agents`)
|
||||
3. Implement MCP Server (port 3100) for agent communication
|
||||
4. Integrate Gitea API client (repos, PRs, webhooks)
|
||||
5. Integrate Kubernetes client (create namespaces, deployments, ingress)
|
||||
6. Deploy backend to K8s at `api.fuq.tv`
|
||||
|
||||
Frontend and agents come after backend is functional.
|
||||
|
||||
---
|
||||
|
||||
## External References
|
||||
|
||||
- **Lucia Auth** (for React frontend): https://github.com/lucia-auth/lucia
|
||||
- **Vercel Agent Skills** (for React frontend): https://github.com/vercel-labs/agent-skills
|
||||
- **Gitea API**: https://git.fuq.tv/api/swagger
|
||||
- **MCP SDK**: `@modelcontextprotocol/sdk` documentation
|
||||
|
||||
---
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
### Backend Deployment
|
||||
```
|
||||
Code change → Git push → Gitea Actions → Docker build → Push to git.fuq.tv → ArgoCD sync → K8s deploy
|
||||
```
|
||||
|
||||
### Agent Deployment
|
||||
```
|
||||
Backend creates pod → Agent starts → Registers via MCP → Polls for tasks → Works autonomously → Reports back
|
||||
```
|
||||
|
||||
### Preview Deployment
|
||||
```
|
||||
Agent completes task → Create PR → Trigger preview → K8s namespace created → Deploy at task-{id}.r.fuq.tv → User tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Read `NEXT-SESSION.md` for detailed next steps. All credentials and cluster access info in `QUICK-REFERENCE.md`.**
|
||||
311
CLUSTER-READY.md
Normal file
311
CLUSTER-READY.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# 🚀 AiWorker Kubernetes Cluster - PRODUCTION READY
|
||||
|
||||
**Status**: ✅ Completamente Funcional
|
||||
**Fecha**: 2026-01-19
|
||||
**Ubicación**: Houston, Texas (us-hou-1)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Infraestructura Desplegada
|
||||
|
||||
### Servidores (8 VPS)
|
||||
|
||||
| Tipo | Hostname | IP Pública | IP Privada | Specs | Estado |
|
||||
|----------------|----------------|-----------------|-------------|----------------------|--------|
|
||||
| Control Plane | k8s-cp-01 | 108.165.47.233 | 10.100.0.2 | 4 vCPU, 8 GB RAM | ✅ |
|
||||
| Control Plane | k8s-cp-02 | 108.165.47.235 | 10.100.0.3 | 4 vCPU, 8 GB RAM | ✅ |
|
||||
| Control Plane | k8s-cp-03 | 108.165.47.215 | 10.100.0.4 | 4 vCPU, 8 GB RAM | ✅ |
|
||||
| Worker | k8s-worker-01 | 108.165.47.225 | 10.100.0.5 | 8 vCPU, 16 GB RAM | ✅ |
|
||||
| Worker | k8s-worker-02 | 108.165.47.224 | 10.100.0.6 | 8 vCPU, 16 GB RAM | ✅ |
|
||||
| Worker | k8s-worker-03 | 108.165.47.222 | 10.100.0.7 | 8 vCPU, 16 GB RAM | ✅ |
|
||||
| Load Balancer | k8s-lb-01 | 108.165.47.221 | 10.100.0.8 | 2 vCPU, 4 GB RAM | ✅ |
|
||||
| Load Balancer | k8s-lb-02 | 108.165.47.203 | 10.100.0.9 | 2 vCPU, 4 GB RAM | ✅ |
|
||||
|
||||
**Total**: 48 vCPU, 104 GB RAM, ~2.9 TB Storage
|
||||
**Costo**: $148/mes
|
||||
|
||||
---
|
||||
|
||||
## 🌐 URLs de Acceso
|
||||
|
||||
| Servicio | URL | Credenciales | Estado |
|
||||
|-------------|----------------------------|----------------------------|--------|
|
||||
| Gitea | https://git.fuq.tv | (setup inicial pendiente) | ✅ |
|
||||
| ArgoCD | https://argocd.fuq.tv | admin / LyPF4Hy0wvp52IoU | ✅ |
|
||||
| Longhorn UI | https://longhorn.fuq.tv | admin / aiworker2026 | ✅ |
|
||||
| HAProxy LB1 | http://108.165.47.221:8404/stats | admin / aiworker2026 | ✅ |
|
||||
| HAProxy LB2 | http://108.165.47.203:8404/stats | admin / aiworker2026 | ✅ |
|
||||
| Test App | https://test.fuq.tv | (público) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 💾 Bases de Datos
|
||||
|
||||
### MariaDB 11.4.9 LTS
|
||||
|
||||
**Conexión interna (desde pods)**:
|
||||
```
|
||||
Host: mariadb.control-plane.svc.cluster.local
|
||||
Port: 3306
|
||||
```
|
||||
|
||||
**Credenciales Root:**
|
||||
```
|
||||
Usuario: root
|
||||
Password: AiWorker2026_RootPass!
|
||||
```
|
||||
|
||||
**Credenciales Aplicación:**
|
||||
```
|
||||
Database: aiworker
|
||||
Usuario: aiworker
|
||||
Password: AiWorker2026_UserPass!
|
||||
```
|
||||
|
||||
**Storage**: PVC 20Gi con Longhorn (3 réplicas HA)
|
||||
|
||||
**Conexión de prueba:**
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- mariadb -uaiworker -pAiWorker2026_UserPass! aiworker -e "SHOW TABLES;"
|
||||
```
|
||||
|
||||
### Gitea Database
|
||||
|
||||
**Base de datos**: `gitea` (creada en MariaDB)
|
||||
**Conexión**: Configurada automáticamente en Gitea
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Storage HA con Longhorn
|
||||
|
||||
### Configuración
|
||||
- **StorageClass**: `longhorn` (default)
|
||||
- **Replicación**: 3 réplicas por volumen
|
||||
- **Tolerancia a fallos**: Puede perder 2 nodos sin pérdida de datos
|
||||
- **UI**: https://longhorn.fuq.tv
|
||||
|
||||
### Volúmenes Actuales
|
||||
|
||||
| PVC | Namespace | Tamaño | Réplicas | Nodos |
|
||||
|--------------|----------------|--------|----------|--------------------------------------|
|
||||
| mariadb-pvc | control-plane | 20Gi | 3 | worker-01, worker-02, worker-03 |
|
||||
| gitea-data | gitea | 50Gi | 3 | worker-01, worker-02, worker-03 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Software Instalado
|
||||
|
||||
| Componente | Versión | Namespace | Estado |
|
||||
|-------------------------|--------------|----------------|--------|
|
||||
| K3s | v1.35.0+k3s1 | - | ✅ |
|
||||
| Nginx Ingress | latest | ingress-nginx | ✅ |
|
||||
| Cert-Manager | v1.16.2 | cert-manager | ✅ |
|
||||
| Longhorn | v1.8.0 | longhorn-system| ✅ |
|
||||
| ArgoCD | stable | argocd | ✅ |
|
||||
| MariaDB | 11.4.9 | control-plane | ✅ |
|
||||
| Gitea | 1.22 | gitea | ✅ |
|
||||
| HAProxy | 2.8.16 | (en LBs) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Kubeconfig
|
||||
|
||||
**Path local**: `~/.kube/aiworker-config`
|
||||
|
||||
**Configurar como default:**
|
||||
```bash
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
**Crear alias:**
|
||||
```bash
|
||||
alias k='kubectl --kubeconfig ~/.kube/aiworker-config'
|
||||
```
|
||||
|
||||
**Uso:**
|
||||
```bash
|
||||
kubectl --kubeconfig ~/.kube/aiworker-config get nodes
|
||||
kubectl --kubeconfig ~/.kube/aiworker-config get pods -A
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Namespaces
|
||||
|
||||
| Namespace | Propósito | Resource Quota |
|
||||
|-----------------|-------------------------------|---------------------|
|
||||
| control-plane | Backend, API, MySQL, Redis | 8 CPU, 16 GB |
|
||||
| agents | Claude Code agents | 20 CPU, 40 GB |
|
||||
| gitea | Git server | 2 CPU, 4 GB |
|
||||
| monitoring | Prometheus, Grafana (futuro) | - |
|
||||
| argocd | GitOps | - |
|
||||
| ingress-nginx | Ingress controller | - |
|
||||
| cert-manager | TLS management | - |
|
||||
| longhorn-system | Distributed storage | - |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Seguridad
|
||||
|
||||
### TLS/SSL
|
||||
- ✅ Certificados automáticos con Let's Encrypt
|
||||
- ✅ Force HTTPS redirect
|
||||
- ✅ Email notificaciones: hector+aiworker@teamsuqad.io
|
||||
|
||||
### Secrets Creados
|
||||
```bash
|
||||
# MariaDB
|
||||
kubectl get secret mariadb-secret -n control-plane
|
||||
|
||||
# Longhorn UI
|
||||
kubectl get secret longhorn-basic-auth -n longhorn-system
|
||||
|
||||
# ArgoCD
|
||||
kubectl get secret argocd-initial-admin-secret -n argocd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verificación Funcional
|
||||
|
||||
### Cluster Health
|
||||
```bash
|
||||
kubectl get nodes
|
||||
kubectl get pods -A
|
||||
kubectl top nodes
|
||||
kubectl get pvc -A
|
||||
```
|
||||
|
||||
### Storage Replication
|
||||
```bash
|
||||
# Ver volúmenes
|
||||
kubectl get volumes.longhorn.io -n longhorn-system
|
||||
|
||||
# Ver réplicas
|
||||
kubectl get replicas.longhorn.io -n longhorn-system
|
||||
|
||||
# UI Web
|
||||
https://longhorn.fuq.tv
|
||||
```
|
||||
|
||||
### Ingress & TLS
|
||||
```bash
|
||||
# Ver ingress
|
||||
kubectl get ingress -A
|
||||
|
||||
# Ver certificados
|
||||
kubectl get certificate -A
|
||||
|
||||
# Probar acceso
|
||||
curl https://test.fuq.tv
|
||||
curl https://git.fuq.tv
|
||||
curl https://argocd.fuq.tv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Próximos Pasos
|
||||
|
||||
### 1. Configurar Gitea (https://git.fuq.tv)
|
||||
- Completar instalación inicial
|
||||
- Crear organización "aiworker"
|
||||
- Crear usuario bot con token
|
||||
- Configurar webhooks
|
||||
|
||||
### 2. Desplegar Backend
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
```
|
||||
|
||||
### 3. Desplegar Frontend
|
||||
```bash
|
||||
kubectl apply -f k8s/frontend/
|
||||
```
|
||||
|
||||
### 4. Configurar ArgoCD
|
||||
- Login en https://argocd.fuq.tv
|
||||
- Conectar repositorio Gitea
|
||||
- Crear Applications
|
||||
- Configurar auto-sync
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Arquitectura Final
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
[DNS: *.fuq.tv]
|
||||
(108.165.47.221 + .203)
|
||||
↓
|
||||
┌─────────────┴─────────────┐
|
||||
↓ ↓
|
||||
[HAProxy LB-01] [HAProxy LB-02]
|
||||
:80, :443 :80, :443
|
||||
↓ ↓
|
||||
└─────────────┬─────────────┘
|
||||
↓
|
||||
[Private Network]
|
||||
10.100.0.0/24
|
||||
↓
|
||||
┌───────────────────┼───────────────────┐
|
||||
↓ ↓ ↓
|
||||
[CP etcd HA] [CP etcd HA] [CP etcd HA]
|
||||
10.100.0.2 10.100.0.3 10.100.0.4
|
||||
↓ ↓ ↓
|
||||
─────┴───────────────────┴───────────────────┴─────
|
||||
↓ ↓ ↓
|
||||
[Worker + Storage] [Worker + Storage] [Worker + Storage]
|
||||
10.100.0.5 10.100.0.6 10.100.0.7
|
||||
↓ ↓ ↓
|
||||
[Pods] [Pods] [Pods]
|
||||
│ │ │
|
||||
[MariaDB PVC]────────[Longhorn 3x Replica]────────[Gitea PVC]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Lo que aprendimos
|
||||
|
||||
1. ✅ Desplegar K3s HA con embedded etcd (3 control planes)
|
||||
2. ✅ Configurar red privada para comunicación interna
|
||||
3. ✅ Setup HAProxy para load balancing HTTP/HTTPS
|
||||
4. ✅ DNS round-robin para HA de load balancers
|
||||
5. ✅ Nginx Ingress Controller con NodePort
|
||||
6. ✅ Cert-Manager con Let's Encrypt automático
|
||||
7. ✅ Longhorn distributed storage con replicación
|
||||
8. ✅ MariaDB 11.4 LTS con storage HA
|
||||
9. ✅ Gitea con storage HA y MariaDB
|
||||
10. ✅ ArgoCD para GitOps
|
||||
|
||||
---
|
||||
|
||||
## 💪 Características HA Implementadas
|
||||
|
||||
| Componente | HA Implementado | Tolerancia a Fallos |
|
||||
|-------------------|-----------------|---------------------|
|
||||
| Control Plane | ✅ 3 nodos etcd | Pierde 1 nodo |
|
||||
| Workers | ✅ 3 nodos | Pierde 2 nodos |
|
||||
| Load Balancers | ✅ DNS RR | Pierde 1 LB |
|
||||
| Storage (Longhorn)| ✅ 3 réplicas | Pierde 2 workers |
|
||||
| Ingress | ✅ En workers | Redundante |
|
||||
| DNS | ✅ 2 IPs | Auto failover |
|
||||
|
||||
**Cluster puede perder simultáneamente:**
|
||||
- 1 Control Plane
|
||||
- 2 Workers
|
||||
- 1 Load Balancer
|
||||
- Y seguir funcionando! 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
- **CubePath**: https://cubepath.com/support
|
||||
- **K3s**: https://docs.k3s.io
|
||||
- **Longhorn**: https://longhorn.io/docs/
|
||||
- **Cert-Manager**: https://cert-manager.io/docs/
|
||||
|
||||
---
|
||||
|
||||
**🎉 ¡Cluster listo para desplegar AiWorker!**
|
||||
241
CLUSTER-SETUP-COMPLETE.md
Normal file
241
CLUSTER-SETUP-COMPLETE.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# ✅ AiWorker Kubernetes Cluster - Setup Completo
|
||||
|
||||
**Fecha**: 2026-01-19
|
||||
**Estado**: ✅ Producción Ready
|
||||
|
||||
## 🎯 Resumen del Cluster
|
||||
|
||||
### Infraestructura Desplegada
|
||||
|
||||
| Componente | Cantidad | Plan | Specs | IP Pública | IP Privada |
|
||||
|------------------|----------|------------|--------------------------|------------------|-------------|
|
||||
| Control Planes | 3 | gp.starter | 4 vCPU, 8 GB RAM | 108.165.47.x | 10.100.0.2-4|
|
||||
| Workers | 3 | gp.small | 8 vCPU, 16 GB RAM | 108.165.47.x | 10.100.0.5-7|
|
||||
| Load Balancers | 2 | gp.micro | 2 vCPU, 4 GB RAM | 221, 203 | 10.100.0.8-9|
|
||||
| **Total** | **8** | | **48 vCPU, 104 GB RAM** | | |
|
||||
|
||||
### Software Stack
|
||||
|
||||
| Componente | Versión | Estado | Propósito |
|
||||
|-------------------------|--------------|--------|-------------------------------------|
|
||||
| K3s | v1.35.0+k3s1 | ✅ | Kubernetes distribution |
|
||||
| Nginx Ingress | latest | ✅ | HTTP/HTTPS routing |
|
||||
| Cert-Manager | v1.16.2 | ✅ | TLS certificates automation |
|
||||
| ArgoCD | stable | ✅ | GitOps continuous delivery |
|
||||
| HAProxy | 2.8.16 | ✅ | Load balancing (on LB nodes) |
|
||||
| Metrics Server | included | ✅ | Resource metrics |
|
||||
| CoreDNS | included | ✅ | Cluster DNS |
|
||||
| Local Path Provisioner | included | ✅ | Dynamic storage |
|
||||
|
||||
## 🌐 Arquitectura de Red
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
[DNS: *.fuq.tv]
|
||||
↓
|
||||
┌─────────────┴─────────────┐
|
||||
↓ ↓
|
||||
[LB-01: .221] [LB-02: .203]
|
||||
HAProxy HA HAProxy HA
|
||||
↓ ↓
|
||||
└─────────────┬─────────────┘
|
||||
↓
|
||||
[Private Network 10.100.0.0/24]
|
||||
↓
|
||||
┌───────────────────┼───────────────────┐
|
||||
↓ ↓ ↓
|
||||
[CP-01: .2] [CP-02: .3] [CP-03: .4]
|
||||
K3s + etcd K3s + etcd K3s + etcd
|
||||
↓ ↓ ↓
|
||||
─────┴───────────────────┴───────────────────┴─────
|
||||
↓ ↓ ↓
|
||||
[Worker-01: .5] [Worker-02: .6] [Worker-03: .7]
|
||||
Nginx Ingress Nginx Ingress Nginx Ingress
|
||||
↓ ↓ ↓
|
||||
[Pods] [Pods] [Pods]
|
||||
```
|
||||
|
||||
## 🔐 Accesos
|
||||
|
||||
### Kubernetes
|
||||
```bash
|
||||
# Kubeconfig
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
|
||||
# Comandos
|
||||
kubectl get nodes
|
||||
kubectl get pods -A
|
||||
kubectl get ingress -A
|
||||
```
|
||||
|
||||
### ArgoCD
|
||||
- **URL**: https://argocd.fuq.tv
|
||||
- **Usuario**: admin
|
||||
- **Password**: `LyPF4Hy0wvp52IoU`
|
||||
|
||||
### HAProxy Stats
|
||||
- **LB-01**: http://108.165.47.221:8404/stats
|
||||
- **LB-02**: http://108.165.47.203:8404/stats
|
||||
- **Credentials**: admin / aiworker2026
|
||||
|
||||
## 📋 DNS Configuración
|
||||
|
||||
**Configurado en fuq.tv:**
|
||||
```
|
||||
*.fuq.tv A 108.165.47.221
|
||||
*.fuq.tv A 108.165.47.203
|
||||
*.r.fuq.tv A 108.165.47.221
|
||||
*.r.fuq.tv A 108.165.47.203
|
||||
```
|
||||
|
||||
**Subdominios disponibles:**
|
||||
- `app.fuq.tv` - Dashboard frontend
|
||||
- `api.fuq.tv` - Backend API
|
||||
- `git.fuq.tv` - Gitea server
|
||||
- `argocd.fuq.tv` - ArgoCD UI
|
||||
- `*.r.fuq.tv` - Preview environments (task-123.r.fuq.tv)
|
||||
|
||||
## 🧪 Verificación
|
||||
|
||||
### Test Application
|
||||
```bash
|
||||
# HTTP (redirect a HTTPS)
|
||||
curl http://test.fuq.tv
|
||||
|
||||
# HTTPS con TLS
|
||||
curl https://test.fuq.tv
|
||||
|
||||
# Verificar certificado
|
||||
curl -v https://test.fuq.tv 2>&1 | grep "issuer"
|
||||
```
|
||||
|
||||
### Cluster Health
|
||||
```bash
|
||||
# Nodes
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# System pods
|
||||
kubectl get pods -A
|
||||
|
||||
# Certificates
|
||||
kubectl get certificate -A
|
||||
|
||||
# Ingresses
|
||||
kubectl get ingress -A
|
||||
```
|
||||
|
||||
## 📁 Namespaces Creados
|
||||
|
||||
| Namespace | Propósito | Resource Quota |
|
||||
|-----------------|----------------------------------------|-----------------------|
|
||||
| control-plane | Backend, API, MySQL, Redis | 8 CPU, 16 GB RAM |
|
||||
| agents | Claude Code agent pods | 20 CPU, 40 GB RAM |
|
||||
| gitea | Git server | 2 CPU, 4 GB RAM |
|
||||
| monitoring | Prometheus, Grafana (futuro) | - |
|
||||
| argocd | GitOps controller | - |
|
||||
| ingress-nginx | Ingress controller | - |
|
||||
| cert-manager | TLS management | - |
|
||||
|
||||
## 💰 Costos Mensuales
|
||||
|
||||
```
|
||||
Control Planes: 3 × $15 = $45
|
||||
Workers: 3 × $29 = $87
|
||||
Load Balancers: 2 × $8 = $16
|
||||
─────────────────────────────
|
||||
Total: $148/mes
|
||||
```
|
||||
|
||||
## 🔄 Alta Disponibilidad
|
||||
|
||||
✅ **Control Plane**: 3 nodos con etcd distribuido - tolera 1 fallo
|
||||
✅ **Workers**: 3 nodos - workload distribuido
|
||||
✅ **Load Balancers**: 2 nodos con DNS round-robin - tolera 1 fallo
|
||||
✅ **Ingress**: Corriendo en todos los workers - redundante
|
||||
✅ **Storage**: Local path provisioner en cada nodo
|
||||
|
||||
## 🚀 Próximos Pasos
|
||||
|
||||
1. **Desplegar Gitea**
|
||||
```bash
|
||||
kubectl apply -f k8s/gitea/
|
||||
```
|
||||
|
||||
2. **Desplegar Backend**
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
```
|
||||
|
||||
3. **Desplegar Frontend**
|
||||
```bash
|
||||
kubectl apply -f k8s/frontend/
|
||||
```
|
||||
|
||||
4. **Configurar ArgoCD**
|
||||
- Conectar repositorio Git
|
||||
- Crear Applications
|
||||
- Configurar auto-sync
|
||||
|
||||
## 📝 Archivos Importantes
|
||||
|
||||
- `CLUSTER-CREDENTIALS.md` - Credenciales y accesos (⚠️ NO COMMITEAR)
|
||||
- `k8s-cluster-info.md` - Info técnica del cluster
|
||||
- `scripts/install-k3s-cluster.sh` - Script instalación completa
|
||||
- `scripts/setup-load-balancers.sh` - Script configuración LBs
|
||||
- `docs/` - Documentación completa del proyecto
|
||||
|
||||
## 🔧 Mantenimiento
|
||||
|
||||
### Backup etcd
|
||||
```bash
|
||||
ssh root@108.165.47.233 "k3s etcd-snapshot save"
|
||||
```
|
||||
|
||||
### Actualizar K3s
|
||||
```bash
|
||||
# En cada nodo (empezar por workers, luego control planes)
|
||||
ssh root@<node-ip> "curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.X.X+k3s1 sh -"
|
||||
```
|
||||
|
||||
### Monitoreo
|
||||
```bash
|
||||
# Resource usage
|
||||
kubectl top nodes
|
||||
kubectl top pods -A
|
||||
|
||||
# Logs
|
||||
kubectl logs -f -n <namespace> <pod>
|
||||
|
||||
# Events
|
||||
kubectl get events -A --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
## 🎉 Estado Final
|
||||
|
||||
**Cluster Status**: ✅ Production Ready
|
||||
|
||||
**Capacidad Total**:
|
||||
- 48 vCPUs
|
||||
- 104 GB RAM
|
||||
- ~2.5 TB Storage
|
||||
- HA en todos los componentes críticos
|
||||
|
||||
**Probado**:
|
||||
- ✅ Cluster HA funcional
|
||||
- ✅ Nginx Ingress routing
|
||||
- ✅ TLS automático con Let's Encrypt
|
||||
- ✅ DNS resolution
|
||||
- ✅ Load balancing
|
||||
- ✅ Private network communication
|
||||
|
||||
**Listo para**:
|
||||
- ✅ Desplegar aplicaciones
|
||||
- ✅ GitOps con ArgoCD
|
||||
- ✅ Auto-scaling de pods
|
||||
- ✅ Certificados TLS automáticos
|
||||
- ✅ Preview environments
|
||||
|
||||
---
|
||||
|
||||
**¡Cluster AiWorker listo para producción! 🚀**
|
||||
427
DEVELOPMENT-WORKFLOW.md
Normal file
427
DEVELOPMENT-WORKFLOW.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# 🔄 Development Workflow - Cómo Trabajamos
|
||||
|
||||
Flujo completo de desarrollo usando Gitea, container registry y CI/CD automático.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview del Workflow
|
||||
|
||||
```
|
||||
Local Dev → Git Push → Gitea Actions → Docker Build → Registry Push → K8s Deploy
|
||||
```
|
||||
|
||||
**Principios**:
|
||||
- ✅ **CI/CD automático** - Cada push buildea automáticamente
|
||||
- ✅ **Registry integrado** - Imágenes en Gitea, no Docker Hub
|
||||
- ✅ **GitOps** - ArgoCD sincroniza desde Git
|
||||
- ✅ **Sin Docker local** - Builds en el cluster
|
||||
- ✅ **Preview automático** - Cada tarea tiene su environment
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Workflow Típico: Nueva Feature
|
||||
|
||||
### 1. Desarrollo Local
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
bun run dev # Hot reload activado
|
||||
|
||||
# Test localmente
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
**Conexión a servicios K8s** (solo para desarrollo):
|
||||
```bash
|
||||
# Terminal 1: MariaDB
|
||||
kubectl port-forward -n control-plane svc/mariadb 3306:3306
|
||||
|
||||
# Terminal 2: Redis
|
||||
kubectl port-forward -n control-plane svc/redis 6379:6379
|
||||
|
||||
# Ahora el backend local puede conectar a DB/Redis en K8s
|
||||
```
|
||||
|
||||
### 2. Commit y Push
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature X
|
||||
|
||||
Detailed description
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 3. CI/CD Automático (Gitea Actions)
|
||||
|
||||
**Qué pasa automáticamente**:
|
||||
1. Gitea recibe push
|
||||
2. Workflow `.gitea/workflows/build.yml` se ejecuta
|
||||
3. Runner en K8s (pod con Docker) buildea imagen
|
||||
4. Push a `git.fuq.tv/admin/<repo>:latest`
|
||||
5. Tag adicional con commit hash: `git.fuq.tv/admin/<repo>:<sha>`
|
||||
|
||||
**Monitorear**:
|
||||
```bash
|
||||
# Ver en UI
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
|
||||
# Ver logs del runner
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=100 -f
|
||||
```
|
||||
|
||||
**Tiempo típico**: 2-5 minutos (primer build más lento)
|
||||
|
||||
### 4. Verificar Imagen en Registry
|
||||
|
||||
```bash
|
||||
# Ver en UI
|
||||
open https://git.fuq.tv/admin/-/packages
|
||||
|
||||
# O via API
|
||||
curl -H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
https://git.fuq.tv/api/v1/packages/admin/container
|
||||
```
|
||||
|
||||
**Debe aparecer**: `aiworker-backend` con tags `latest` y `<commit-hash>`
|
||||
|
||||
### 5. Deploy en K8s (manual por ahora, ArgoCD después)
|
||||
|
||||
```bash
|
||||
# Aplicar manifests
|
||||
kubectl apply -f k8s/backend/
|
||||
|
||||
# O update imagen específica
|
||||
kubectl set image deployment/backend backend=git.fuq.tv/admin/aiworker-backend:latest -n control-plane
|
||||
|
||||
# Verificar rollout
|
||||
kubectl rollout status deployment/backend -n control-plane
|
||||
|
||||
# Ver logs
|
||||
kubectl logs -f deployment/backend -n control-plane
|
||||
```
|
||||
|
||||
### 6. Verificar en Producción
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl https://api.fuq.tv/api/health
|
||||
|
||||
# Test endpoints
|
||||
curl https://api.fuq.tv/api/projects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌿 Workflow: Feature Branch
|
||||
|
||||
### 1. Crear Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/nueva-funcionalidad
|
||||
```
|
||||
|
||||
### 2. Desarrollar y Commit
|
||||
|
||||
```bash
|
||||
# Hacer cambios
|
||||
bun run dev # Test local
|
||||
|
||||
git add .
|
||||
git commit -m "Implement nueva funcionalidad"
|
||||
git push origin feature/nueva-funcionalidad
|
||||
```
|
||||
|
||||
### 3. Crear Pull Request
|
||||
|
||||
**Opción A - UI**:
|
||||
1. https://git.fuq.tv/admin/aiworker-backend
|
||||
2. "New Pull Request"
|
||||
3. Base: main ← Compare: feature/nueva-funcionalidad
|
||||
|
||||
**Opción B - API** (desde backend):
|
||||
```typescript
|
||||
const pr = await giteaClient.createPullRequest('admin', 'aiworker-backend', {
|
||||
title: 'Nueva funcionalidad',
|
||||
body: 'Descripción detallada',
|
||||
head: 'feature/nueva-funcionalidad',
|
||||
base: 'main'
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Review y Merge
|
||||
|
||||
**Manual** (UI):
|
||||
- Review code
|
||||
- Click "Merge Pull Request"
|
||||
|
||||
**Automático** (via backend):
|
||||
```typescript
|
||||
await giteaClient.mergePullRequest('admin', 'aiworker-backend', prNumber, 'squash')
|
||||
```
|
||||
|
||||
### 5. Deploy Automático post-Merge
|
||||
|
||||
Una vez mergeado a `main`, el CI/CD rebuildeará automáticamente.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Workflow: Deploy de Preview
|
||||
|
||||
Para cada tarea, se crea un preview environment aislado.
|
||||
|
||||
### Proceso Completo
|
||||
|
||||
```typescript
|
||||
// 1. Agente completa tarea
|
||||
// 2. Crea branch y PR (via MCP)
|
||||
|
||||
// 3. Backend crea preview deployment
|
||||
const previewNamespace = `preview-task-${taskId.slice(0, 8)}`
|
||||
|
||||
// Crear namespace en K8s
|
||||
await k8sClient.createNamespace(previewNamespace)
|
||||
|
||||
// Deploy app
|
||||
await k8sClient.createDeployment({
|
||||
namespace: previewNamespace,
|
||||
name: 'app',
|
||||
image: `git.fuq.tv/admin/aiworker-backend:${branchName}`,
|
||||
// ... config
|
||||
})
|
||||
|
||||
// Crear ingress
|
||||
await k8sClient.createIngress({
|
||||
namespace: previewNamespace,
|
||||
host: `task-${taskId.slice(0, 8)}.r.fuq.tv`,
|
||||
// ... config
|
||||
})
|
||||
|
||||
// 4. Usuario accede a:
|
||||
// https://task-abc12345.r.fuq.tv
|
||||
|
||||
// 5. Si aprueba → merge a staging
|
||||
// 6. Cleanup automático después de 7 días
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Workflow: Multi-Repo
|
||||
|
||||
Eventualmente tendremos múltiples repos:
|
||||
|
||||
```
|
||||
/admin/aiworker-backend → Backend API
|
||||
/admin/aiworker-frontend → Dashboard React
|
||||
/admin/aiworker-agents → Agent Docker image
|
||||
/admin/aiworker-gitops → ArgoCD manifests
|
||||
/aiworker/<project-name> → Proyectos de usuarios
|
||||
```
|
||||
|
||||
**Cada repo**:
|
||||
- Tiene su propio `.gitea/workflows/build.yml`
|
||||
- Buildea a `git.fuq.tv/<owner>/<repo>:<tag>`
|
||||
- Deploy independiente
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Autenticación en Diferentes Contextos
|
||||
|
||||
### 1. Git Clone/Push (HTTPS)
|
||||
|
||||
```bash
|
||||
# Con token en URL (inseguro pero funciona)
|
||||
git clone https://admin:159a5de2a16d15f33e388b55b1276e431dbca3f3@git.fuq.tv/admin/myrepo.git
|
||||
|
||||
# O configurar credential helper
|
||||
git config --global credential.helper store
|
||||
git clone https://git.fuq.tv/admin/myrepo.git
|
||||
# Primera vez pedirá user/password, luego lo guarda
|
||||
```
|
||||
|
||||
### 2. Docker Registry
|
||||
|
||||
```bash
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
```
|
||||
|
||||
### 3. Kubernetes Pulls
|
||||
|
||||
**Secret ya creado**:
|
||||
```bash
|
||||
# En control-plane y agents
|
||||
kubectl get secret gitea-registry -n control-plane
|
||||
kubectl get secret gitea-registry -n agents
|
||||
```
|
||||
|
||||
**Usar en deployment**:
|
||||
```yaml
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
```
|
||||
|
||||
### 4. API Calls
|
||||
|
||||
**Header**:
|
||||
```bash
|
||||
curl -H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
https://git.fuq.tv/api/v1/user/repos
|
||||
```
|
||||
|
||||
### 5. Webhooks
|
||||
|
||||
**Secret** (para verificar requests):
|
||||
```bash
|
||||
# Configurar en webhook
|
||||
"secret": "webhook-secret-aiworker-2026"
|
||||
|
||||
# Verificar en backend usando HMAC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Build Strategies
|
||||
|
||||
### Estrategia Actual: Gitea Actions
|
||||
|
||||
**Ventajas**:
|
||||
- ✅ Sin Docker local necesario
|
||||
- ✅ Build en cluster (más recursos)
|
||||
- ✅ Histórico de builds en UI
|
||||
- ✅ Cacheo de layers
|
||||
|
||||
**Cómo funciona**:
|
||||
```
|
||||
Push → Gitea Actions → Runner Pod (DinD) → Docker build → Push to registry
|
||||
```
|
||||
|
||||
**Configuración**:
|
||||
- Runner: Pod en namespace `gitea-actions`
|
||||
- Docker-in-Docker (DinD) para builds
|
||||
- Volumenes compartidos para cache
|
||||
|
||||
### Alternativa Futura: ArgoCD Image Updater
|
||||
|
||||
Cuando esté configurado:
|
||||
```
|
||||
Push → Build → Registry → ArgoCD detecta → Auto-update K8s → Deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Checklist de Feature Completa
|
||||
|
||||
- [ ] Desarrollo local con `bun run dev`
|
||||
- [ ] Test manual de endpoints
|
||||
- [ ] Commit con mensaje descriptivo
|
||||
- [ ] Push a Gitea
|
||||
- [ ] Verificar build en Actions (verde ✅)
|
||||
- [ ] Verificar imagen en registry
|
||||
- [ ] Deploy en K8s (manual o ArgoCD)
|
||||
- [ ] Verificar pods running
|
||||
- [ ] Test en producción (`api.fuq.tv`)
|
||||
- [ ] Verificar logs sin errores
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting del Workflow
|
||||
|
||||
### Build Falla en Actions
|
||||
|
||||
```bash
|
||||
# Ver logs del job
|
||||
https://git.fuq.tv/admin/aiworker-backend/actions/runs/<run-id>
|
||||
|
||||
# Ver runner logs
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=200
|
||||
|
||||
# Problemas comunes:
|
||||
# - Dockerfile error → Fix Dockerfile
|
||||
# - Dependencias faltantes → Update package.json
|
||||
# - Registry auth → Verificar REGISTRY_TOKEN secret
|
||||
```
|
||||
|
||||
**Fix**: Corregir error, commit, push de nuevo.
|
||||
|
||||
### Build OK pero K8s no Pulla Imagen
|
||||
|
||||
```bash
|
||||
# Verificar secret
|
||||
kubectl get secret gitea-registry -n control-plane
|
||||
|
||||
# Verificar imagePullSecrets en deployment
|
||||
kubectl get deployment backend -n control-plane -o yaml | grep imagePullSecrets
|
||||
|
||||
# Ver eventos
|
||||
kubectl describe pod <pod-name> -n control-plane | grep -i pull
|
||||
```
|
||||
|
||||
**Fix**: Recrear secret o agregar `imagePullSecrets` al deployment.
|
||||
|
||||
### Imagen en Registry pero versión vieja en K8s
|
||||
|
||||
```bash
|
||||
# Force pull nueva imagen
|
||||
kubectl rollout restart deployment/backend -n control-plane
|
||||
|
||||
# O delete pod para recrear
|
||||
kubectl delete pod <pod-name> -n control-plane
|
||||
```
|
||||
|
||||
**Nota**: Si usas tag `latest`, K8s cachea. Mejor usar tags específicos (`v1.0.0`, `main-abc1234`).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring del Workflow
|
||||
|
||||
### CI/CD Health
|
||||
|
||||
```bash
|
||||
# Runner status
|
||||
kubectl get pods -n gitea-actions
|
||||
|
||||
# Workflows recientes
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
|
||||
# Registry usage
|
||||
open https://git.fuq.tv/admin/-/packages
|
||||
```
|
||||
|
||||
### Deployments
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
kubectl get pods -n control-plane
|
||||
kubectl logs -f deployment/backend -n control-plane
|
||||
|
||||
# Frontend (cuando exista)
|
||||
kubectl get pods -n control-plane
|
||||
kubectl logs -f deployment/frontend -n control-plane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Best Practices
|
||||
|
||||
1. **Commits frecuentes** - Push pequeños, builds rápidos
|
||||
2. **Tags semánticos** - `v1.0.0`, `v1.1.0` para releases
|
||||
3. **Branch strategy** - `main` (prod), `develop` (staging), `feature/*` (features)
|
||||
4. **PR reviews** - Siempre review antes de merge
|
||||
5. **Registry cleanup** - Eliminar imágenes viejas periódicamente
|
||||
6. **Logs** - Siempre verificar logs post-deploy
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referencias
|
||||
|
||||
- **Guía Gitea completa**: `GITEA-GUIDE.md`
|
||||
- **Container Registry**: `docs/CONTAINER-REGISTRY.md` (puedes eliminar después)
|
||||
- **API Gitea**: `docs/02-backend/gitea-integration.md` (código de ejemplo)
|
||||
- **CI/CD**: `docs/06-deployment/ci-cd.md`
|
||||
|
||||
---
|
||||
|
||||
**✅ Con este workflow, el desarrollo es fluido: código local → push → build automático → deploy → verificar.**
|
||||
830
GITEA-GUIDE.md
Normal file
830
GITEA-GUIDE.md
Normal file
@@ -0,0 +1,830 @@
|
||||
# 📚 Gitea - Guía Completa de Uso
|
||||
|
||||
Toda la información de Gitea en un solo lugar: autenticación, API, registry, webhooks, y CI/CD.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Acceso Web
|
||||
|
||||
**URL**: https://git.fuq.tv
|
||||
|
||||
**Credenciales Admin**:
|
||||
- Usuario: `admin`
|
||||
- Password: `admin123`
|
||||
|
||||
**Primera vez**: Ya configurado, listo para usar.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Autenticación y Tokens
|
||||
|
||||
### Tokens Existentes
|
||||
|
||||
| Nombre | Token | Scopes | Uso |
|
||||
|--------|-------|--------|-----|
|
||||
| full-access | `159a5de2a16d15f33e388b55b1276e431dbca3f3` | all | API completa |
|
||||
| docker-registry | `7401126cfb56ab2aebba17755bdc968c20768c27` | write:package, read:package | Container registry |
|
||||
|
||||
### Crear Nuevo Token (CLI)
|
||||
|
||||
```bash
|
||||
# Desde el pod de Gitea
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea admin user generate-access-token \
|
||||
--username admin \
|
||||
--scopes all \
|
||||
--token-name my-token \
|
||||
--raw"
|
||||
```
|
||||
|
||||
### Crear Token (Web UI)
|
||||
|
||||
1. Login en https://git.fuq.tv
|
||||
2. Perfil → Settings → Applications
|
||||
3. Generate New Token
|
||||
4. Seleccionar scopes necesarios
|
||||
5. Copiar token (solo se muestra una vez)
|
||||
|
||||
**Scopes importantes**:
|
||||
- `write:repository` - Crear repos, push
|
||||
- `write:package` - Push imágenes Docker
|
||||
- `read:package` - Pull imágenes Docker
|
||||
- `write:issue` - Gestionar issues
|
||||
- `write:user` - Operaciones de usuario
|
||||
- `all` - Acceso completo
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Container Registry
|
||||
|
||||
### Configuración
|
||||
|
||||
**Registry URL**: `git.fuq.tv`
|
||||
**Formato de imágenes**: `git.fuq.tv/<owner>/<package>:<tag>`
|
||||
|
||||
### Login Docker
|
||||
|
||||
```bash
|
||||
# Con token de registry
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
|
||||
# O de forma segura
|
||||
echo "7401126cfb56ab2aebba17755bdc968c20768c27" | docker login git.fuq.tv -u admin --password-stdin
|
||||
```
|
||||
|
||||
### Build y Push
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t git.fuq.tv/admin/aiworker-backend:v1.0.0 .
|
||||
|
||||
# Push
|
||||
docker push git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
|
||||
# Tag como latest
|
||||
docker tag git.fuq.tv/admin/aiworker-backend:v1.0.0 git.fuq.tv/admin/aiworker-backend:latest
|
||||
docker push git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
### Pull
|
||||
|
||||
```bash
|
||||
docker pull git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
### Ver Imágenes (UI)
|
||||
|
||||
1. https://git.fuq.tv
|
||||
2. Perfil → Packages
|
||||
3. O: https://git.fuq.tv/admin/-/packages
|
||||
|
||||
### Kubernetes Pull Secret
|
||||
|
||||
**Ya creado** en namespaces `control-plane` y `agents`:
|
||||
|
||||
```bash
|
||||
# Verificar
|
||||
kubectl get secret gitea-registry -n control-plane
|
||||
|
||||
# Crear en nuevo namespace
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--docker-server=git.fuq.tv \
|
||||
--docker-username=admin \
|
||||
--docker-password=7401126cfb56ab2aebba17755bdc968c20768c27 \
|
||||
--docker-email=hector@teamsuqad.io \
|
||||
-n <namespace>
|
||||
```
|
||||
|
||||
**Uso en deployment**:
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: app
|
||||
image: git.fuq.tv/admin/myapp:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API de Gitea
|
||||
|
||||
**Base URL**: `https://git.fuq.tv/api/v1`
|
||||
**Documentación**: https://git.fuq.tv/api/swagger
|
||||
|
||||
### Autenticación API
|
||||
|
||||
**Header**:
|
||||
```
|
||||
Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3
|
||||
```
|
||||
|
||||
### Ejemplos de Uso
|
||||
|
||||
#### Crear Repositorio
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/user/repos" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-new-repo",
|
||||
"description": "My description",
|
||||
"private": false,
|
||||
"auto_init": true
|
||||
}'
|
||||
```
|
||||
|
||||
#### Listar Repositorios
|
||||
|
||||
```bash
|
||||
curl "https://git.fuq.tv/api/v1/user/repos" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3"
|
||||
```
|
||||
|
||||
#### Crear Branch
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/repos/admin/myrepo/branches" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"new_branch_name": "feature-x",
|
||||
"old_branch_name": "main"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Crear Pull Request
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/repos/admin/myrepo/pulls" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "My PR",
|
||||
"body": "Description",
|
||||
"head": "feature-x",
|
||||
"base": "main"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Merge Pull Request
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/repos/admin/myrepo/pulls/1/merge" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"Do": "merge",
|
||||
"merge_when_checks_succeed": false
|
||||
}'
|
||||
```
|
||||
|
||||
### Cliente TypeScript (Backend)
|
||||
|
||||
**Ubicación**: `backend/src/services/gitea/client.ts`
|
||||
|
||||
**Uso**:
|
||||
```typescript
|
||||
import { GiteaClient } from './services/gitea/client'
|
||||
|
||||
const gitea = new GiteaClient({
|
||||
url: 'https://git.fuq.tv',
|
||||
token: process.env.GITEA_TOKEN,
|
||||
owner: 'admin'
|
||||
})
|
||||
|
||||
// Crear repo
|
||||
const repo = await gitea.createRepo('my-app', {
|
||||
description: 'My application',
|
||||
private: false,
|
||||
autoInit: true
|
||||
})
|
||||
|
||||
// Crear branch
|
||||
await gitea.createBranch('admin', 'my-app', 'feature-auth', 'main')
|
||||
|
||||
// Crear PR
|
||||
const pr = await gitea.createPullRequest('admin', 'my-app', {
|
||||
title: 'Add authentication',
|
||||
body: 'Implements JWT auth',
|
||||
head: 'feature-auth',
|
||||
base: 'main'
|
||||
})
|
||||
```
|
||||
|
||||
**Referencia completa**: `docs/02-backend/gitea-integration.md`
|
||||
|
||||
---
|
||||
|
||||
## 🪝 Webhooks
|
||||
|
||||
### Configurar Webhook (API)
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/repos/admin/myrepo/hooks" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "gitea",
|
||||
"config": {
|
||||
"url": "https://api.fuq.tv/api/webhooks/gitea",
|
||||
"content_type": "json",
|
||||
"secret": "webhook-secret-123"
|
||||
},
|
||||
"events": ["push", "pull_request"],
|
||||
"active": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Handler Backend
|
||||
|
||||
```typescript
|
||||
// backend/src/api/routes/webhooks.ts
|
||||
export async function handleGiteaWebhook(req: Request) {
|
||||
const signature = req.headers.get('x-gitea-signature')
|
||||
const event = req.headers.get('x-gitea-event')
|
||||
const payload = await req.json()
|
||||
|
||||
// Verify signature
|
||||
// ... verification logic
|
||||
|
||||
switch (event) {
|
||||
case 'push':
|
||||
await handlePushEvent(payload)
|
||||
break
|
||||
case 'pull_request':
|
||||
await handlePREvent(payload)
|
||||
break
|
||||
}
|
||||
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
**Eventos importantes**:
|
||||
- `push` - Nuevo commit
|
||||
- `pull_request` - PR creado/actualizado
|
||||
- `pull_request_closed` - PR cerrado/mergeado
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Gitea Actions (CI/CD)
|
||||
|
||||
### Workflow File
|
||||
|
||||
**Ubicación**: `.gitea/workflows/<name>.yml`
|
||||
|
||||
**Ejemplo** (Build Docker image):
|
||||
```yaml
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t git.fuq.tv/admin/myapp:latest .
|
||||
|
||||
- name: Login to registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fuq.tv -u admin --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: docker push git.fuq.tv/admin/myapp:latest
|
||||
```
|
||||
|
||||
### Secrets en Repositorio
|
||||
|
||||
**Crear secret** (API):
|
||||
```bash
|
||||
curl -X PUT "https://git.fuq.tv/api/v1/repos/admin/myrepo/actions/secrets/MY_SECRET" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"data":"my-secret-value"}'
|
||||
```
|
||||
|
||||
**Crear secret** (Web UI):
|
||||
1. Repo → Settings → Secrets → Actions
|
||||
2. Add Secret
|
||||
3. Name: `REGISTRY_TOKEN`
|
||||
4. Value: `7401126cfb56ab2aebba17755bdc968c20768c27`
|
||||
|
||||
**Uso en workflow**:
|
||||
```yaml
|
||||
- name: Use secret
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}"
|
||||
```
|
||||
|
||||
### Ver Workflows
|
||||
|
||||
**UI**: https://git.fuq.tv/admin/<repo>/actions
|
||||
|
||||
**Runner logs** (K8s):
|
||||
```bash
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=100
|
||||
```
|
||||
|
||||
### Runner Status
|
||||
|
||||
**Verificar runner activo**:
|
||||
```bash
|
||||
kubectl get pods -n gitea-actions
|
||||
|
||||
# Logs
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner
|
||||
|
||||
# Restart si es necesario
|
||||
kubectl rollout restart deployment/gitea-runner -n gitea-actions
|
||||
```
|
||||
|
||||
**Ver en UI**: https://git.fuq.tv/admin/runners
|
||||
|
||||
---
|
||||
|
||||
## 👥 Gestión de Usuarios
|
||||
|
||||
### Crear Usuario (CLI)
|
||||
|
||||
```bash
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea admin user create \
|
||||
--username myuser \
|
||||
--password mypass123 \
|
||||
--email user@example.com \
|
||||
--admin"
|
||||
```
|
||||
|
||||
### Cambiar Password
|
||||
|
||||
```bash
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea admin user change-password \
|
||||
--username admin \
|
||||
--password newpassword"
|
||||
```
|
||||
|
||||
### Listar Usuarios
|
||||
|
||||
```bash
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea admin user list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 Organizaciones
|
||||
|
||||
### Crear Organización (API)
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/orgs" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "aiworker",
|
||||
"full_name": "AiWorker Organization",
|
||||
"description": "AiWorker platform repos"
|
||||
}'
|
||||
```
|
||||
|
||||
### Crear Repo en Organización
|
||||
|
||||
```bash
|
||||
curl -X POST "https://git.fuq.tv/api/v1/org/aiworker/repos" \
|
||||
-H "Authorization: token 159a5de2a16d15f33e388b55b1276e431dbca3f3" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-project",
|
||||
"auto_init": true
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuración de Gitea
|
||||
|
||||
### Ver Configuración Actual
|
||||
|
||||
```bash
|
||||
kubectl exec -n gitea gitea-0 -- cat /data/gitea/conf/app.ini
|
||||
```
|
||||
|
||||
### Configuración Importante
|
||||
|
||||
**Database**:
|
||||
```ini
|
||||
[database]
|
||||
DB_TYPE = mysql
|
||||
HOST = mariadb.control-plane.svc.cluster.local:3306
|
||||
NAME = gitea
|
||||
USER = root
|
||||
PASSWD = AiWorker2026_RootPass!
|
||||
```
|
||||
|
||||
**Server**:
|
||||
```ini
|
||||
[server]
|
||||
DOMAIN = git.fuq.tv
|
||||
ROOT_URL = https://git.fuq.tv/
|
||||
HTTP_PORT = 3000
|
||||
SSH_PORT = 2222
|
||||
```
|
||||
|
||||
**Packages** (Container Registry):
|
||||
```ini
|
||||
[packages]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
### Restart Gitea
|
||||
|
||||
```bash
|
||||
kubectl delete pod gitea-0 -n gitea
|
||||
# Esperar a que se recree automáticamente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SSH Access a Repos
|
||||
|
||||
### SSH URL Format
|
||||
|
||||
```
|
||||
ssh://git@git.fuq.tv:2222/<owner>/<repo>.git
|
||||
```
|
||||
|
||||
### SSH Clone
|
||||
|
||||
```bash
|
||||
# Nota: Puerto 2222, no 22
|
||||
git clone ssh://git@git.fuq.tv:2222/admin/aiworker-backend.git
|
||||
```
|
||||
|
||||
### Agregar SSH Key (UI)
|
||||
|
||||
1. Login → Settings → SSH/GPG Keys
|
||||
2. Add Key
|
||||
3. Paste public key
|
||||
|
||||
### Agregar SSH Key (CLI)
|
||||
|
||||
```bash
|
||||
# Generar key
|
||||
ssh-keygen -t ed25519 -C "agent@aiworker.dev" -f ~/.ssh/gitea_key
|
||||
|
||||
# Agregar a Gitea (manual en UI o via API)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Gitea Actions - Guía Completa
|
||||
|
||||
### Runner en K8s
|
||||
|
||||
**Namespace**: `gitea-actions`
|
||||
**Pod**: `gitea-runner-*`
|
||||
|
||||
**Status**:
|
||||
```bash
|
||||
kubectl get pods -n gitea-actions
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner
|
||||
```
|
||||
|
||||
### Workflow Syntax
|
||||
|
||||
Compatible con GitHub Actions. Ubicación: `.gitea/workflows/*.yml`
|
||||
|
||||
**Triggers**:
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Diario
|
||||
```
|
||||
|
||||
**Jobs**:
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run script
|
||||
run: echo "Hello"
|
||||
```
|
||||
|
||||
### Actions Disponibles
|
||||
|
||||
Compatible con GitHub Actions marketplace:
|
||||
- `actions/checkout@v4`
|
||||
- `docker/build-push-action@v5`
|
||||
- `docker/login-action@v3`
|
||||
- Y muchas más
|
||||
|
||||
### Secrets en Actions
|
||||
|
||||
**Acceso**:
|
||||
```yaml
|
||||
- name: Use secret
|
||||
run: echo "${{ secrets.MY_SECRET }}"
|
||||
env:
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
```
|
||||
|
||||
**Secrets necesarios para builds**:
|
||||
- `REGISTRY_TOKEN` - Ya configurado en `aiworker-backend`
|
||||
|
||||
### Variables de Entorno Automáticas
|
||||
|
||||
```yaml
|
||||
${{ github.repository }} # admin/aiworker-backend
|
||||
${{ github.sha }} # Commit hash
|
||||
${{ github.ref }} # refs/heads/main
|
||||
${{ github.actor }} # Usuario que hizo push
|
||||
${{ github.event_name }} # push, pull_request, etc.
|
||||
```
|
||||
|
||||
### Debug de Workflows
|
||||
|
||||
```bash
|
||||
# Ver en UI
|
||||
https://git.fuq.tv/admin/<repo>/actions/runs/<run-id>
|
||||
|
||||
# Ver runner logs
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=200
|
||||
|
||||
# Ver Docker daemon logs
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c dind --tail=50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Operaciones Comunes desde Backend
|
||||
|
||||
### Inicializar Proyecto Nuevo
|
||||
|
||||
```typescript
|
||||
// 1. Crear repo en Gitea
|
||||
const repo = await giteaClient.createRepo('my-project', {
|
||||
description: 'Project description',
|
||||
autoInit: true
|
||||
})
|
||||
|
||||
// 2. Setup webhooks
|
||||
await giteaClient.createWebhook('admin', 'my-project', {
|
||||
url: 'https://api.fuq.tv/api/webhooks/gitea',
|
||||
events: ['push', 'pull_request']
|
||||
})
|
||||
|
||||
// 3. Guardar en DB
|
||||
await db.insert(projects).values({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'my-project',
|
||||
giteaRepoUrl: repo.clone_url,
|
||||
giteaOwner: 'admin',
|
||||
giteaRepoName: 'my-project'
|
||||
})
|
||||
```
|
||||
|
||||
### Workflow de Tarea Completa
|
||||
|
||||
```typescript
|
||||
// 1. Obtener tarea
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.state, 'backlog')
|
||||
})
|
||||
|
||||
// 2. Crear branch para tarea
|
||||
await giteaClient.createBranch(
|
||||
project.giteaOwner,
|
||||
project.giteaRepoName,
|
||||
`task-${task.id}`,
|
||||
'main'
|
||||
)
|
||||
|
||||
// 3. Agente trabaja (commits via git)...
|
||||
|
||||
// 4. Crear PR
|
||||
const pr = await giteaClient.createPullRequest(
|
||||
project.giteaOwner,
|
||||
project.giteaRepoName,
|
||||
{
|
||||
title: task.title,
|
||||
body: task.description,
|
||||
head: `task-${task.id}`,
|
||||
base: 'main'
|
||||
}
|
||||
)
|
||||
|
||||
// 5. Guardar PR URL
|
||||
await db.update(tasks)
|
||||
.set({ prUrl: pr.html_url })
|
||||
.where(eq(tasks.id, task.id))
|
||||
```
|
||||
|
||||
### Merge Automático
|
||||
|
||||
```typescript
|
||||
await giteaClient.mergePullRequest(
|
||||
'admin',
|
||||
'my-project',
|
||||
prNumber,
|
||||
'squash' // o 'merge', 'rebase'
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Estructura de Archivos en Gitea
|
||||
|
||||
### En el Pod
|
||||
|
||||
```bash
|
||||
# Datos de Gitea
|
||||
/data/gitea/
|
||||
├── conf/app.ini # Configuración
|
||||
├── log/ # Logs
|
||||
├── git/repositories/ # Repos Git
|
||||
├── git/lfs/ # Git LFS
|
||||
├── packages/ # Container registry
|
||||
└── attachments/ # Uploads
|
||||
|
||||
# Ejecutable
|
||||
/usr/local/bin/gitea
|
||||
```
|
||||
|
||||
### Comandos CLI de Gitea
|
||||
|
||||
```bash
|
||||
# Todos los comandos deben ejecutarse como usuario 'git'
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea <command>"
|
||||
|
||||
# Ejemplos:
|
||||
gitea admin user list
|
||||
gitea admin user create --username x --password y --email z
|
||||
gitea admin regenerate keys
|
||||
gitea dump # Backup completo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring y Maintenance
|
||||
|
||||
### Logs de Gitea
|
||||
|
||||
```bash
|
||||
# Logs del contenedor
|
||||
kubectl logs -n gitea gitea-0 --tail=100 -f
|
||||
|
||||
# Logs de aplicación (dentro del pod)
|
||||
kubectl exec -n gitea gitea-0 -- tail -f /data/gitea/log/gitea.log
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# HTTP health
|
||||
curl https://git.fuq.tv/api/healthz
|
||||
|
||||
# Database connection
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea doctor check --run"
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup completo (crea archivo .zip)
|
||||
kubectl exec -n gitea gitea-0 -- su git -c "gitea dump -f /tmp/gitea-backup.zip"
|
||||
|
||||
# Copiar backup fuera
|
||||
kubectl cp gitea/gitea-0:/tmp/gitea-backup.zip ./gitea-backup-$(date +%Y%m%d).zip
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
**PVC**: 50Gi con Longhorn (3 réplicas HA)
|
||||
|
||||
```bash
|
||||
# Ver PVC
|
||||
kubectl get pvc -n gitea
|
||||
|
||||
# Ver volumen Longhorn
|
||||
kubectl get volumes.longhorn.io -n longhorn-system | grep gitea
|
||||
|
||||
# Ver réplicas
|
||||
kubectl get replicas.longhorn.io -n longhorn-system | grep $(kubectl get pvc -n gitea gitea-data -o jsonpath='{.spec.volumeName}')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Actions
|
||||
|
||||
### Crear Proyecto Completo (Script)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
PROJECT_NAME="my-project"
|
||||
TOKEN="159a5de2a16d15f33e388b55b1276e431dbca3f3"
|
||||
|
||||
# 1. Crear repo
|
||||
curl -X POST "https://git.fuq.tv/api/v1/user/repos" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$PROJECT_NAME\",\"auto_init\":true}"
|
||||
|
||||
# 2. Crear webhook
|
||||
curl -X POST "https://git.fuq.tv/api/v1/repos/admin/$PROJECT_NAME/hooks" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"gitea","config":{"url":"https://api.fuq.tv/webhooks/gitea","content_type":"json"},"events":["push","pull_request"],"active":true}'
|
||||
|
||||
# 3. Clone
|
||||
git clone https://git.fuq.tv/admin/$PROJECT_NAME.git
|
||||
cd $PROJECT_NAME
|
||||
|
||||
# 4. Crear workflow
|
||||
mkdir -p .gitea/workflows
|
||||
cat > .gitea/workflows/build.yml << 'EOF'
|
||||
name: Build
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "Build steps here"
|
||||
EOF
|
||||
|
||||
git add .gitea && git commit -m "Add CI/CD" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Resumen de URLs y Credenciales
|
||||
|
||||
### Web UI
|
||||
- **Gitea**: https://git.fuq.tv
|
||||
- **Login**: admin / admin123
|
||||
- **Settings**: https://git.fuq.tv/user/settings
|
||||
- **Packages**: https://git.fuq.tv/admin/-/packages
|
||||
- **Actions**: https://git.fuq.tv/admin/<repo>/actions
|
||||
|
||||
### API
|
||||
- **Base**: https://git.fuq.tv/api/v1
|
||||
- **Swagger**: https://git.fuq.tv/api/swagger
|
||||
- **Token Full**: `159a5de2a16d15f33e388b55b1276e431dbca3f3`
|
||||
|
||||
### Registry
|
||||
- **URL**: git.fuq.tv
|
||||
- **Token**: `7401126cfb56ab2aebba17755bdc968c20768c27`
|
||||
- **Format**: `git.fuq.tv/<owner>/<image>:<tag>`
|
||||
|
||||
### SSH
|
||||
- **URL**: `ssh://git@git.fuq.tv:2222/<owner>/<repo>.git`
|
||||
- **Port**: 2222 (no 22)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Tokens**: Full access token para API, registry token solo para Docker
|
||||
2. **Branches**: Siempre desde `main` a menos que especifique otro
|
||||
3. **PRs**: Usar `squash` merge para historial limpio
|
||||
4. **Webhooks**: Verificar que `ALLOWED_HOST_LIST` incluye tu dominio
|
||||
5. **Actions**: Primer build tarda más (descarga imágenes)
|
||||
6. **Registry**: Las imágenes se guardan en Longhorn HA storage
|
||||
|
||||
---
|
||||
|
||||
**📖 Referencia completa**: `docs/02-backend/gitea-integration.md` y `docs/CONTAINER-REGISTRY.md`
|
||||
|
||||
**🔧 Para implementar en backend**: Ver código de ejemplo en `docs/02-backend/gitea-integration.md` líneas 5-400
|
||||
429
NEXT-SESSION.md
Normal file
429
NEXT-SESSION.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# 📋 Próxima Sesión - Checklist Ejecutable
|
||||
|
||||
**Objetivo**: Completar Backend API y MCP Server básico
|
||||
**Tiempo estimado**: 2-3 horas
|
||||
|
||||
---
|
||||
|
||||
## ✅ PRE-REQUISITOS (Verificar antes de empezar)
|
||||
|
||||
```bash
|
||||
# 1. Cluster funcionando
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes
|
||||
# Debe mostrar 6 nodos Ready
|
||||
|
||||
# 2. Servicios corriendo
|
||||
kubectl get pods -n control-plane
|
||||
# mariadb-0: Running
|
||||
# redis-xxx: Running
|
||||
|
||||
kubectl get pods -n gitea
|
||||
# gitea-0: Running
|
||||
|
||||
# 3. Backend local
|
||||
cd backend
|
||||
bun --version
|
||||
# 1.3.6
|
||||
|
||||
# 4. Gitea accesible
|
||||
curl -I https://git.fuq.tv
|
||||
# HTTP/2 200
|
||||
```
|
||||
|
||||
**Si algo falla, consulta**: `CLUSTER-READY.md` y `TROUBLESHOOTING.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 1: Verificar CI/CD (15 min)
|
||||
|
||||
### 1.1 Revisar último build
|
||||
```bash
|
||||
# Ver en Gitea Actions
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
```
|
||||
|
||||
**Opciones**:
|
||||
- ✅ **Si build exitoso**: Continuar a paso 2
|
||||
- ❌ **Si build fallido**: Ver logs, corregir, push de nuevo
|
||||
|
||||
### 1.2 Verificar imagen en registry
|
||||
```bash
|
||||
# Vía UI
|
||||
open https://git.fuq.tv/admin/-/packages
|
||||
|
||||
# Vía API
|
||||
curl https://git.fuq.tv/api/v1/packages/admin/container?type=container
|
||||
```
|
||||
|
||||
**Debe existir**: `aiworker-backend` con tag `latest`
|
||||
|
||||
### 1.3 Si no hay imagen, build manual
|
||||
```bash
|
||||
# Desde un nodo del cluster (si Docker local no funciona)
|
||||
ssh root@108.165.47.225 # worker-01
|
||||
|
||||
cd /tmp
|
||||
git clone https://git.fuq.tv/admin/aiworker-backend.git
|
||||
cd aiworker-backend
|
||||
|
||||
docker build -t git.fuq.tv/admin/aiworker-backend:latest .
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
docker push git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 2: Implementar API Routes (45 min)
|
||||
|
||||
### 2.1 Crear estructura de routes
|
||||
```bash
|
||||
cd backend/src/api
|
||||
mkdir routes
|
||||
|
||||
# Archivos a crear:
|
||||
# - routes/projects.ts
|
||||
# - routes/tasks.ts
|
||||
# - routes/agents.ts
|
||||
# - routes/index.ts
|
||||
```
|
||||
|
||||
### 2.2 Implementar Projects API
|
||||
|
||||
**Archivo**: `src/api/routes/projects.ts`
|
||||
|
||||
**Endpoints necesarios**:
|
||||
```typescript
|
||||
GET /api/projects // List all
|
||||
GET /api/projects/:id // Get one
|
||||
POST /api/projects // Create
|
||||
PATCH /api/projects/:id // Update
|
||||
DELETE /api/projects/:id // Delete
|
||||
```
|
||||
|
||||
**Referencia**: `docs/02-backend/api-endpoints.md` (líneas 15-80)
|
||||
|
||||
**Conectar con Bun.serve()**:
|
||||
```typescript
|
||||
// En src/index.ts
|
||||
import { handleProjectRoutes } from './api/routes/projects'
|
||||
|
||||
if (url.pathname.startsWith('/api/projects')) {
|
||||
return handleProjectRoutes(req, url)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Implementar Tasks API
|
||||
|
||||
**Archivo**: `src/api/routes/tasks.ts`
|
||||
|
||||
**Endpoints principales**:
|
||||
```typescript
|
||||
GET /api/tasks // List with filters
|
||||
GET /api/tasks/:id // Get details
|
||||
POST /api/tasks // Create
|
||||
PATCH /api/tasks/:id // Update
|
||||
POST /api/tasks/:id/respond // Respond to question
|
||||
```
|
||||
|
||||
### 2.4 Probar APIs localmente
|
||||
```bash
|
||||
# Terminal 1: Port-forward MariaDB
|
||||
kubectl port-forward -n control-plane svc/mariadb 3306:3306 &
|
||||
|
||||
# Terminal 2: Port-forward Redis
|
||||
kubectl port-forward -n control-plane svc/redis 6379:6379 &
|
||||
|
||||
# Terminal 3: Run backend
|
||||
cd backend
|
||||
bun run dev
|
||||
|
||||
# Terminal 4: Test
|
||||
curl http://localhost:3000/api/health
|
||||
curl http://localhost:3000/api/projects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 3: MCP Server Básico (60 min)
|
||||
|
||||
### 3.1 Crear estructura MCP
|
||||
```bash
|
||||
mkdir -p src/services/mcp
|
||||
# Archivos:
|
||||
# - services/mcp/server.ts
|
||||
# - services/mcp/tools.ts
|
||||
# - services/mcp/handlers.ts
|
||||
```
|
||||
|
||||
### 3.2 Implementar herramientas básicas
|
||||
|
||||
**Herramientas mínimas para MVP**:
|
||||
1. `get_next_task` - Obtener siguiente tarea
|
||||
2. `update_task_status` - Actualizar estado
|
||||
3. `create_branch` - Crear rama en Gitea
|
||||
4. `create_pull_request` - Crear PR
|
||||
|
||||
**Referencia**: `docs/05-agents/mcp-tools.md`
|
||||
|
||||
**Template básico**:
|
||||
```typescript
|
||||
// src/services/mcp/server.ts
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
|
||||
export class MCPServer {
|
||||
private server: Server
|
||||
|
||||
constructor() {
|
||||
this.server = new Server({
|
||||
name: 'aiworker-mcp',
|
||||
version: '1.0.0'
|
||||
}, {
|
||||
capabilities: { tools: {} }
|
||||
})
|
||||
|
||||
this.setupHandlers()
|
||||
}
|
||||
|
||||
// Implementar handlers...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Conectar MCP con Bun.serve()
|
||||
|
||||
**Opciones**:
|
||||
- **A**: Puerto separado (3100) para MCP
|
||||
- **B**: Ruta `/mcp` en mismo server
|
||||
|
||||
**Recomendación**: Opción A (puerto 3100)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 4: Integración con Gitea (30 min)
|
||||
|
||||
### 4.1 Cliente API de Gitea
|
||||
|
||||
**Archivo**: `src/services/gitea/client.ts`
|
||||
|
||||
**Operaciones necesarias**:
|
||||
```typescript
|
||||
- createRepo(name, description)
|
||||
- createBranch(owner, repo, branch, from)
|
||||
- createPullRequest(owner, repo, {title, body, head, base})
|
||||
- mergePullRequest(owner, repo, number)
|
||||
```
|
||||
|
||||
**Usar**:
|
||||
- Axios para HTTP requests
|
||||
- Base URL: `https://git.fuq.tv/api/v1`
|
||||
- Token: Variable de entorno `GITEA_TOKEN`
|
||||
|
||||
**Referencia**: `docs/02-backend/gitea-integration.md` (líneas 10-200)
|
||||
|
||||
### 4.2 Test de integración
|
||||
```bash
|
||||
# Crear un repo de prueba vía API
|
||||
bun run src/test-gitea.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 5: Deploy Backend en K8s (30 min)
|
||||
|
||||
### 5.1 Crear manifests
|
||||
|
||||
**Directorio**: `k8s/backend/`
|
||||
|
||||
**Archivos necesarios**:
|
||||
```yaml
|
||||
# deployment.yaml
|
||||
# service.yaml
|
||||
# ingress.yaml
|
||||
# secrets.yaml
|
||||
```
|
||||
|
||||
**Template deployment**:
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: control-plane
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: backend
|
||||
image: git.fuq.tv/admin/aiworker-backend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- containerPort: 3100 # MCP
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: mariadb.control-plane.svc.cluster.local
|
||||
# ... más env vars
|
||||
```
|
||||
|
||||
### 5.2 Crear secrets
|
||||
```bash
|
||||
kubectl create secret generic backend-secrets -n control-plane \
|
||||
--from-literal=jwt-secret=your-secret \
|
||||
--from-literal=anthropic-api-key=your-key
|
||||
```
|
||||
|
||||
### 5.3 Deploy
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
kubectl get pods -n control-plane
|
||||
kubectl logs -f deployment/backend -n control-plane
|
||||
```
|
||||
|
||||
### 5.4 Crear Ingress
|
||||
```yaml
|
||||
# Para api.fuq.tv
|
||||
host: api.fuq.tv
|
||||
backend: backend:3000
|
||||
```
|
||||
|
||||
### 5.5 Verificar
|
||||
```bash
|
||||
curl https://api.fuq.tv/api/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PASO 6: Test End-to-End (15 min)
|
||||
|
||||
### 6.1 Crear proyecto vía API
|
||||
```bash
|
||||
curl -X POST https://api.fuq.tv/api/projects \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "test-project",
|
||||
"description": "First project"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6.2 Crear tarea
|
||||
```bash
|
||||
curl -X POST https://api.fuq.tv/api/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"projectId": "xxx",
|
||||
"title": "Test task",
|
||||
"description": "First automated task"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6.3 Verificar en DB
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! aiworker \
|
||||
-e "SELECT * FROM projects; SELECT * FROM tasks;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 NOTAS IMPORTANTES
|
||||
|
||||
### Desarrollo Local vs K8s
|
||||
|
||||
**Local (desarrollo)**:
|
||||
- Port-forward para MariaDB y Redis
|
||||
- `bun run dev` con hot-reload
|
||||
- Cambios rápidos
|
||||
|
||||
**K8s (testing/producción)**:
|
||||
- Build → Push → Deploy
|
||||
- Migrations automáticas en startup
|
||||
- Logs con kubectl
|
||||
|
||||
### Migrations
|
||||
|
||||
**SIEMPRE** automáticas en el código:
|
||||
```typescript
|
||||
// src/index.ts
|
||||
await runMigrations() // Al inicio
|
||||
```
|
||||
|
||||
**NUNCA** manuales con port-forward
|
||||
|
||||
### Secrets
|
||||
|
||||
**Desarrollo**: `.env` (git-ignored)
|
||||
**Producción**: Kubernetes Secrets
|
||||
|
||||
```bash
|
||||
kubectl create secret generic app-secrets -n namespace \
|
||||
--from-env-file=.env.production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 TROUBLESHOOTING
|
||||
|
||||
### Si backend no arranca
|
||||
```bash
|
||||
# Ver logs
|
||||
kubectl logs -n control-plane deployment/backend
|
||||
|
||||
# Verificar DB connection
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! -e "SELECT 1"
|
||||
|
||||
# Verificar Redis
|
||||
kubectl exec -n control-plane deployment/redis -- redis-cli ping
|
||||
```
|
||||
|
||||
### Si Actions no funciona
|
||||
```bash
|
||||
# Ver runner
|
||||
kubectl get pods -n gitea-actions
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner
|
||||
|
||||
# Restart runner
|
||||
kubectl rollout restart deployment/gitea-runner -n gitea-actions
|
||||
```
|
||||
|
||||
### Si Ingress no resuelve
|
||||
```bash
|
||||
# Verificar DNS
|
||||
dig api.fuq.tv +short
|
||||
# Debe mostrar: 108.165.47.221 y 108.165.47.203
|
||||
|
||||
# Verificar certificado
|
||||
kubectl get certificate -n control-plane
|
||||
|
||||
# Logs de Ingress
|
||||
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller --tail=50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ CHECKLIST DE SESIÓN
|
||||
|
||||
Al final de cada sesión, verificar:
|
||||
|
||||
- [ ] Código commitado y pusheado a Gitea
|
||||
- [ ] Build de CI/CD exitoso
|
||||
- [ ] Pods corriendo en K8s
|
||||
- [ ] Endpoints accesibles vía HTTPS
|
||||
- [ ] Documentación actualizada
|
||||
- [ ] Credenciales documentadas en lugar seguro
|
||||
- [ ] Tests básicos pasando
|
||||
|
||||
---
|
||||
|
||||
## 🎉 META
|
||||
|
||||
**Completado**: Infraestructura HA + Backend base
|
||||
**Próximo hito**: Backend API funcional + MCP Server
|
||||
**Hito final**: Sistema completo de agentes autónomos
|
||||
|
||||
**¡Excelente progreso! Sigue el roadmap y lo tendrás listo pronto! 🚀**
|
||||
390
QUICK-REFERENCE.md
Normal file
390
QUICK-REFERENCE.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# ⚡ Quick Reference - Comandos y URLs
|
||||
|
||||
Referencia rápida de todo lo importante en un solo lugar.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 URLs
|
||||
|
||||
| Servicio | URL | Credenciales |
|
||||
|----------|-----|--------------|
|
||||
| Gitea | https://git.fuq.tv | admin / admin123 |
|
||||
| ArgoCD | https://argocd.fuq.tv | admin / LyPF4Hy0wvp52IoU |
|
||||
| Longhorn | https://longhorn.fuq.tv | admin / aiworker2026 |
|
||||
| Backend Repo | https://git.fuq.tv/admin/aiworker-backend | - |
|
||||
| Actions | https://git.fuq.tv/admin/aiworker-backend/actions | - |
|
||||
| Packages | https://git.fuq.tv/admin/-/packages | - |
|
||||
| Test App | https://test.fuq.tv | - |
|
||||
| HAProxy Stats | http://108.165.47.221:8404/stats | admin / aiworker2026 |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Tokens y Secrets
|
||||
|
||||
```bash
|
||||
# Gitea Full Access
|
||||
159a5de2a16d15f33e388b55b1276e431dbca3f3
|
||||
|
||||
# Gitea Registry
|
||||
7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
|
||||
# K3s Token
|
||||
K10e74a5aacfaf4e2e0a291c3b369db8588cf0b9c2590a4d66e04ab960e24fcb4db::server:bc53704a9707d3cd9188af9e558ab50c
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Base de Datos
|
||||
|
||||
### MariaDB (Interno)
|
||||
```bash
|
||||
Host: mariadb.control-plane.svc.cluster.local
|
||||
Port: 3306
|
||||
User: aiworker
|
||||
Pass: AiWorker2026_UserPass!
|
||||
DB: aiworker
|
||||
```
|
||||
|
||||
### Conexión desde pod
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! aiworker
|
||||
```
|
||||
|
||||
### Bases de datos
|
||||
- `aiworker` - App principal
|
||||
- `gitea` - Gitea data
|
||||
|
||||
---
|
||||
|
||||
## ☸️ Kubernetes
|
||||
|
||||
### Kubeconfig
|
||||
```bash
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
### Comandos Básicos
|
||||
```bash
|
||||
# Nodos
|
||||
kubectl get nodes
|
||||
|
||||
# Todos los pods
|
||||
kubectl get pods -A
|
||||
|
||||
# Pods en namespace
|
||||
kubectl get pods -n control-plane
|
||||
|
||||
# Logs
|
||||
kubectl logs -f <pod> -n <namespace>
|
||||
|
||||
# Exec
|
||||
kubectl exec -it <pod> -n <namespace> -- /bin/sh
|
||||
|
||||
# Port-forward
|
||||
kubectl port-forward svc/<service> 3000:3000 -n <namespace>
|
||||
```
|
||||
|
||||
### Namespaces Principales
|
||||
- `control-plane` - Backend, DB, Redis
|
||||
- `agents` - Claude agents
|
||||
- `gitea` - Git server
|
||||
- `gitea-actions` - CI/CD runner
|
||||
- `ingress-nginx` - Ingress
|
||||
- `cert-manager` - TLS
|
||||
- `longhorn-system` - Storage
|
||||
- `argocd` - GitOps
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Container Registry
|
||||
|
||||
### Login
|
||||
```bash
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
```
|
||||
|
||||
### Push
|
||||
```bash
|
||||
docker build -t git.fuq.tv/admin/<app>:<tag> .
|
||||
docker push git.fuq.tv/admin/<app>:<tag>
|
||||
```
|
||||
|
||||
### K8s Secret
|
||||
```bash
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--docker-server=git.fuq.tv \
|
||||
--docker-username=admin \
|
||||
--docker-password=7401126cfb56ab2aebba17755bdc968c20768c27 \
|
||||
-n <namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ SSH a Servidores
|
||||
|
||||
```bash
|
||||
# Control Planes
|
||||
ssh root@108.165.47.233 # k8s-cp-01
|
||||
ssh root@108.165.47.235 # k8s-cp-02
|
||||
ssh root@108.165.47.215 # k8s-cp-03
|
||||
|
||||
# Workers
|
||||
ssh root@108.165.47.225 # k8s-worker-01
|
||||
ssh root@108.165.47.224 # k8s-worker-02
|
||||
ssh root@108.165.47.222 # k8s-worker-03
|
||||
|
||||
# Load Balancers
|
||||
ssh root@108.165.47.221 # k8s-lb-01
|
||||
ssh root@108.165.47.203 # k8s-lb-02
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Backend Development
|
||||
|
||||
### Local
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install deps
|
||||
bun install
|
||||
|
||||
# Dev mode
|
||||
bun run dev
|
||||
|
||||
# Generate migrations
|
||||
bun run db:generate
|
||||
|
||||
# Build
|
||||
bun build src/index.ts --outdir dist
|
||||
```
|
||||
|
||||
### Port-forwards para desarrollo local
|
||||
```bash
|
||||
# Terminal 1: MariaDB
|
||||
kubectl port-forward -n control-plane svc/mariadb 3306:3306
|
||||
|
||||
# Terminal 2: Redis
|
||||
kubectl port-forward -n control-plane svc/redis 6379:6379
|
||||
|
||||
# Terminal 3: Backend
|
||||
cd backend && bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Git Workflow
|
||||
|
||||
### Commits
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Description
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>"
|
||||
git push
|
||||
```
|
||||
|
||||
### Ver Actions
|
||||
```bash
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
```
|
||||
|
||||
### Build manual (si Actions falla)
|
||||
```bash
|
||||
# En un worker node
|
||||
ssh root@108.165.47.225
|
||||
|
||||
cd /tmp
|
||||
git clone https://git.fuq.tv/admin/aiworker-backend.git
|
||||
cd aiworker-backend
|
||||
|
||||
docker build -t git.fuq.tv/admin/aiworker-backend:latest .
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
docker push git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
### Aplicar manifests
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
kubectl apply -f k8s/frontend/
|
||||
```
|
||||
|
||||
### Ver estado
|
||||
```bash
|
||||
kubectl get all -n control-plane
|
||||
kubectl logs -f deployment/backend -n control-plane
|
||||
```
|
||||
|
||||
### Rollout
|
||||
```bash
|
||||
# Update image
|
||||
kubectl set image deployment/backend backend=git.fuq.tv/admin/aiworker-backend:v2.0.0 -n control-plane
|
||||
|
||||
# Restart
|
||||
kubectl rollout restart deployment/backend -n control-plane
|
||||
|
||||
# Status
|
||||
kubectl rollout status deployment/backend -n control-plane
|
||||
|
||||
# Rollback
|
||||
kubectl rollout undo deployment/backend -n control-plane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Resources
|
||||
```bash
|
||||
kubectl top nodes
|
||||
kubectl top pods -A
|
||||
kubectl top pods -n control-plane
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# Tail logs
|
||||
kubectl logs -f <pod> -n <namespace>
|
||||
|
||||
# Logs recientes
|
||||
kubectl logs --tail=100 <pod> -n <namespace>
|
||||
|
||||
# Logs de todos los containers
|
||||
kubectl logs <pod> -n <namespace> --all-containers
|
||||
```
|
||||
|
||||
### Events
|
||||
```bash
|
||||
kubectl get events -A --sort-by='.lastTimestamp' | tail -20
|
||||
kubectl get events -n <namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database
|
||||
|
||||
### Query rápido
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! aiworker \
|
||||
-e "SHOW TABLES;"
|
||||
```
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb-dump -uaiworker -pAiWorker2026_UserPass! aiworker \
|
||||
> backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
```bash
|
||||
cat backup.sql | kubectl exec -i -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! aiworker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Secrets Management
|
||||
|
||||
### Create secret
|
||||
```bash
|
||||
kubectl create secret generic my-secret -n <namespace> \
|
||||
--from-literal=key1=value1 \
|
||||
--from-literal=key2=value2
|
||||
```
|
||||
|
||||
### View secret
|
||||
```bash
|
||||
kubectl get secret my-secret -n <namespace> -o yaml
|
||||
kubectl get secret my-secret -n <namespace> -o jsonpath='{.data.key1}' | base64 -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ CubePath CLI
|
||||
|
||||
### Ver servidores
|
||||
```bash
|
||||
cubecli vps list
|
||||
cubecli project list
|
||||
```
|
||||
|
||||
### SSH a servidor
|
||||
```bash
|
||||
cubecli vps list | grep k8s-
|
||||
ssh root@<ip>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 DNS
|
||||
|
||||
**Configurado**:
|
||||
```
|
||||
*.fuq.tv → 108.165.47.221, 108.165.47.203
|
||||
*.r.fuq.tv → 108.165.47.221, 108.165.47.203
|
||||
```
|
||||
|
||||
**Verificar**:
|
||||
```bash
|
||||
dig api.fuq.tv +short
|
||||
dig test.fuq.tv +short
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 ESTRUCTURA DEL PROYECTO
|
||||
|
||||
```
|
||||
teamSquadAiWorker/
|
||||
├── backend/ # Bun backend
|
||||
│ ├── src/
|
||||
│ ├── Dockerfile
|
||||
│ └── .gitea/workflows/
|
||||
├── frontend/ # React (por crear)
|
||||
├── docs/ # Documentación completa
|
||||
├── scripts/ # Scripts de instalación
|
||||
├── k8s/ # Manifests Kubernetes (por crear)
|
||||
├── ROADMAP.md # Plan general
|
||||
├── NEXT-SESSION.md # Próximos pasos detallados
|
||||
├── TROUBLESHOOTING.md # Solución de problemas
|
||||
├── QUICK-REFERENCE.md # Este archivo
|
||||
├── CLUSTER-READY.md # Estado del cluster
|
||||
└── CLUSTER-CREDENTIALS.md # Credenciales (sensible)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 INICIO RÁPIDO DE SESIÓN
|
||||
|
||||
```bash
|
||||
# 1. Verificar cluster
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes
|
||||
|
||||
# 2. Ver servicios
|
||||
kubectl get pods -n control-plane
|
||||
kubectl get pods -n gitea
|
||||
|
||||
# 3. Backend local
|
||||
cd backend
|
||||
bun run dev
|
||||
|
||||
# 4. Ver Actions
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
|
||||
# 5. Continuar donde te quedaste
|
||||
code NEXT-SESSION.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎊 FIN DE QUICK REFERENCE
|
||||
|
||||
**Todo lo importante en un solo lugar. ¡Guarda este archivo!**
|
||||
245
README.md
Normal file
245
README.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# 🤖 AiWorker - AI Agent Orchestration Platform
|
||||
|
||||
Sistema de orquestación de agentes IA (Claude Code) para automatización del ciclo completo de desarrollo de software.
|
||||
|
||||
**Estado**: 🚧 En desarrollo - Infraestructura completa ✅
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ¿Qué es AiWorker?
|
||||
|
||||
Plataforma que permite a agentes IA (Claude Code) trabajar autónomamente en tareas de desarrollo:
|
||||
|
||||
1. **Dashboard Web** - Kanban board para gestionar tareas
|
||||
2. **Agentes Autónomos** - Claude Code en pods de Kubernetes
|
||||
3. **Comunicación MCP** - Agentes piden/dan información
|
||||
4. **Deployments Automáticos** - Preview, staging, production
|
||||
5. **GitOps** - Todo versionado en Git
|
||||
|
||||
**Flujo completo**:
|
||||
```
|
||||
Tarea → Agente → Código → PR → Preview Deploy → Aprobación → Staging → Production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 DOCUMENTACIÓN
|
||||
|
||||
### 🚀 Start Here
|
||||
- **[ROADMAP.md](ROADMAP.md)** - Plan general y progreso
|
||||
- **[NEXT-SESSION.md](NEXT-SESSION.md)** - Próximos pasos detallados
|
||||
- **[DEVELOPMENT-WORKFLOW.md](DEVELOPMENT-WORKFLOW.md)** - Cómo trabajamos (Git, CI/CD, Deploy)
|
||||
- **[QUICK-REFERENCE.md](QUICK-REFERENCE.md)** - Comandos y URLs importantes
|
||||
- **[GITEA-GUIDE.md](GITEA-GUIDE.md)** - Guía completa de Gitea (API, Registry, CI/CD)
|
||||
|
||||
### 🏗️ Infraestructura
|
||||
- **[CLUSTER-READY.md](CLUSTER-READY.md)** - Estado del cluster K8s
|
||||
- **[CLUSTER-CREDENTIALS.md](CLUSTER-CREDENTIALS.md)** - Credenciales (⚠️ sensible)
|
||||
- **[AGENT-GUIDE.md](AGENT-GUIDE.md)** - Guía para agentes IA
|
||||
- **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** - Solución de problemas
|
||||
|
||||
### 📖 Documentación Completa
|
||||
- **[docs/](docs/)** - 40+ archivos de documentación detallada
|
||||
- `01-arquitectura/` - Diseño del sistema
|
||||
- `02-backend/` - Backend (Bun + Express + MCP)
|
||||
- `03-frontend/` - Frontend (React 19.2 + TailwindCSS)
|
||||
- `04-kubernetes/` - Kubernetes setup
|
||||
- `05-agents/` - Claude Code agents
|
||||
- `06-deployment/` - CI/CD y GitOps
|
||||
- `CONTAINER-REGISTRY.md` - Uso del registry
|
||||
|
||||
---
|
||||
|
||||
## ✅ COMPLETADO
|
||||
|
||||
### Infraestructura Kubernetes HA
|
||||
- ✅ Cluster K3s v1.35.0+k3s1 (Houston, Texas)
|
||||
- ✅ 3 Control Planes + 3 Workers + 2 Load Balancers
|
||||
- ✅ Longhorn Storage HA (3 réplicas)
|
||||
- ✅ Nginx Ingress + Cert-Manager (TLS automático)
|
||||
- ✅ Red privada con HAProxy
|
||||
- ✅ DNS: *.fuq.tv (round-robin HA)
|
||||
|
||||
### Servicios Base
|
||||
- ✅ MariaDB 11.4 LTS
|
||||
- ✅ Redis 7
|
||||
- ✅ Gitea 1.25.3 + Container Registry
|
||||
- ✅ ArgoCD
|
||||
- ✅ Gitea Actions Runner (CI/CD)
|
||||
|
||||
### Backend Inicial
|
||||
- ✅ Estructura del proyecto
|
||||
- ✅ Bun 1.3.6 con Bun.serve()
|
||||
- ✅ Database schema (Drizzle ORM)
|
||||
- ✅ Dockerfile
|
||||
- ✅ Workflow CI/CD
|
||||
|
||||
**Costo**: $148/mes | **Capacidad**: 48 vCPU, 104 GB RAM
|
||||
|
||||
---
|
||||
|
||||
## 🚧 EN DESARROLLO
|
||||
|
||||
- [ ] Backend API routes completas
|
||||
- [ ] MCP Server para agentes
|
||||
- [ ] Frontend React 19.2
|
||||
- [ ] Pods de agentes Claude Code
|
||||
- [ ] Preview environments automáticos
|
||||
|
||||
---
|
||||
|
||||
## ⚡ QUICK START
|
||||
|
||||
### Acceder al Cluster
|
||||
```bash
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes
|
||||
kubectl get pods -A
|
||||
```
|
||||
|
||||
### Desarrollo Local (Backend)
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Port-forward services
|
||||
kubectl port-forward -n control-plane svc/mariadb 3306:3306 &
|
||||
kubectl port-forward -n control-plane svc/redis 6379:6379 &
|
||||
|
||||
# Run dev server
|
||||
bun run dev
|
||||
|
||||
# Test
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### Ver Actions CI/CD
|
||||
```bash
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
```
|
||||
|
||||
### Deploy en K8s (cuando esté listo)
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
kubectl get pods -n control-plane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ ARQUITECTURA
|
||||
|
||||
```
|
||||
Internet
|
||||
↓
|
||||
[DNS: *.fuq.tv]
|
||||
↓
|
||||
┌────────────┴────────────┐
|
||||
↓ ↓
|
||||
[LB-01] [LB-02]
|
||||
HAProxy HAProxy
|
||||
↓ ↓
|
||||
└────────────┬────────────┘
|
||||
↓
|
||||
[Private Network 10.100.0.0/24]
|
||||
↓
|
||||
┌───────────────┼───────────────┐
|
||||
↓ ↓ ↓
|
||||
[CP etcd] [CP etcd] [CP etcd]
|
||||
10.100.0.2 10.100.0.3 10.100.0.4
|
||||
↓ ↓ ↓
|
||||
─────┴───────────────┴───────────────┴─────
|
||||
↓ ↓ ↓
|
||||
[Worker+Storage] [Worker+Storage] [Worker+Storage]
|
||||
10.100.0.5 10.100.0.6 10.100.0.7
|
||||
↓ ↓ ↓
|
||||
[Apps] [Apps] [Apps]
|
||||
│ │ │
|
||||
[Longhorn 3x Replica Storage HA]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 STACK TECNOLÓGICO
|
||||
|
||||
| Layer | Tecnología | Versión |
|
||||
|-------|-----------|---------|
|
||||
| **Runtime** | Bun | 1.3.6 |
|
||||
| **Backend** | Bun.serve() + Drizzle | - |
|
||||
| **Frontend** | React + TailwindCSS | 19.2 + 4.x |
|
||||
| **Database** | MariaDB | 11.4 LTS |
|
||||
| **Cache** | Redis | 7 |
|
||||
| **Git** | Gitea | 1.25.3 |
|
||||
| **Registry** | Gitea Container Registry | - |
|
||||
| **K8s** | K3s | v1.35.0+k3s1 |
|
||||
| **Storage** | Longhorn | v1.8.0 |
|
||||
| **Ingress** | Nginx | latest |
|
||||
| **TLS** | Cert-Manager | v1.16.2 |
|
||||
| **GitOps** | ArgoCD | stable |
|
||||
| **CI/CD** | Gitea Actions | - |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 LEARN BY DOING - Sesiones
|
||||
|
||||
### Sesión 1 (2026-01-19) - Infraestructura ✅
|
||||
- Desplegar cluster K3s HA en CubePath
|
||||
- Configurar storage, networking, ingress
|
||||
- Instalar Gitea, MariaDB, Redis
|
||||
- Setup CI/CD con Gitea Actions
|
||||
- Inicializar backend
|
||||
|
||||
**Ver**: `CLUSTER-READY.md` para detalles completos
|
||||
|
||||
### Sesión 2 (Próxima) - Backend API
|
||||
- Completar API routes
|
||||
- Implementar MCP Server
|
||||
- Integración con Gitea y K8s
|
||||
- Deploy backend en cluster
|
||||
|
||||
**Ver**: `NEXT-SESSION.md` para pasos exactos
|
||||
|
||||
---
|
||||
|
||||
## 📞 SOPORTE
|
||||
|
||||
- **Issues**: Documentadas en `TROUBLESHOOTING.md`
|
||||
- **CubePath**: https://cubepath.com/support
|
||||
- **K3s Docs**: https://docs.k3s.io
|
||||
- **Bun Docs**: https://bun.sh/docs
|
||||
|
||||
---
|
||||
|
||||
## 🤝 CONTRIBUCIÓN
|
||||
|
||||
Este es un proyecto en desarrollo activo. La documentación se actualiza en cada sesión.
|
||||
|
||||
**Estructura de commits**:
|
||||
```
|
||||
Title line (imperativo)
|
||||
|
||||
Detailed description
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 LICENSE
|
||||
|
||||
Proyecto privado - AiWorker Platform
|
||||
|
||||
---
|
||||
|
||||
## 🎉 HIGHLIGHTS
|
||||
|
||||
- **Alta Disponibilidad**: 3 réplicas de todo (storage, control planes)
|
||||
- **TLS Automático**: Let's Encrypt con Cert-Manager
|
||||
- **CI/CD Integrado**: Build automático en cada push
|
||||
- **GitOps Ready**: ArgoCD configurado
|
||||
- **Escalable**: 48 vCPU, 104 GB RAM disponibles
|
||||
- **Moderno**: Últimas versiones de todo (K3s, Bun, React 19.2)
|
||||
|
||||
---
|
||||
|
||||
**🚀 ¡Proyecto con bases sólidas! Listo para construir features increíbles!**
|
||||
|
||||
**Siguiente paso**: Abre `NEXT-SESSION.md` y continúa donde lo dejaste.
|
||||
531
ROADMAP.md
Normal file
531
ROADMAP.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# 🗺️ AiWorker - Roadmap y Próximos Pasos
|
||||
|
||||
**Última actualización**: 2026-01-19
|
||||
**Estado actual**: Infraestructura completa, Backend iniciado
|
||||
|
||||
---
|
||||
|
||||
## ✅ COMPLETADO (Sesión 1 - 2026-01-19)
|
||||
|
||||
### 1. Infraestructura Kubernetes HA
|
||||
- [x] Cluster K3s desplegado en CubePath (Houston)
|
||||
- [x] 3 Control Planes + 3 Workers + 2 Load Balancers
|
||||
- [x] Red privada 10.100.0.0/24
|
||||
- [x] Longhorn storage HA (3 réplicas)
|
||||
- [x] Nginx Ingress + Cert-Manager (TLS automático)
|
||||
- [x] DNS configurado (*.fuq.tv)
|
||||
|
||||
**Docs**: `CLUSTER-READY.md`, `docs/04-kubernetes/`
|
||||
|
||||
### 2. Bases de Datos y Servicios
|
||||
- [x] MariaDB 11.4 LTS con storage HA
|
||||
- [x] Redis 7 desplegado
|
||||
- [x] Gitea 1.25.3 con Container Registry habilitado
|
||||
- [x] ArgoCD para GitOps
|
||||
|
||||
**Credenciales**: `CLUSTER-CREDENTIALS.md`
|
||||
**Acceso Gitea**: https://git.fuq.tv (admin/admin123)
|
||||
|
||||
### 3. Backend Inicial
|
||||
- [x] Estructura del proyecto creada
|
||||
- [x] Bun 1.3.6 configurado
|
||||
- [x] Database schema (projects, agents, tasks) con Drizzle
|
||||
- [x] Dockerfile multi-stage
|
||||
- [x] Gitea Actions Runner configurado
|
||||
- [x] Workflow CI/CD básico
|
||||
|
||||
**Repo**: https://git.fuq.tv/admin/aiworker-backend
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PRÓXIMOS PASOS
|
||||
|
||||
### FASE 1: Completar Backend
|
||||
|
||||
#### 1.1 Verificar y corregir CI/CD
|
||||
**Objetivo**: Build automático funcionando
|
||||
**Tareas**:
|
||||
- [ ] Verificar build en https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
- [ ] Corregir errores si los hay
|
||||
- [ ] Confirmar imagen en registry: `git.fuq.tv/admin/aiworker-backend:latest`
|
||||
|
||||
**Comandos útiles**:
|
||||
```bash
|
||||
# Ver runner logs
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=50
|
||||
|
||||
# Ver packages en Gitea
|
||||
https://git.fuq.tv/admin/-/packages
|
||||
```
|
||||
|
||||
#### 1.2 Implementar API Routes con Bun.serve()
|
||||
**Objetivo**: Endpoints REST funcionales
|
||||
**Tareas**:
|
||||
- [ ] Crear `/api/projects` (CRUD)
|
||||
- [ ] Crear `/api/tasks` (CRUD)
|
||||
- [ ] Crear `/api/agents` (list, status)
|
||||
- [ ] Implementar validación con Zod
|
||||
- [ ] Health check mejorado con DB/Redis status
|
||||
|
||||
**Referencia**: `docs/02-backend/api-endpoints.md`
|
||||
|
||||
**Estructura**:
|
||||
```
|
||||
src/api/
|
||||
├── routes/
|
||||
│ ├── projects.ts
|
||||
│ ├── tasks.ts
|
||||
│ └── agents.ts
|
||||
└── middleware/
|
||||
├── auth.ts
|
||||
└── validate.ts
|
||||
```
|
||||
|
||||
#### 1.3 Implementar MCP Server
|
||||
**Objetivo**: Herramientas para agentes Claude Code
|
||||
**Tareas**:
|
||||
- [ ] Instalar `@modelcontextprotocol/sdk`
|
||||
- [ ] Crear MCP server en puerto 3100
|
||||
- [ ] Implementar tools: `get_next_task`, `update_task_status`, etc.
|
||||
- [ ] Conectar con Gitea API
|
||||
- [ ] Conectar con Kubernetes API
|
||||
|
||||
**Referencia**: `docs/02-backend/mcp-server.md`, `docs/05-agents/mcp-tools.md`
|
||||
|
||||
**Archivo**: `src/services/mcp/server.ts`
|
||||
|
||||
#### 1.4 Integración con Gitea
|
||||
**Objetivo**: Gestión de repos y PRs
|
||||
**Tareas**:
|
||||
- [ ] Cliente API de Gitea
|
||||
- [ ] Webhooks handler
|
||||
- [ ] Operaciones: create repo, create PR, merge, etc.
|
||||
|
||||
**Referencia**: `docs/02-backend/gitea-integration.md`
|
||||
|
||||
**Token Gitea**: `159a5de2a16d15f33e388b55b1276e431dbca3f3` (full access)
|
||||
|
||||
#### 1.5 Integración con Kubernetes
|
||||
**Objetivo**: Crear/gestionar deployments y namespaces
|
||||
**Tareas**:
|
||||
- [ ] Cliente K8s usando `@kubernetes/client-node`
|
||||
- [ ] Crear namespaces dinámicos
|
||||
- [ ] Crear deployments de preview
|
||||
- [ ] Crear ingress automáticos
|
||||
- [ ] Gestionar pods de agentes
|
||||
|
||||
**Referencia**: `docs/04-kubernetes/deployments.md`
|
||||
|
||||
**Kubeconfig**: `~/.kube/aiworker-config`
|
||||
|
||||
#### 1.6 Sistema de Colas (BullMQ)
|
||||
**Objetivo**: Jobs asíncronos para deployments
|
||||
**Tareas**:
|
||||
- [ ] Setup BullMQ con Redis
|
||||
- [ ] Queue para tasks
|
||||
- [ ] Queue para deployments
|
||||
- [ ] Workers para procesar jobs
|
||||
|
||||
**Referencia**: `docs/02-backend/queue-system.md`
|
||||
|
||||
#### 1.7 WebSocket Real-time
|
||||
**Objetivo**: Notificaciones en tiempo real
|
||||
**Tareas**:
|
||||
- [ ] WebSocket server con Bun.serve()
|
||||
- [ ] Eventos: `task:created`, `task:status_changed`, etc.
|
||||
- [ ] Autenticación de conexiones
|
||||
|
||||
**Referencia**: `docs/01-arquitectura/flujo-de-datos.md`
|
||||
|
||||
#### 1.8 Deploy Backend en K8s
|
||||
**Objetivo**: Backend corriendo en producción
|
||||
**Tareas**:
|
||||
- [ ] Crear manifests K8s (deployment, service, ingress)
|
||||
- [ ] Configurar secrets (DB, Gitea, etc.)
|
||||
- [ ] Deploy en namespace `control-plane`
|
||||
- [ ] Verificar en `api.fuq.tv`
|
||||
|
||||
**Comandos**:
|
||||
```bash
|
||||
kubectl apply -f k8s/backend/
|
||||
kubectl get pods -n control-plane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FASE 2: Frontend React 19.2
|
||||
|
||||
#### 2.1 Inicializar Proyecto
|
||||
**Objetivo**: Setup base de React
|
||||
**Tareas**:
|
||||
- [ ] Crear proyecto con Vite + React 19.2
|
||||
- [ ] Instalar TailwindCSS 4
|
||||
- [ ] Configurar TypeScript
|
||||
- [ ] Estructura de carpetas
|
||||
|
||||
**Stack**:
|
||||
- React 19.2.0
|
||||
- Vite 6.x
|
||||
- TailwindCSS 4.x
|
||||
- Bun como package manager
|
||||
|
||||
**Auth**: Lucia Auth (`https://github.com/lucia-auth/lucia`)
|
||||
**Skills**: Vercel Agent Skills (`https://github.com/vercel-labs/agent-skills`)
|
||||
|
||||
#### 2.2 Componentes Base
|
||||
**Objetivo**: UI components library
|
||||
**Tareas**:
|
||||
- [ ] Layout (Header, Sidebar)
|
||||
- [ ] Componentes UI (Button, Card, Modal, etc.)
|
||||
- [ ] TailwindCSS config con tema
|
||||
|
||||
**Referencia**: `docs/03-frontend/componentes.md`
|
||||
|
||||
#### 2.3 Kanban Board
|
||||
**Objetivo**: Gestión visual de tareas
|
||||
**Tareas**:
|
||||
- [ ] Implementar con `@dnd-kit`
|
||||
- [ ] Columnas por estado (backlog, in_progress, etc.)
|
||||
- [ ] Drag & drop funcional
|
||||
- [ ] Filtros y búsqueda
|
||||
|
||||
**Referencia**: `docs/03-frontend/kanban.md`
|
||||
|
||||
#### 2.4 Consolas Web
|
||||
**Objetivo**: Terminales para agentes
|
||||
**Tareas**:
|
||||
- [ ] Implementar con `xterm.js`
|
||||
- [ ] WebSocket a pods de agentes
|
||||
- [ ] Tabs manager
|
||||
|
||||
**Referencia**: `docs/03-frontend/consolas-web.md`
|
||||
|
||||
#### 2.5 Deploy Frontend
|
||||
**Objetivo**: Frontend en producción
|
||||
**Tareas**:
|
||||
- [ ] Build para producción
|
||||
- [ ] Dockerfile con nginx
|
||||
- [ ] Deploy en `app.fuq.tv`
|
||||
|
||||
---
|
||||
|
||||
### FASE 3: Agentes Claude Code
|
||||
|
||||
#### 3.1 Docker Image del Agente
|
||||
**Objetivo**: Imagen base para agentes
|
||||
**Tareas**:
|
||||
- [ ] Dockerfile con Claude Code CLI
|
||||
- [ ] Git config
|
||||
- [ ] SSH keys setup
|
||||
- [ ] Script de trabajo (agent-loop.sh)
|
||||
|
||||
**Referencia**: `docs/05-agents/claude-code-pods.md`
|
||||
|
||||
#### 3.2 Gestión de Agentes desde Backend
|
||||
**Objetivo**: Crear/eliminar pods de agentes
|
||||
**Tareas**:
|
||||
- [ ] API endpoint `/agents` (create, delete, list)
|
||||
- [ ] Auto-scaling basado en tareas pendientes
|
||||
- [ ] Healthcheck de agentes
|
||||
|
||||
**Referencia**: `docs/05-agents/ciclo-vida.md`
|
||||
|
||||
#### 3.3 Comunicación MCP
|
||||
**Objetivo**: Agentes conectados al backend
|
||||
**Tareas**:
|
||||
- [ ] MCP client en agentes
|
||||
- [ ] Herramientas implementadas (get_next_task, etc.)
|
||||
- [ ] Heartbeat system
|
||||
|
||||
**Referencia**: `docs/05-agents/comunicacion.md`
|
||||
|
||||
---
|
||||
|
||||
### FASE 4: GitOps y Deployments
|
||||
|
||||
#### 4.1 ArgoCD Setup
|
||||
**Objetivo**: GitOps funcional
|
||||
**Tareas**:
|
||||
- [ ] Conectar repos de Gitea a ArgoCD
|
||||
- [ ] Crear Applications
|
||||
- [ ] Auto-sync configurado
|
||||
|
||||
**Referencia**: `docs/06-deployment/gitops.md`
|
||||
**URL**: https://argocd.fuq.tv (admin/LyPF4Hy0wvp52IoU)
|
||||
|
||||
#### 4.2 Preview Environments
|
||||
**Objetivo**: Deploy automático por tarea
|
||||
**Tareas**:
|
||||
- [ ] Lógica para crear namespace temporal
|
||||
- [ ] Deploy app en `task-{id}.r.fuq.tv`
|
||||
- [ ] Cleanup automático (TTL)
|
||||
|
||||
**Referencia**: `docs/06-deployment/preview-envs.md`
|
||||
|
||||
#### 4.3 Staging y Production
|
||||
**Objetivo**: Pipeline completo
|
||||
**Tareas**:
|
||||
- [ ] Merge a staging branch
|
||||
- [ ] Deploy staging automático
|
||||
- [ ] Aprobación manual para production
|
||||
- [ ] Rollback capability
|
||||
|
||||
**Referencia**: `docs/06-deployment/staging-production.md`
|
||||
|
||||
---
|
||||
|
||||
## 📚 DOCUMENTACIÓN EXISTENTE
|
||||
|
||||
### Arquitectura
|
||||
- `docs/01-arquitectura/overview.md` - Visión general
|
||||
- `docs/01-arquitectura/stack-tecnologico.md` - Stack completo
|
||||
- `docs/01-arquitectura/flujo-de-datos.md` - Diagramas de flujo
|
||||
- `docs/01-arquitectura/modelo-datos.md` - Database schema
|
||||
|
||||
### Backend
|
||||
- `docs/02-backend/estructura.md` - Estructura del proyecto
|
||||
- `docs/02-backend/database-schema.md` - Drizzle schema
|
||||
- `docs/02-backend/mcp-server.md` - MCP implementation
|
||||
- `docs/02-backend/gitea-integration.md` - Gitea API client
|
||||
- `docs/02-backend/queue-system.md` - BullMQ
|
||||
- `docs/02-backend/api-endpoints.md` - REST API specs
|
||||
|
||||
### Frontend
|
||||
- `docs/03-frontend/estructura.md` - Estructura
|
||||
- `docs/03-frontend/componentes.md` - Componentes principales
|
||||
- `docs/03-frontend/estado.md` - React Query + Zustand
|
||||
- `docs/03-frontend/kanban.md` - Kanban board
|
||||
- `docs/03-frontend/consolas-web.md` - xterm.js
|
||||
|
||||
### Kubernetes
|
||||
- `docs/04-kubernetes/cluster-setup.md` - Setup inicial
|
||||
- `docs/04-kubernetes/namespaces.md` - Estructura
|
||||
- `docs/04-kubernetes/deployments.md` - Manifests
|
||||
- `docs/04-kubernetes/gitea-deployment.md` - Gitea en K8s
|
||||
- `docs/04-kubernetes/networking.md` - Ingress y red
|
||||
|
||||
### Agentes
|
||||
- `docs/05-agents/claude-code-pods.md` - Pods de agentes
|
||||
- `docs/05-agents/mcp-tools.md` - Herramientas MCP
|
||||
- `docs/05-agents/comunicacion.md` - MCP protocol
|
||||
- `docs/05-agents/ciclo-vida.md` - Lifecycle
|
||||
|
||||
### Deployment
|
||||
- `docs/06-deployment/ci-cd.md` - Pipelines
|
||||
- `docs/06-deployment/gitops.md` - ArgoCD
|
||||
- `docs/06-deployment/preview-envs.md` - Previews
|
||||
- `docs/06-deployment/staging-production.md` - Promoción
|
||||
|
||||
### Cluster
|
||||
- `CLUSTER-READY.md` - Estado del cluster
|
||||
- `CLUSTER-CREDENTIALS.md` - Credenciales (⚠️ sensible)
|
||||
- `AGENT-GUIDE.md` - Guía para agentes IA
|
||||
- `docs/CONTAINER-REGISTRY.md` - Uso del registry
|
||||
- `k8s-cluster-info.md` - Info técnica
|
||||
|
||||
---
|
||||
|
||||
## 🔑 CREDENCIALES RÁPIDAS
|
||||
|
||||
**Gitea**:
|
||||
- URL: https://git.fuq.tv
|
||||
- User: admin / admin123
|
||||
- Token: `159a5de2a16d15f33e388b55b1276e431dbca3f3`
|
||||
|
||||
**Registry**:
|
||||
- URL: git.fuq.tv
|
||||
- Token: `7401126cfb56ab2aebba17755bdc968c20768c27`
|
||||
|
||||
**ArgoCD**:
|
||||
- URL: https://argocd.fuq.tv
|
||||
- User: admin / LyPF4Hy0wvp52IoU
|
||||
|
||||
**Longhorn**:
|
||||
- URL: https://longhorn.fuq.tv
|
||||
- User: admin / aiworker2026
|
||||
|
||||
**MariaDB** (interno):
|
||||
- Host: mariadb.control-plane.svc.cluster.local:3306
|
||||
- DB: aiworker
|
||||
- User: aiworker / AiWorker2026_UserPass!
|
||||
|
||||
**Kubeconfig**:
|
||||
```bash
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ COMANDOS ÚTILES
|
||||
|
||||
### Cluster
|
||||
```bash
|
||||
# Ver nodos
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Ver todos los pods
|
||||
kubectl get pods -A
|
||||
|
||||
# Namespaces
|
||||
kubectl get ns
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
bun run dev # Desarrollo local
|
||||
bun run db:generate # Generar migraciones
|
||||
```
|
||||
|
||||
### Gitea
|
||||
```bash
|
||||
# Ver Actions
|
||||
https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
|
||||
# Ver packages/imágenes
|
||||
https://git.fuq.tv/admin/-/packages
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# MariaDB
|
||||
kubectl logs -n control-plane mariadb-0
|
||||
|
||||
# Redis
|
||||
kubectl logs -n control-plane deployment/redis
|
||||
|
||||
# Gitea
|
||||
kubectl logs -n gitea gitea-0
|
||||
|
||||
# Runner
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 PRÓXIMA SESIÓN - Plan Sugerido
|
||||
|
||||
### Opción A: Completar Backend (Recomendado)
|
||||
1. Verificar CI/CD funcional
|
||||
2. Implementar API routes básicas
|
||||
3. Implementar MCP Server básico
|
||||
4. Deploy backend en K8s
|
||||
5. Probar end-to-end
|
||||
|
||||
**Tiempo estimado**: 2-3 horas
|
||||
|
||||
### Opción B: Frontend Paralelo
|
||||
1. Inicializar React 19.2 + Vite
|
||||
2. Setup TailwindCSS
|
||||
3. Componentes básicos UI
|
||||
4. Kanban board inicial
|
||||
|
||||
**Tiempo estimado**: 2-3 horas
|
||||
|
||||
### Opción C: Agentes Primero
|
||||
1. Crear imagen Docker de agente
|
||||
2. Deploy agente de prueba
|
||||
3. Conectar con MCP
|
||||
4. Primera tarea automática
|
||||
|
||||
**Tiempo estimado**: 3-4 horas
|
||||
|
||||
---
|
||||
|
||||
## 📊 PROGRESO GENERAL
|
||||
|
||||
```
|
||||
Infraestructura: ████████████████████ 100%
|
||||
Backend: ████░░░░░░░░░░░░░░░░ 20%
|
||||
Frontend: ░░░░░░░░░░░░░░░░░░░░ 0%
|
||||
Agentes: ░░░░░░░░░░░░░░░░░░░░ 0%
|
||||
GitOps/Deploy: ██░░░░░░░░░░░░░░░░░░ 10%
|
||||
──────────────────────────────────────────
|
||||
Total: ██████░░░░░░░░░░░░░░ 26%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 QUICK START para Próxima Sesión
|
||||
|
||||
```bash
|
||||
# 1. Verificar cluster
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes
|
||||
|
||||
# 2. Verificar servicios
|
||||
kubectl get pods -n control-plane
|
||||
kubectl get pods -n gitea
|
||||
kubectl get pods -n gitea-actions
|
||||
|
||||
# 3. Acceder a Gitea
|
||||
open https://git.fuq.tv
|
||||
|
||||
# 4. Continuar con backend
|
||||
cd backend
|
||||
bun run dev
|
||||
|
||||
# 5. Ver Actions
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 APRENDIZAJES DE ESTA SESIÓN
|
||||
|
||||
### Lo que funcionó bien ✅
|
||||
- CubeCLI para gestionar VPS
|
||||
- K3s con instalación manual (control total)
|
||||
- Longhorn para storage HA
|
||||
- Gitea como plataforma todo-en-uno
|
||||
- Bun.serve() nativo (más simple que Express)
|
||||
|
||||
### Challenges superados 💪
|
||||
- Configurar red privada en K3s
|
||||
- TLS automático con Cert-Manager
|
||||
- Container Registry en Gitea
|
||||
- Gitea Actions Runner con DinD
|
||||
- Auto-migrations en la app
|
||||
|
||||
### Tips para futuras sesiones 💡
|
||||
- Port-forward solo para testing, nunca para migrations
|
||||
- Migrations deben ser automáticas en la app
|
||||
- Usar TCP probes en vez de exec para MariaDB
|
||||
- DinD necesita privileged + volumen compartido
|
||||
- Gitea Actions compatible con GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
## 📞 REFERENCIAS EXTERNAS
|
||||
|
||||
### Tecnologías
|
||||
- **Bun**: https://bun.sh/docs
|
||||
- **K3s**: https://docs.k3s.io
|
||||
- **Drizzle ORM**: https://orm.drizzle.team/docs
|
||||
- **Longhorn**: https://longhorn.io/docs/
|
||||
- **Gitea**: https://docs.gitea.com
|
||||
- **Cert-Manager**: https://cert-manager.io/docs/
|
||||
- **Lucia Auth**: https://github.com/lucia-auth/lucia
|
||||
- **Vercel Agent Skills**: https://github.com/vercel-labs/agent-skills
|
||||
|
||||
### APIs
|
||||
- **MCP Protocol**: `@modelcontextprotocol/sdk`
|
||||
- **Kubernetes**: `@kubernetes/client-node`
|
||||
- **Gitea API**: https://git.fuq.tv/api/swagger
|
||||
|
||||
---
|
||||
|
||||
## 🎯 OBJETIVO FINAL
|
||||
|
||||
Sistema completo de orquestación de agentes IA que automatiza:
|
||||
1. Usuario crea tarea en Dashboard
|
||||
2. Agente Claude Code toma tarea vía MCP
|
||||
3. Agente trabaja: código, commits, PR
|
||||
4. Deploy automático en preview environment
|
||||
5. Usuario aprueba → Staging → Production
|
||||
|
||||
**Todo automático, todo con HA, todo monitoreado.**
|
||||
|
||||
---
|
||||
|
||||
**💪 ¡Hemos construido bases sólidas! El siguiente paso más lógico es completar el Backend para tener la API funcional.**
|
||||
372
TROUBLESHOOTING.md
Normal file
372
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# 🔧 Troubleshooting Guide
|
||||
|
||||
Guía rápida de solución de problemas comunes.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 PROBLEMAS COMUNES
|
||||
|
||||
### 1. No puedo acceder al cluster
|
||||
|
||||
**Síntomas**: `kubectl` no conecta
|
||||
|
||||
**Solución**:
|
||||
```bash
|
||||
# Verificar kubeconfig
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl cluster-info
|
||||
|
||||
# Si falla, re-descargar
|
||||
ssh root@108.165.47.233 "cat /etc/rancher/k3s/k3s.yaml" | \
|
||||
sed 's/127.0.0.1/108.165.47.233/g' > ~/.kube/aiworker-config
|
||||
```
|
||||
|
||||
### 2. Pod en CrashLoopBackOff
|
||||
|
||||
**Síntomas**: Pod se reinicia constantemente
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Ver logs
|
||||
kubectl logs <pod-name> -n <namespace>
|
||||
|
||||
# Ver logs del contenedor anterior
|
||||
kubectl logs <pod-name> -n <namespace> --previous
|
||||
|
||||
# Describir pod
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
```
|
||||
|
||||
**Causas comunes**:
|
||||
- Variable de entorno faltante
|
||||
- Secret no existe
|
||||
- No puede conectar a DB
|
||||
- Puerto ya en uso
|
||||
|
||||
### 3. Ingress no resuelve (502/503/504)
|
||||
|
||||
**Síntomas**: URL da error de gateway
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Verificar Ingress
|
||||
kubectl get ingress -n <namespace>
|
||||
kubectl describe ingress <name> -n <namespace>
|
||||
|
||||
# Verificar Service
|
||||
kubectl get svc -n <namespace>
|
||||
kubectl get endpoints -n <namespace>
|
||||
|
||||
# Logs de Nginx Ingress
|
||||
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller --tail=100 | grep <domain>
|
||||
```
|
||||
|
||||
**Verificar**:
|
||||
- Service selector correcto
|
||||
- Pod está Running y Ready
|
||||
- Puerto correcto en Service
|
||||
|
||||
### 4. TLS Certificate no se emite
|
||||
|
||||
**Síntomas**: Certificado en estado `False`
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Ver certificado
|
||||
kubectl get certificate -n <namespace>
|
||||
kubectl describe certificate <name> -n <namespace>
|
||||
|
||||
# Ver CertificateRequest
|
||||
kubectl get certificaterequest -n <namespace>
|
||||
|
||||
# Ver Challenge (HTTP-01)
|
||||
kubectl get challenge -n <namespace>
|
||||
kubectl describe challenge <name> -n <namespace>
|
||||
|
||||
# Logs de cert-manager
|
||||
kubectl logs -n cert-manager deployment/cert-manager --tail=50
|
||||
```
|
||||
|
||||
**Causas comunes**:
|
||||
- DNS no apunta a los LBs
|
||||
- Firewall bloquea puerto 80
|
||||
- Ingress no tiene annotation de cert-manager
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
# Verificar DNS
|
||||
dig <domain> +short
|
||||
# Debe mostrar: 108.165.47.221, 108.165.47.203
|
||||
|
||||
# Delete y recreate certificate
|
||||
kubectl delete certificate <name> -n <namespace>
|
||||
kubectl delete secret <name> -n <namespace>
|
||||
# Recreate ingress
|
||||
```
|
||||
|
||||
### 5. PVC en estado Pending
|
||||
|
||||
**Síntomas**: PVC no se bindea
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Ver PVC
|
||||
kubectl get pvc -n <namespace>
|
||||
kubectl describe pvc <name> -n <namespace>
|
||||
|
||||
# Ver PVs disponibles
|
||||
kubectl get pv
|
||||
|
||||
# Ver Longhorn volumes
|
||||
kubectl get volumes.longhorn.io -n longhorn-system
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
# Ver Longhorn UI
|
||||
open https://longhorn.fuq.tv
|
||||
|
||||
# Logs de Longhorn
|
||||
kubectl logs -n longhorn-system daemonset/longhorn-manager --tail=50
|
||||
```
|
||||
|
||||
### 6. Gitea Actions no ejecuta
|
||||
|
||||
**Síntomas**: Workflow no se trigerea
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Ver runner
|
||||
kubectl get pods -n gitea-actions
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner --tail=100
|
||||
|
||||
# Ver en Gitea UI
|
||||
open https://git.fuq.tv/admin/aiworker-backend/actions
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
# Restart runner
|
||||
kubectl rollout restart deployment/gitea-runner -n gitea-actions
|
||||
|
||||
# Verificar runner registrado
|
||||
kubectl logs -n gitea-actions deployment/gitea-runner -c runner | grep "registered"
|
||||
|
||||
# Push de nuevo para triggear
|
||||
git commit --allow-empty -m "Trigger workflow"
|
||||
git push
|
||||
```
|
||||
|
||||
### 7. MariaDB no conecta
|
||||
|
||||
**Síntomas**: `Connection refused` o `Access denied`
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Verificar pod
|
||||
kubectl get pods -n control-plane mariadb-0
|
||||
|
||||
# Ver logs
|
||||
kubectl logs -n control-plane mariadb-0
|
||||
|
||||
# Test de conexión
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb -uaiworker -pAiWorker2026_UserPass! -e "SELECT 1"
|
||||
```
|
||||
|
||||
**Credenciales correctas**:
|
||||
```
|
||||
Host: mariadb.control-plane.svc.cluster.local
|
||||
Port: 3306
|
||||
User: aiworker
|
||||
Pass: AiWorker2026_UserPass!
|
||||
DB: aiworker
|
||||
```
|
||||
|
||||
### 8. Load Balancer no responde
|
||||
|
||||
**Síntomas**: `curl https://<domain>` timeout
|
||||
|
||||
**Diagnóstico**:
|
||||
```bash
|
||||
# Verificar HAProxy
|
||||
ssh root@108.165.47.221 "systemctl status haproxy"
|
||||
ssh root@108.165.47.203 "systemctl status haproxy"
|
||||
|
||||
# Ver stats
|
||||
open http://108.165.47.221:8404/stats
|
||||
# Usuario: admin / aiworker2026
|
||||
|
||||
# Test directo a worker
|
||||
curl http://108.165.47.225:32388 # NodePort de Ingress
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
# Restart HAProxy
|
||||
ssh root@108.165.47.221 "systemctl restart haproxy"
|
||||
ssh root@108.165.47.203 "systemctl restart haproxy"
|
||||
|
||||
# Verificar config
|
||||
ssh root@108.165.47.221 "cat /etc/haproxy/haproxy.cfg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 COMANDOS DE DIAGNÓSTICO GENERAL
|
||||
|
||||
### Estado del Cluster
|
||||
```bash
|
||||
# Nodos
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Recursos
|
||||
kubectl top nodes
|
||||
kubectl top pods -A
|
||||
|
||||
# Eventos recientes
|
||||
kubectl get events -A --sort-by='.lastTimestamp' | tail -30
|
||||
|
||||
# Pods con problemas
|
||||
kubectl get pods -A | grep -v Running
|
||||
```
|
||||
|
||||
### Verificar Conectividad
|
||||
|
||||
```bash
|
||||
# Desde un pod a otro servicio
|
||||
kubectl run -it --rm debug --image=busybox --restart=Never -- sh
|
||||
# Dentro del pod:
|
||||
wget -O- http://mariadb.control-plane.svc.cluster.local:3306
|
||||
```
|
||||
|
||||
### Limpiar Recursos
|
||||
|
||||
```bash
|
||||
# Pods completados/fallidos
|
||||
kubectl delete pods --field-selector=status.phase=Failed -A
|
||||
kubectl delete pods --field-selector=status.phase=Succeeded -A
|
||||
|
||||
# Preview namespaces viejos
|
||||
kubectl get ns -l environment=preview
|
||||
kubectl delete ns <preview-namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 CONTACTOS Y RECURSOS
|
||||
|
||||
### Soporte
|
||||
- **CubePath**: https://cubepath.com/support
|
||||
- **K3s Issues**: https://github.com/k3s-io/k3s/issues
|
||||
- **Gitea**: https://discourse.gitea.io
|
||||
|
||||
### Logs Centrales
|
||||
```bash
|
||||
# Todos los errores recientes
|
||||
kubectl get events -A --sort-by='.lastTimestamp' | grep -i error | tail -20
|
||||
```
|
||||
|
||||
### Backup Rápido
|
||||
```bash
|
||||
# Export toda la configuración
|
||||
kubectl get all,ingress,certificate,pvc,secret -A -o yaml > cluster-backup.yaml
|
||||
|
||||
# Backup MariaDB
|
||||
kubectl exec -n control-plane mariadb-0 -- \
|
||||
mariadb-dump -uroot -pAiWorker2026_RootPass! --all-databases > backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 EMERGENCY PROCEDURES
|
||||
|
||||
### Cluster no responde
|
||||
```bash
|
||||
# SSH a control plane
|
||||
ssh root@108.165.47.233
|
||||
|
||||
# Ver K3s
|
||||
systemctl status k3s
|
||||
journalctl -u k3s -n 100
|
||||
|
||||
# Restart K3s (último recurso)
|
||||
systemctl restart k3s
|
||||
```
|
||||
|
||||
### Nodo caído
|
||||
```bash
|
||||
# Cordon (evitar scheduling)
|
||||
kubectl cordon <node-name>
|
||||
|
||||
# Drain (mover pods)
|
||||
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
|
||||
|
||||
# Investigar en el nodo
|
||||
ssh root@<node-ip>
|
||||
systemctl status k3s-agent
|
||||
```
|
||||
|
||||
### Storage corruption
|
||||
```bash
|
||||
# Ver Longhorn UI
|
||||
open https://longhorn.fuq.tv
|
||||
|
||||
# Ver réplicas
|
||||
kubectl get replicas.longhorn.io -n longhorn-system
|
||||
|
||||
# Restore desde snapshot (si existe)
|
||||
# Via Longhorn UI → Volume → Create from Snapshot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 TIPS
|
||||
|
||||
### Desarrollo Rápido
|
||||
```bash
|
||||
# Auto-reload en backend
|
||||
bun --watch src/index.ts
|
||||
|
||||
# Ver logs en tiempo real
|
||||
kubectl logs -f deployment/backend -n control-plane
|
||||
|
||||
# Port-forward para testing
|
||||
kubectl port-forward svc/backend 3000:3000 -n control-plane
|
||||
```
|
||||
|
||||
### Debug de Networking
|
||||
```bash
|
||||
# Test desde fuera del cluster
|
||||
curl -v https://api.fuq.tv
|
||||
|
||||
# Test desde dentro del cluster
|
||||
kubectl run curl --image=curlimages/curl -it --rm -- sh
|
||||
curl http://backend.control-plane.svc.cluster.local:3000/api/health
|
||||
```
|
||||
|
||||
### Performance
|
||||
```bash
|
||||
# Ver uso de recursos
|
||||
kubectl top pods -n control-plane
|
||||
kubectl top nodes
|
||||
|
||||
# Ver pods que más consumen
|
||||
kubectl top pods -A --sort-by=memory
|
||||
kubectl top pods -A --sort-by=cpu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 ENLACES RÁPIDOS
|
||||
|
||||
- **Cluster Info**: `CLUSTER-READY.md`
|
||||
- **Credenciales**: `CLUSTER-CREDENTIALS.md`
|
||||
- **Roadmap**: `ROADMAP.md`
|
||||
- **Próxima sesión**: `NEXT-SESSION.md`
|
||||
- **Guía para agentes**: `AGENT-GUIDE.md`
|
||||
- **Container Registry**: `docs/CONTAINER-REGISTRY.md`
|
||||
|
||||
---
|
||||
|
||||
**Si nada de esto funciona, revisa los docs completos en `/docs` o contacta con el equipo.**
|
||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at ebf5d74933
316
docs/01-arquitectura/flujo-de-datos.md
Normal file
316
docs/01-arquitectura/flujo-de-datos.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Flujo de Datos
|
||||
|
||||
## Arquitectura de Comunicación
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Frontend │ ◄─────► │ Backend │ ◄─────► │ MySQL │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
│ │ │
|
||||
│ ├──────────────────────┤
|
||||
│ │ Redis │
|
||||
│ └──────────────────────┘
|
||||
│ │
|
||||
│ ┌─────┴─────┐
|
||||
│ │ │
|
||||
│ ┌────▼────┐ ┌───▼────┐
|
||||
│ │ Gitea │ │ K8s │
|
||||
│ └─────────┘ └───┬────┘
|
||||
│ │
|
||||
│ ┌────▼────────┐
|
||||
└────────────────────┤ Claude Code │
|
||||
WebSocket │ Agents │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## 1. Flujo Completo: Creación de Tarea
|
||||
|
||||
### 1.1 Usuario Crea Tarea
|
||||
|
||||
```
|
||||
Frontend Backend MySQL Redis
|
||||
│ │ │ │
|
||||
│ POST /api/tasks │ │ │
|
||||
├──────────────────────►│ │ │
|
||||
│ │ INSERT task │ │
|
||||
│ ├──────────────────►│ │
|
||||
│ │ │ │
|
||||
│ │ PUBLISH task.new │ │
|
||||
│ ├───────────────────┼────────────────►│
|
||||
│ │ │ │
|
||||
│ { taskId, status } │ │ │
|
||||
│◄──────────────────────┤ │ │
|
||||
│ │ │ │
|
||||
│ WS: task_created │ │ │
|
||||
│◄──────────────────────┤ │ │
|
||||
```
|
||||
|
||||
**Detalle**:
|
||||
1. Frontend envía POST a `/api/tasks` con JSON:
|
||||
```json
|
||||
{
|
||||
"projectId": "uuid",
|
||||
"title": "Implementar login",
|
||||
"description": "Crear sistema de autenticación..."
|
||||
}
|
||||
```
|
||||
|
||||
2. Backend:
|
||||
- Valida datos
|
||||
- Inserta en MySQL tabla `tasks`
|
||||
- Publica evento en Redis: `task:new`
|
||||
- Añade job a cola BullMQ: `task-queue`
|
||||
- Responde con task creada
|
||||
|
||||
3. WebSocket notifica a todos los clientes conectados
|
||||
|
||||
### 1.2 Agente Toma Tarea
|
||||
|
||||
```
|
||||
Agent (K8s) Backend (MCP) MySQL BullMQ
|
||||
│ │ │ │
|
||||
│ MCP: get_next_task │ │ │
|
||||
├──────────────────────►│ │ │
|
||||
│ │ SELECT task │ │
|
||||
│ ├──────────────────►│ │
|
||||
│ │ │ │
|
||||
│ │ UPDATE status │ │
|
||||
│ ├──────────────────►│ │
|
||||
│ │ │ │
|
||||
│ { task details } │ DEQUEUE job │ │
|
||||
│◄──────────────────────┤◄─────────────────┼─────────────────┤
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
**Detalle**:
|
||||
1. Agente llama herramienta MCP `get_next_task`
|
||||
2. Backend:
|
||||
- Query: `SELECT * FROM tasks WHERE state='backlog' ORDER BY created_at LIMIT 1`
|
||||
- Actualiza: `UPDATE tasks SET state='in_progress', assigned_agent_id=?`
|
||||
- Elimina job de BullMQ
|
||||
3. Responde con detalles completos de la tarea
|
||||
|
||||
## 2. Flujo: Agente Pide Información
|
||||
|
||||
```
|
||||
Agent Backend (MCP) MySQL Frontend (WS)
|
||||
│ │ │ │
|
||||
│ ask_user_question │ │ │
|
||||
├─────────────────────►│ │ │
|
||||
│ │ UPDATE task │ │
|
||||
│ ├──────────────────►│ │
|
||||
│ │ state=needs_input │ │
|
||||
│ │ │ │
|
||||
│ │ INSERT question │ │
|
||||
│ ├──────────────────►│ │
|
||||
│ │ │ │
|
||||
│ { success: true } │ WS: needs_input │ │
|
||||
│◄─────────────────────┤──────────────────┼───────────────────►│
|
||||
│ │ │ │
|
||||
│ │ │ [Usuario ve] │
|
||||
│ │ │ [notificación] │
|
||||
│ │ │ │
|
||||
│ │ POST /api/tasks/ │ │
|
||||
│ │ :id/respond │ │
|
||||
│ │◄──────────────────┼────────────────────┤
|
||||
│ │ │ │
|
||||
│ MCP: check_response │ UPDATE response │ │
|
||||
├─────────────────────►├──────────────────►│ │
|
||||
│ │ state=in_progress │ │
|
||||
│ { response: "..." } │ │ │
|
||||
│◄─────────────────────┤ │ │
|
||||
```
|
||||
|
||||
**Detalle**:
|
||||
1. Agente detecta necesita info (ej: "¿Qué librería usar para auth?")
|
||||
2. Llama `ask_user_question(taskId, question)`
|
||||
3. Backend:
|
||||
- Actualiza `tasks.state = 'needs_input'`
|
||||
- Inserta en tabla `task_questions`
|
||||
- Emite WebSocket `task:needs_input`
|
||||
4. Frontend muestra notificación y badge en kanban
|
||||
5. Usuario responde vía UI
|
||||
6. Backend guarda respuesta
|
||||
7. Agente hace polling o recibe notificación vía MCP
|
||||
|
||||
## 3. Flujo: Completar Tarea y Deploy Preview
|
||||
|
||||
```
|
||||
Agent Backend(MCP) Gitea API MySQL K8s API Frontend
|
||||
│ │ │ │ │ │
|
||||
│ create_branch │ │ │ │ │
|
||||
├─────────────────►│ │ │ │ │
|
||||
│ │ POST /repos/│ │ │ │
|
||||
│ │ :owner/:repo│ │ │ │
|
||||
│ │ /branches │ │ │ │
|
||||
│ ├────────────►│ │ │ │
|
||||
│ { branch } │ │ │ │ │
|
||||
│◄─────────────────┤ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ [agent works] │ │ │ │ │
|
||||
│ [commits code] │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ create_pr │ │ │ │ │
|
||||
├─────────────────►│ │ │ │ │
|
||||
│ │ POST /pulls │ │ │ │
|
||||
│ ├────────────►│ │ │ │
|
||||
│ { pr_url } │ │ │ │ │
|
||||
│◄─────────────────┤ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ trigger_preview │ │ │ │ │
|
||||
├─────────────────►│ │ │ │ │
|
||||
│ │ UPDATE task │ │ │ │
|
||||
│ ├────────────┼────────────►│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ CREATE │ │ CREATE │ │
|
||||
│ │ namespace │ │ Deployment │
|
||||
│ ├────────────┼────────────┼──────────►│ │
|
||||
│ │ │ │ │ │
|
||||
│ { preview_url } │ │ WS:ready_to_test │ │
|
||||
│◄─────────────────┤─────────────┼───────────┼───────────┼───────────►│
|
||||
```
|
||||
|
||||
**Detalle**:
|
||||
1. **create_branch**: Backend usa Gitea API para crear rama `task-{id}-feature`
|
||||
2. **Agente trabaja**: Clone, cambios, commits, push
|
||||
3. **create_pr**: Crea PR con descripción generada
|
||||
4. **trigger_preview**:
|
||||
- Backend crea namespace K8s: `preview-task-{id}`
|
||||
- Aplica deployment con imagen del proyecto
|
||||
- Configura ingress con URL: `task-{id}.preview.aiworker.dev`
|
||||
- Actualiza `tasks.state = 'ready_to_test'`
|
||||
5. Frontend muestra botón "Ver Preview" con URL
|
||||
|
||||
## 4. Flujo: Merge a Staging
|
||||
|
||||
```
|
||||
User (Frontend) Backend Gitea API K8s API ArgoCD
|
||||
│ │ │ │ │
|
||||
│ POST /merge │ │ │ │
|
||||
│ taskIds[] │ │ │ │
|
||||
├──────────────►│ │ │ │
|
||||
│ │ Validate │ │ │
|
||||
│ │ all approved │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ POST /pulls │ │ │
|
||||
│ │ (merge PRs) │ │ │
|
||||
│ ├──────────────►│ │ │
|
||||
│ │ │ │ │
|
||||
│ │ POST /branches│ │ │
|
||||
│ │ staging │ │ │
|
||||
│ ├──────────────►│ │ │
|
||||
│ │ │ │ │
|
||||
│ │ Trigger │ Apply │ │
|
||||
│ │ ArgoCD sync │ manifests │ │
|
||||
│ ├───────────────┼──────────────┼────────────►│
|
||||
│ │ │ │ │
|
||||
│ { status } │ │ │ [Deploys] │
|
||||
│◄──────────────┤ │ │ │
|
||||
```
|
||||
|
||||
**Detalle**:
|
||||
1. Usuario selecciona 2-3 tareas aprobadas
|
||||
2. Click "Merge a Staging"
|
||||
3. Backend:
|
||||
- Valida todas están en estado `approved`
|
||||
- Mergea cada PR a `staging` branch
|
||||
- Actualiza estado a `staging`
|
||||
- Triggerea ArgoCD sync
|
||||
4. ArgoCD detecta cambios y deploya a namespace `staging`
|
||||
|
||||
## 5. Comunicación Real-Time (WebSocket)
|
||||
|
||||
### Eventos emitidos por Backend:
|
||||
|
||||
```typescript
|
||||
// Usuario se conecta
|
||||
socket.on('connect', () => {
|
||||
socket.emit('auth', { userId, token })
|
||||
})
|
||||
|
||||
// Backend emite eventos
|
||||
socket.emit('task:created', { taskId, projectId })
|
||||
socket.emit('task:status_changed', { taskId, oldState, newState })
|
||||
socket.emit('task:needs_input', { taskId, question })
|
||||
socket.emit('task:ready_to_test', { taskId, previewUrl })
|
||||
socket.emit('agent:status', { agentId, status, currentTaskId })
|
||||
socket.emit('deploy:started', { environment, taskIds })
|
||||
socket.emit('deploy:completed', { environment, url })
|
||||
```
|
||||
|
||||
### Cliente subscribe:
|
||||
|
||||
```typescript
|
||||
socket.on('task:status_changed', (data) => {
|
||||
// Actualiza UI del kanban
|
||||
queryClient.invalidateQueries(['tasks'])
|
||||
})
|
||||
|
||||
socket.on('task:needs_input', (data) => {
|
||||
// Muestra notificación
|
||||
toast.info('Un agente necesita tu ayuda')
|
||||
// Mueve card a columna "Needs Input"
|
||||
})
|
||||
```
|
||||
|
||||
## 6. Caching Strategy
|
||||
|
||||
### Redis Cache Keys:
|
||||
|
||||
```
|
||||
task:{id} → TTL 5min (task details)
|
||||
task:list:{projectId} → TTL 2min (lista de tareas)
|
||||
agent:{id}:status → TTL 30s (estado agente)
|
||||
project:{id} → TTL 10min (config proyecto)
|
||||
```
|
||||
|
||||
### Invalidación:
|
||||
|
||||
```typescript
|
||||
// Al actualizar tarea
|
||||
await redis.del(`task:${taskId}`)
|
||||
await redis.del(`task:list:${projectId}`)
|
||||
|
||||
// Al cambiar estado agente
|
||||
await redis.setex(`agent:${agentId}:status`, 30, status)
|
||||
```
|
||||
|
||||
## 7. Queue System (BullMQ)
|
||||
|
||||
### Colas:
|
||||
|
||||
```
|
||||
task-queue → Tareas pendientes de asignar
|
||||
deploy-queue → Deploys a ejecutar
|
||||
merge-queue → Merges programados
|
||||
cleanup-queue → Limpieza de preview envs antiguos
|
||||
```
|
||||
|
||||
### Workers:
|
||||
|
||||
```typescript
|
||||
// task-worker.ts
|
||||
taskQueue.process(async (job) => {
|
||||
const { taskId } = job.data
|
||||
// Notifica agentes disponibles vía MCP
|
||||
await notifyAgents({ taskId })
|
||||
})
|
||||
|
||||
// deploy-worker.ts
|
||||
deployQueue.process(async (job) => {
|
||||
const { taskId, environment } = job.data
|
||||
await k8sClient.createDeployment(...)
|
||||
})
|
||||
```
|
||||
|
||||
## Resumen de Protocolos
|
||||
|
||||
| Comunicación | Protocolo | Uso |
|
||||
|--------------|-----------|-----|
|
||||
| Frontend ↔ Backend | HTTP REST + WebSocket | CRUD + Real-time |
|
||||
| Backend ↔ MySQL | TCP/MySQL Protocol | Persistencia |
|
||||
| Backend ↔ Redis | RESP | Cache + PubSub |
|
||||
| Backend ↔ Gitea | HTTP REST | Git operations |
|
||||
| Backend ↔ K8s | HTTP + Kubernetes API | Orquestación |
|
||||
| Backend ↔ Agents | MCP (stdio/HTTP) | Herramientas |
|
||||
| Agents ↔ Gitea | Git Protocol (SSH) | Clone/Push |
|
||||
430
docs/01-arquitectura/modelo-datos.md
Normal file
430
docs/01-arquitectura/modelo-datos.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Modelo de Datos (MySQL)
|
||||
|
||||
## Diagrama ER
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Projects │───────│ Tasks │───────│ Agents │
|
||||
└─────────────┘ 1:N └─────────────┘ N:1 └─────────────┘
|
||||
│ 1:N
|
||||
│
|
||||
┌────▼────────┐
|
||||
│ Questions │
|
||||
└─────────────┘
|
||||
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ TaskGroups │───────│ Deploys │
|
||||
└─────────────┘ 1:N └─────────────┘
|
||||
```
|
||||
|
||||
## Schema SQL
|
||||
|
||||
### Tabla: projects
|
||||
|
||||
```sql
|
||||
CREATE TABLE projects (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Gitea integration
|
||||
gitea_repo_id INT,
|
||||
gitea_repo_url VARCHAR(512),
|
||||
gitea_owner VARCHAR(100),
|
||||
gitea_repo_name VARCHAR(100),
|
||||
default_branch VARCHAR(100) DEFAULT 'main',
|
||||
|
||||
-- Kubernetes
|
||||
k8s_namespace VARCHAR(63) NOT NULL UNIQUE,
|
||||
|
||||
-- Infrastructure config (JSON)
|
||||
docker_image VARCHAR(512),
|
||||
env_vars JSON,
|
||||
replicas INT DEFAULT 1,
|
||||
cpu_limit VARCHAR(20) DEFAULT '500m',
|
||||
memory_limit VARCHAR(20) DEFAULT '512Mi',
|
||||
|
||||
-- MCP config (JSON)
|
||||
mcp_tools JSON,
|
||||
mcp_permissions JSON,
|
||||
|
||||
-- Status
|
||||
status ENUM('active', 'paused', 'archived') DEFAULT 'active',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_k8s_namespace (k8s_namespace)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: tasks
|
||||
|
||||
```sql
|
||||
CREATE TABLE tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
|
||||
-- Task info
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
priority ENUM('low', 'medium', 'high', 'urgent') DEFAULT 'medium',
|
||||
|
||||
-- State machine
|
||||
state ENUM(
|
||||
'backlog',
|
||||
'in_progress',
|
||||
'needs_input',
|
||||
'ready_to_test',
|
||||
'approved',
|
||||
'staging',
|
||||
'production',
|
||||
'cancelled'
|
||||
) DEFAULT 'backlog',
|
||||
|
||||
-- Assignment
|
||||
assigned_agent_id VARCHAR(36),
|
||||
assigned_at TIMESTAMP NULL,
|
||||
|
||||
-- Git info
|
||||
branch_name VARCHAR(255),
|
||||
pr_number INT,
|
||||
pr_url VARCHAR(512),
|
||||
|
||||
-- Preview deployment
|
||||
preview_namespace VARCHAR(63),
|
||||
preview_url VARCHAR(512),
|
||||
preview_deployed_at TIMESTAMP NULL,
|
||||
|
||||
-- Metadata
|
||||
estimated_complexity ENUM('trivial', 'simple', 'medium', 'complex') DEFAULT 'medium',
|
||||
actual_duration_minutes INT,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
deployed_staging_at TIMESTAMP NULL,
|
||||
deployed_production_at TIMESTAMP NULL,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_agent_id) REFERENCES agents(id) ON DELETE SET NULL,
|
||||
|
||||
INDEX idx_project_state (project_id, state),
|
||||
INDEX idx_state (state),
|
||||
INDEX idx_assigned_agent (assigned_agent_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: task_questions
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_questions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
|
||||
-- Question
|
||||
question TEXT NOT NULL,
|
||||
context TEXT,
|
||||
asked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Response
|
||||
response TEXT,
|
||||
responded_at TIMESTAMP NULL,
|
||||
responded_by VARCHAR(36),
|
||||
|
||||
-- Status
|
||||
status ENUM('pending', 'answered', 'skipped') DEFAULT 'pending',
|
||||
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_task_status (task_id, status),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: agents
|
||||
|
||||
```sql
|
||||
CREATE TABLE agents (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
|
||||
-- K8s info
|
||||
pod_name VARCHAR(253) NOT NULL UNIQUE,
|
||||
k8s_namespace VARCHAR(63) DEFAULT 'agents',
|
||||
node_name VARCHAR(253),
|
||||
|
||||
-- Status
|
||||
status ENUM('idle', 'busy', 'error', 'offline', 'initializing') DEFAULT 'initializing',
|
||||
current_task_id VARCHAR(36),
|
||||
|
||||
-- Capabilities
|
||||
capabilities JSON, -- ['javascript', 'python', 'react', ...]
|
||||
max_concurrent_tasks INT DEFAULT 1,
|
||||
|
||||
-- Health
|
||||
last_heartbeat TIMESTAMP NULL,
|
||||
error_message TEXT,
|
||||
restarts_count INT DEFAULT 0,
|
||||
|
||||
-- Metrics
|
||||
tasks_completed INT DEFAULT 0,
|
||||
total_runtime_minutes INT DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (current_task_id) REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_pod_name (pod_name),
|
||||
INDEX idx_last_heartbeat (last_heartbeat)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: task_groups
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_groups (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
|
||||
-- Grouping
|
||||
task_ids JSON NOT NULL, -- ['task-id-1', 'task-id-2', ...]
|
||||
|
||||
-- Staging
|
||||
staging_branch VARCHAR(255),
|
||||
staging_pr_number INT,
|
||||
staging_pr_url VARCHAR(512),
|
||||
staging_deployed_at TIMESTAMP NULL,
|
||||
|
||||
-- Production
|
||||
production_deployed_at TIMESTAMP NULL,
|
||||
production_rollback_available BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Status
|
||||
status ENUM('pending', 'staging', 'production', 'rolled_back') DEFAULT 'pending',
|
||||
|
||||
-- Metadata
|
||||
created_by VARCHAR(36),
|
||||
notes TEXT,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_project_status (project_id, status),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: deployments
|
||||
|
||||
```sql
|
||||
CREATE TABLE deployments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
task_group_id VARCHAR(36),
|
||||
|
||||
-- Deployment info
|
||||
environment ENUM('preview', 'staging', 'production') NOT NULL,
|
||||
deployment_type ENUM('manual', 'automatic', 'rollback') DEFAULT 'manual',
|
||||
|
||||
-- Git info
|
||||
branch VARCHAR(255),
|
||||
commit_hash VARCHAR(40),
|
||||
|
||||
-- K8s info
|
||||
k8s_namespace VARCHAR(63),
|
||||
k8s_deployment_name VARCHAR(253),
|
||||
image_tag VARCHAR(255),
|
||||
|
||||
-- Status
|
||||
status ENUM('pending', 'in_progress', 'completed', 'failed', 'rolled_back') DEFAULT 'pending',
|
||||
|
||||
-- Results
|
||||
url VARCHAR(512),
|
||||
error_message TEXT,
|
||||
logs TEXT,
|
||||
|
||||
-- Timing
|
||||
started_at TIMESTAMP NULL,
|
||||
completed_at TIMESTAMP NULL,
|
||||
duration_seconds INT,
|
||||
|
||||
-- Metadata
|
||||
triggered_by VARCHAR(36),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_group_id) REFERENCES task_groups(id) ON DELETE SET NULL,
|
||||
|
||||
INDEX idx_project_env (project_id, environment),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Tabla: agent_logs
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_logs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
agent_id VARCHAR(36) NOT NULL,
|
||||
task_id VARCHAR(36),
|
||||
|
||||
-- Log entry
|
||||
level ENUM('debug', 'info', 'warn', 'error') DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
metadata JSON,
|
||||
|
||||
-- Timestamp
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
|
||||
INDEX idx_agent_created (agent_id, created_at),
|
||||
INDEX idx_task_created (task_id, created_at),
|
||||
INDEX idx_level (level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## Índices y Optimizaciones
|
||||
|
||||
### Índices Compuestos Importantes
|
||||
|
||||
```sql
|
||||
-- Búsqueda de tareas por proyecto y estado
|
||||
CREATE INDEX idx_tasks_project_state ON tasks(project_id, state, created_at);
|
||||
|
||||
-- Búsqueda de agentes disponibles
|
||||
CREATE INDEX idx_agents_available ON agents(status, last_heartbeat)
|
||||
WHERE status = 'idle';
|
||||
|
||||
-- Logs recientes por agente
|
||||
CREATE INDEX idx_agent_logs_recent ON agent_logs(agent_id, created_at DESC)
|
||||
USING BTREE;
|
||||
```
|
||||
|
||||
### Particionamiento (para logs)
|
||||
|
||||
```sql
|
||||
-- Particionar agent_logs por mes
|
||||
ALTER TABLE agent_logs PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
|
||||
PARTITION p202601 VALUES LESS THAN (202602),
|
||||
PARTITION p202602 VALUES LESS THAN (202603),
|
||||
PARTITION p202603 VALUES LESS THAN (202604),
|
||||
-- ... auto-crear con script
|
||||
PARTITION p_future VALUES LESS THAN MAXVALUE
|
||||
);
|
||||
```
|
||||
|
||||
## Queries Comunes
|
||||
|
||||
### Obtener siguiente tarea disponible
|
||||
|
||||
```sql
|
||||
SELECT * FROM tasks
|
||||
WHERE state = 'backlog'
|
||||
AND project_id = ?
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED;
|
||||
```
|
||||
|
||||
### Agentes disponibles
|
||||
|
||||
```sql
|
||||
SELECT * FROM agents
|
||||
WHERE status = 'idle'
|
||||
AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 60 SECOND)
|
||||
ORDER BY tasks_completed ASC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Dashboard: Métricas de proyecto
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) as total_tasks,
|
||||
SUM(CASE WHEN state = 'backlog' THEN 1 ELSE 0 END) as backlog,
|
||||
SUM(CASE WHEN state = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN state = 'needs_input' THEN 1 ELSE 0 END) as needs_input,
|
||||
SUM(CASE WHEN state = 'ready_to_test' THEN 1 ELSE 0 END) as ready_to_test,
|
||||
SUM(CASE WHEN state = 'production' THEN 1 ELSE 0 END) as completed,
|
||||
AVG(actual_duration_minutes) as avg_duration
|
||||
FROM tasks
|
||||
WHERE project_id = ?;
|
||||
```
|
||||
|
||||
### Historial de deployments
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
d.*,
|
||||
tg.task_ids,
|
||||
COUNT(t.id) as tasks_count
|
||||
FROM deployments d
|
||||
LEFT JOIN task_groups tg ON d.task_group_id = tg.id
|
||||
LEFT JOIN tasks t ON JSON_CONTAINS(tg.task_ids, CONCAT('"', t.id, '"'))
|
||||
WHERE d.project_id = ?
|
||||
AND d.environment = 'production'
|
||||
GROUP BY d.id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
## Migraciones con Drizzle
|
||||
|
||||
```typescript
|
||||
// drizzle/schema.ts
|
||||
import { mysqlTable, varchar, text, timestamp, json, int, mysqlEnum } from 'drizzle-orm/mysql-core'
|
||||
|
||||
export const projects = mysqlTable('projects', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
giteaRepoId: int('gitea_repo_id'),
|
||||
giteaRepoUrl: varchar('gitea_repo_url', { length: 512 }),
|
||||
// ... resto campos
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
})
|
||||
|
||||
export const tasks = mysqlTable('tasks', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 36 }).notNull().references(() => projects.id),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
state: mysqlEnum('state', [
|
||||
'backlog', 'in_progress', 'needs_input',
|
||||
'ready_to_test', 'approved', 'staging', 'production', 'cancelled'
|
||||
]).default('backlog'),
|
||||
// ... resto campos
|
||||
})
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
```bash
|
||||
# Daily backup
|
||||
mysqldump -u root -p aiworker \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--lock-tables=false \
|
||||
> backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Restore
|
||||
mysql -u root -p aiworker < backup-20260119.sql
|
||||
```
|
||||
140
docs/01-arquitectura/overview.md
Normal file
140
docs/01-arquitectura/overview.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Overview General - AiWorker
|
||||
|
||||
## Concepto
|
||||
|
||||
AiWorker es un sistema de orquestación de agentes IA que automatiza el ciclo completo de desarrollo de software mediante:
|
||||
|
||||
1. **Dashboard Web**: Interfaz central para gestionar proyectos y tareas
|
||||
2. **Consolas Web Persistentes**: Terminales web conectadas a pods de Claude Code en K8s
|
||||
3. **Kanban Board Inteligente**: Gestión visual de tareas con estados automáticos
|
||||
4. **Agentes Autónomos**: Claude Code trabajando en tareas asignadas
|
||||
5. **Deployments Automatizados**: Preview, staging y producción orquestados
|
||||
|
||||
## Arquitectura de Alto Nivel
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dashboard Web │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Kanban │ │ Consolas │ │ Project │ │
|
||||
│ │ Board │ │ Web │ │ Manager │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ HTTP/WebSocket
|
||||
┌────────────────────────▼────────────────────────────────────────┐
|
||||
│ Backend (Bun + Express) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ API │ │ MCP │ │ Gitea │ │ K8s │ │
|
||||
│ │ Server │ │ Server │ │ Client │ │ Client │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└────────┬───────────────┬─────────────┬─────────────┬───────────┘
|
||||
│ │ │ │
|
||||
┌────▼────┐ ┌───▼────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ MySQL │ │ Redis │ │ Gitea │ │ K8s │
|
||||
└─────────┘ └────────┘ └────────┘ └──────────┘
|
||||
│
|
||||
┌───────────────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────────────────────────────────┐
|
||||
│ Kubernetes Cluster │
|
||||
│ ┌──────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Agents │ │ Project Namespaces │ │
|
||||
│ │ Namespace │ │ ├── dev │ │
|
||||
│ │ │ │ ├── preview/<task-id> │ │
|
||||
│ │ Claude Code │ │ ├── staging │ │
|
||||
│ │ Pods │ │ └── production │ │
|
||||
│ └──────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Componentes Principales
|
||||
|
||||
### 1. Dashboard Web (Frontend)
|
||||
- **Tecnología**: React 19.2 + TailwindCSS + Vite
|
||||
- **Funciones**:
|
||||
- Kanban board para gestión de tareas
|
||||
- Consolas web interactivas (xterm.js)
|
||||
- Gestión de proyectos
|
||||
- Monitoring en tiempo real
|
||||
|
||||
### 2. Backend API
|
||||
- **Tecnología**: Bun 1.3.6 + Express + TypeScript
|
||||
- **Funciones**:
|
||||
- API REST para frontend
|
||||
- MCP Server para agentes
|
||||
- Orquestación de tareas
|
||||
- Integración con Gitea y K8s
|
||||
|
||||
### 3. Base de Datos
|
||||
- **MySQL 8.0**: Almacenamiento persistente
|
||||
- **Redis**: Colas, cache, pub/sub
|
||||
|
||||
### 4. Gitea
|
||||
- **Servidor Git auto-alojado**
|
||||
- **API compatible con GitHub**
|
||||
- **Gestión de repos, branches, PRs**
|
||||
|
||||
### 5. Kubernetes Cluster
|
||||
- **Orquestación de contenedores**
|
||||
- **Namespaces por proyecto y entorno**
|
||||
- **Auto-scaling de agentes**
|
||||
|
||||
### 6. Claude Code Agents
|
||||
- **Pods persistentes en K8s**
|
||||
- **Conectados vía MCP Server**
|
||||
- **Workspace aislado por agente**
|
||||
|
||||
## Estados de Tareas
|
||||
|
||||
```
|
||||
Backlog → En Progreso → Necesita Respuestas
|
||||
↓
|
||||
Usuario responde
|
||||
↓
|
||||
┌───────────────┘
|
||||
↓
|
||||
Listo para Probar
|
||||
↓
|
||||
(Preview deploy)
|
||||
↓
|
||||
Aprobado
|
||||
↓
|
||||
Staging (merge grupal)
|
||||
↓
|
||||
Producción
|
||||
```
|
||||
|
||||
## Flujo de Trabajo Típico
|
||||
|
||||
1. **Usuario crea proyecto** → Sistema crea repo en Gitea + namespace en K8s
|
||||
2. **Usuario crea tareas** → Se añaden al backlog del kanban
|
||||
3. **Agente disponible** → Toma siguiente tarea vía MCP
|
||||
4. **Agente trabaja** → Clone, branch, código, commits
|
||||
5. **¿Necesita info?** → Cambia estado a "Necesita Respuestas"
|
||||
6. **Completa tarea** → Push + PR + deploy preview
|
||||
7. **Usuario prueba** → En ambiente preview aislado
|
||||
8. **Aprueba** → Marca para staging
|
||||
9. **Merge grupal** → Agrega 2-3 tareas + merge a staging
|
||||
10. **Deploy staging** → Tests automáticos
|
||||
11. **Deploy producción** → Aprobación final
|
||||
|
||||
## Ventajas del Sistema
|
||||
|
||||
✅ **Automatización completa**: Desde tarea hasta producción
|
||||
✅ **Aislamiento**: Cada tarea en su propio preview environment
|
||||
✅ **Trazabilidad**: Todo cambio vinculado a tarea y PR
|
||||
✅ **Escalabilidad**: Agentes auto-escalables en K8s
|
||||
✅ **Flexibilidad**: Agentes pueden pedir ayuda al usuario
|
||||
✅ **Control**: Usuario aprueba cada fase importante
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Namespaces aislados en K8s
|
||||
- RBAC por agente
|
||||
- Secrets management
|
||||
- Network policies
|
||||
- Auditoría de acciones
|
||||
|
||||
## Próximos Pasos
|
||||
|
||||
Ver documentación específica de cada componente en las secciones correspondientes.
|
||||
208
docs/01-arquitectura/stack-tecnologico.md
Normal file
208
docs/01-arquitectura/stack-tecnologico.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Stack Tecnológico
|
||||
|
||||
## Frontend
|
||||
|
||||
### Core
|
||||
- **React 19.2**: Framework UI principal
|
||||
- **Vite**: Build tool y dev server
|
||||
- **TypeScript**: Type safety
|
||||
- **TailwindCSS 4.x**: Styling utility-first
|
||||
|
||||
### Librerías UI
|
||||
- **@dnd-kit/core**: Drag and drop para kanban
|
||||
- **xterm.js**: Emulador de terminal web
|
||||
- **lucide-react**: Iconos modernos
|
||||
- **react-hot-toast**: Notificaciones
|
||||
- **recharts**: Gráficas y métricas
|
||||
|
||||
### Estado y Data Fetching
|
||||
- **@tanstack/react-query**: Server state management
|
||||
- **zustand**: Client state management (ligero y simple)
|
||||
- **socket.io-client**: WebSocket para real-time
|
||||
|
||||
### Routing
|
||||
- **react-router-dom**: Navegación SPA
|
||||
|
||||
## Backend
|
||||
|
||||
### Core
|
||||
- **Bun 1.3.6**: Runtime JavaScript ultra-rápido
|
||||
- **Express**: Framework HTTP
|
||||
- **TypeScript**: Type safety
|
||||
|
||||
### Database
|
||||
- **MySQL 8.0**: Base de datos relacional principal
|
||||
- **mysql2**: Driver MySQL para Node.js
|
||||
- **Drizzle ORM**: ORM TypeScript-first moderno
|
||||
- Type-safe
|
||||
- Ligero
|
||||
- Excelente DX con Bun
|
||||
|
||||
### Cache y Colas
|
||||
- **Redis 7.x**: Cache y message broker
|
||||
- **BullMQ**: Sistema de colas robusto
|
||||
- **ioredis**: Cliente Redis
|
||||
|
||||
### Comunicación con Agentes
|
||||
- **@modelcontextprotocol/sdk**: SDK oficial MCP
|
||||
- **socket.io**: WebSocket server
|
||||
|
||||
### Integraciones
|
||||
- **@kubernetes/client-node**: Cliente oficial K8s
|
||||
- **octokit** (adaptado): Cliente API Gitea
|
||||
- **axios**: HTTP client
|
||||
|
||||
### Desarrollo
|
||||
- **tsx**: TypeScript execution
|
||||
- **nodemon**: Hot reload
|
||||
- **prettier**: Code formatting
|
||||
- **eslint**: Linting
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Containerización
|
||||
- **Docker 24.x**: Containerización
|
||||
- **Docker Compose**: Orquestación local
|
||||
|
||||
### Orchestration
|
||||
- **Kubernetes 1.28+**: Orquestación de contenedores
|
||||
- **kubectl**: CLI
|
||||
- **helm**: Package manager
|
||||
- **kustomize**: Configuration management
|
||||
|
||||
### Git Server
|
||||
- **Gitea latest**: Servidor Git auto-alojado
|
||||
- Ligero (~100MB)
|
||||
- API REST compatible GitHub
|
||||
- Webhooks nativos
|
||||
|
||||
### CI/CD y GitOps
|
||||
- **ArgoCD**: GitOps continuous delivery
|
||||
- **GitHub Actions** (o Gitea Actions): CI pipelines
|
||||
|
||||
### Monitoring y Logging
|
||||
- **Prometheus**: Métricas
|
||||
- **Grafana**: Visualización
|
||||
- **Loki**: Logs aggregation
|
||||
- **Jaeger**: Distributed tracing (opcional)
|
||||
|
||||
### Networking
|
||||
- **Nginx Ingress Controller**: Routing
|
||||
- **cert-manager**: TLS certificates
|
||||
|
||||
## Agentes
|
||||
|
||||
### Claude Code
|
||||
- **Claude Code CLI**: Herramienta oficial de Anthropic
|
||||
- **Model**: Claude Sonnet 4.5
|
||||
- **MCP Tools**: Comunicación con backend
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Package Management
|
||||
- **bun**: Package manager principal
|
||||
- **npm**: Fallback para compatibilidad
|
||||
|
||||
### Testing
|
||||
- **Vitest**: Unit testing (compatible con Bun)
|
||||
- **@testing-library/react**: React testing
|
||||
- **Playwright**: E2E testing
|
||||
|
||||
### Code Quality
|
||||
- **TypeScript 5.x**: Type checking
|
||||
- **ESLint**: Linting
|
||||
- **Prettier**: Formatting
|
||||
- **husky**: Git hooks
|
||||
|
||||
## Versiones Específicas
|
||||
|
||||
```json
|
||||
{
|
||||
"frontend": {
|
||||
"react": "19.2.0",
|
||||
"vite": "^6.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
},
|
||||
"backend": {
|
||||
"bun": "1.3.6",
|
||||
"express": "^4.19.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"bullmq": "^5.23.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"infrastructure": {
|
||||
"kubernetes": "1.28+",
|
||||
"docker": "24.0+",
|
||||
"gitea": "1.22+",
|
||||
"redis": "7.2+",
|
||||
"mysql": "8.0+"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Justificación de Tecnologías
|
||||
|
||||
### ¿Por qué Bun?
|
||||
- **Velocidad**: 3-4x más rápido que Node.js
|
||||
- **TypeScript nativo**: Sin configuración adicional
|
||||
- **APIs modernas**: Compatibilidad Web Standard
|
||||
- **Tooling integrado**: Bundler, test runner, package manager
|
||||
|
||||
### ¿Por qué MySQL?
|
||||
- **Madurez**: Batalla-probado en producción
|
||||
- **Rendimiento**: Excelente para lecturas/escrituras
|
||||
- **Transacciones**: ACID compliance
|
||||
- **Ecosistema**: Herramientas maduras (backup, replicación)
|
||||
|
||||
### ¿Por qué Drizzle ORM?
|
||||
- **Type-safety**: Inferencia total de tipos
|
||||
- **Performance**: Query builder sin overhead
|
||||
- **DX**: Migraciones automáticas
|
||||
- **Bun compatible**: Primera clase
|
||||
|
||||
### ¿Por qué Gitea?
|
||||
- **Ligero**: Binario único, bajo consumo
|
||||
- **Auto-alojado**: Control total
|
||||
- **API familiar**: Compatible con GitHub
|
||||
- **Simple**: Instalación en minutos
|
||||
|
||||
### ¿Por qué React 19.2 sin Next.js?
|
||||
- **Simplicidad**: SPA sin server-side complexity
|
||||
- **Control total**: Sin abstracciones extra
|
||||
- **Rendimiento**: Nuevo compilador React
|
||||
- **Features**: Transitions, Server Actions cliente-side
|
||||
|
||||
## Alternativas Consideradas
|
||||
|
||||
| Necesidad | Elegido | Alternativas | Razón |
|
||||
|-----------|---------|--------------|-------|
|
||||
| Runtime | Bun | Node, Deno | Velocidad + DX |
|
||||
| DB | MySQL | PostgreSQL, MongoDB | Familiaridad + Madurez |
|
||||
| ORM | Drizzle | Prisma, TypeORM | Type-safety + Performance |
|
||||
| Git | Gitea | GitLab, Gogs | Simplicidad + Features |
|
||||
| Frontend | React | Vue, Svelte | Ecosistema + React 19 |
|
||||
| Orchestration | K8s | Docker Swarm, Nomad | Industry standard |
|
||||
|
||||
## Dependencias Críticas
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
bun add express mysql2 drizzle-orm ioredis bullmq
|
||||
bun add @modelcontextprotocol/sdk socket.io
|
||||
bun add @kubernetes/client-node axios
|
||||
|
||||
# Frontend
|
||||
bun add react@19.2.0 react-dom@19.2.0
|
||||
bun add @tanstack/react-query zustand
|
||||
bun add socket.io-client xterm
|
||||
bun add @dnd-kit/core react-router-dom
|
||||
```
|
||||
|
||||
## Roadmap Tecnológico
|
||||
|
||||
**Fase 1 (MVP)**: Stack actual
|
||||
**Fase 2**: Añadir Prometheus + Grafana
|
||||
**Fase 3**: Implementar tracing con Jaeger
|
||||
**Fase 4**: Multi-tenancy y sharding de DB
|
||||
484
docs/02-backend/api-endpoints.md
Normal file
484
docs/02-backend/api-endpoints.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# API Endpoints
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:3000/api
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Todos los endpoints (excepto `/health`) requieren autenticación JWT:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Projects
|
||||
|
||||
### GET /projects
|
||||
|
||||
Lista todos los proyectos.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Project",
|
||||
"description": "Project description",
|
||||
"giteaRepoUrl": "http://gitea/owner/repo",
|
||||
"k8sNamespace": "my-project",
|
||||
"status": "active",
|
||||
"createdAt": "2026-01-19T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /projects/:id
|
||||
|
||||
Obtiene detalles de un proyecto.
|
||||
|
||||
### POST /projects
|
||||
|
||||
Crea un nuevo proyecto.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"name": "My New Project",
|
||||
"description": "Project description",
|
||||
"dockerImage": "node:20-alpine",
|
||||
"envVars": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"replicas": 2,
|
||||
"cpuLimit": "1000m",
|
||||
"memoryLimit": "1Gi"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"project": {
|
||||
"id": "uuid",
|
||||
"name": "My New Project",
|
||||
"giteaRepoUrl": "http://gitea/owner/my-new-project",
|
||||
"k8sNamespace": "my-new-project-abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /projects/:id
|
||||
|
||||
Actualiza un proyecto.
|
||||
|
||||
### DELETE /projects/:id
|
||||
|
||||
Elimina un proyecto y todos sus recursos.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### GET /tasks
|
||||
|
||||
Lista tareas con filtros opcionales.
|
||||
|
||||
**Query params**:
|
||||
- `projectId`: Filtrar por proyecto
|
||||
- `state`: Filtrar por estado (`backlog`, `in_progress`, etc.)
|
||||
- `assignedAgentId`: Filtrar por agente
|
||||
- `limit`: Límite de resultados (default: 50)
|
||||
- `offset`: Offset para paginación
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"projectId": "uuid",
|
||||
"title": "Implement login",
|
||||
"description": "Create authentication system",
|
||||
"state": "in_progress",
|
||||
"priority": "high",
|
||||
"assignedAgentId": "agent-123",
|
||||
"branchName": "task-abc-implement-login",
|
||||
"prNumber": 42,
|
||||
"prUrl": "http://gitea/owner/repo/pulls/42",
|
||||
"previewUrl": "https://task-abc.preview.aiworker.dev",
|
||||
"createdAt": "2026-01-19T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
### GET /tasks/:id
|
||||
|
||||
Obtiene detalles completos de una tarea incluyendo preguntas.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "uuid",
|
||||
"title": "Implement login",
|
||||
"state": "needs_input",
|
||||
"questions": [
|
||||
{
|
||||
"id": "q-uuid",
|
||||
"question": "Which auth library should I use?",
|
||||
"context": "Need to choose between JWT or session-based",
|
||||
"askedAt": "2026-01-19T11:00:00Z",
|
||||
"status": "pending"
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
"name": "My Project",
|
||||
"giteaRepoUrl": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /tasks
|
||||
|
||||
Crea una nueva tarea.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"projectId": "uuid",
|
||||
"title": "Implement feature X",
|
||||
"description": "Detailed description...",
|
||||
"priority": "high"
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /tasks/:id
|
||||
|
||||
Actualiza una tarea.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"state": "approved",
|
||||
"notes": "Looks good!"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /tasks/:id/respond
|
||||
|
||||
Responde a una pregunta del agente.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"questionId": "q-uuid",
|
||||
"response": "Use JWT with jsonwebtoken library"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"question": {
|
||||
"id": "q-uuid",
|
||||
"status": "answered",
|
||||
"respondedAt": "2026-01-19T11:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /tasks/:id/approve
|
||||
|
||||
Aprueba una tarea en estado `ready_to_test`.
|
||||
|
||||
### POST /tasks/:id/reject
|
||||
|
||||
Rechaza una tarea y la regresa a `in_progress`.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"reason": "Needs more tests"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Groups (Merges)
|
||||
|
||||
### POST /task-groups
|
||||
|
||||
Crea un grupo de tareas para merge a staging/production.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"projectId": "uuid",
|
||||
"taskIds": ["task-1", "task-2", "task-3"],
|
||||
"targetBranch": "staging",
|
||||
"notes": "Sprint 1 features"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"taskGroup": {
|
||||
"id": "uuid",
|
||||
"taskIds": ["task-1", "task-2", "task-3"],
|
||||
"status": "pending",
|
||||
"stagingBranch": "release/sprint-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /task-groups/:id
|
||||
|
||||
Obtiene detalles de un task group.
|
||||
|
||||
### POST /task-groups/:id/deploy-staging
|
||||
|
||||
Despliega el task group a staging.
|
||||
|
||||
### POST /task-groups/:id/deploy-production
|
||||
|
||||
Despliega el task group a production.
|
||||
|
||||
---
|
||||
|
||||
## Agents
|
||||
|
||||
### GET /agents
|
||||
|
||||
Lista todos los agentes.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"id": "agent-123",
|
||||
"podName": "claude-agent-abc123",
|
||||
"status": "busy",
|
||||
"currentTaskId": "task-uuid",
|
||||
"capabilities": ["javascript", "react", "node"],
|
||||
"tasksCompleted": 42,
|
||||
"lastHeartbeat": "2026-01-19T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /agents/:id
|
||||
|
||||
Obtiene detalles de un agente incluyendo logs recientes.
|
||||
|
||||
### GET /agents/:id/logs
|
||||
|
||||
Obtiene logs del agente.
|
||||
|
||||
**Query params**:
|
||||
- `limit`: Número de logs (default: 100)
|
||||
- `level`: Filtrar por nivel (`debug`, `info`, `warn`, `error`)
|
||||
|
||||
---
|
||||
|
||||
## Deployments
|
||||
|
||||
### GET /deployments
|
||||
|
||||
Lista deployments con filtros.
|
||||
|
||||
**Query params**:
|
||||
- `projectId`: Filtrar por proyecto
|
||||
- `environment`: Filtrar por entorno
|
||||
- `status`: Filtrar por estado
|
||||
|
||||
### GET /deployments/:id
|
||||
|
||||
Obtiene detalles de un deployment.
|
||||
|
||||
### POST /deployments/:id/rollback
|
||||
|
||||
Hace rollback de un deployment.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"rollbackDeploymentId": "new-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health & Status
|
||||
|
||||
### GET /health
|
||||
|
||||
Health check del backend.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-01-19T12:00:00Z",
|
||||
"services": {
|
||||
"mysql": "connected",
|
||||
"redis": "connected",
|
||||
"gitea": "reachable",
|
||||
"kubernetes": "connected"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /metrics
|
||||
|
||||
Métricas del sistema (Prometheus format).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Conectar a: `ws://localhost:3000`
|
||||
|
||||
### Client → Server
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "auth",
|
||||
"data": {
|
||||
"token": "jwt-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "subscribe",
|
||||
"data": {
|
||||
"projectId": "uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Server → Client
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task:created",
|
||||
"data": {
|
||||
"taskId": "uuid",
|
||||
"projectId": "uuid",
|
||||
"title": "New task"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task:status_changed",
|
||||
"data": {
|
||||
"taskId": "uuid",
|
||||
"oldState": "in_progress",
|
||||
"newState": "ready_to_test",
|
||||
"previewUrl": "https://..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task:needs_input",
|
||||
"data": {
|
||||
"taskId": "uuid",
|
||||
"questionId": "q-uuid",
|
||||
"question": "Which library?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agent:status",
|
||||
"data": {
|
||||
"agentId": "agent-123",
|
||||
"status": "idle",
|
||||
"lastTaskId": "task-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "deploy:started",
|
||||
"data": {
|
||||
"deploymentId": "uuid",
|
||||
"environment": "staging"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "deploy:completed",
|
||||
"data": {
|
||||
"deploymentId": "uuid",
|
||||
"environment": "staging",
|
||||
"url": "https://staging-project.aiworker.dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
Todos los endpoints pueden retornar estos errores:
|
||||
|
||||
### 400 Bad Request
|
||||
```json
|
||||
{
|
||||
"error": "Validation error",
|
||||
"details": {
|
||||
"field": "projectId",
|
||||
"message": "Required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
```json
|
||||
{
|
||||
"error": "Invalid or expired token"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"error": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 500 Internal Server Error
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error",
|
||||
"requestId": "req-uuid"
|
||||
}
|
||||
```
|
||||
462
docs/02-backend/database-schema.md
Normal file
462
docs/02-backend/database-schema.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# Database Schema con Drizzle ORM
|
||||
|
||||
## Schema Definitions
|
||||
|
||||
```typescript
|
||||
// db/schema.ts
|
||||
import { relations } from 'drizzle-orm'
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
json,
|
||||
int,
|
||||
mysqlEnum,
|
||||
boolean,
|
||||
bigint,
|
||||
index,
|
||||
unique,
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
|
||||
// ============================================
|
||||
// PROJECTS TABLE
|
||||
// ============================================
|
||||
|
||||
export const projects = mysqlTable('projects', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
|
||||
// Gitea
|
||||
giteaRepoId: int('gitea_repo_id'),
|
||||
giteaRepoUrl: varchar('gitea_repo_url', { length: 512 }),
|
||||
giteaOwner: varchar('gitea_owner', { length: 100 }),
|
||||
giteaRepoName: varchar('gitea_repo_name', { length: 100 }),
|
||||
defaultBranch: varchar('default_branch', { length: 100 }).default('main'),
|
||||
|
||||
// K8s
|
||||
k8sNamespace: varchar('k8s_namespace', { length: 63 }).notNull().unique(),
|
||||
|
||||
// Infrastructure
|
||||
dockerImage: varchar('docker_image', { length: 512 }),
|
||||
envVars: json('env_vars').$type<Record<string, string>>(),
|
||||
replicas: int('replicas').default(1),
|
||||
cpuLimit: varchar('cpu_limit', { length: 20 }).default('500m'),
|
||||
memoryLimit: varchar('memory_limit', { length: 20 }).default('512Mi'),
|
||||
|
||||
// MCP
|
||||
mcpTools: json('mcp_tools').$type<string[]>(),
|
||||
mcpPermissions: json('mcp_permissions').$type<Record<string, any>>(),
|
||||
|
||||
// Status
|
||||
status: mysqlEnum('status', ['active', 'paused', 'archived']).default('active'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
}, (table) => ({
|
||||
statusIdx: index('idx_status').on(table.status),
|
||||
k8sNamespaceIdx: index('idx_k8s_namespace').on(table.k8sNamespace),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// AGENTS TABLE
|
||||
// ============================================
|
||||
|
||||
export const agents = mysqlTable('agents', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
|
||||
// K8s
|
||||
podName: varchar('pod_name', { length: 253 }).notNull().unique(),
|
||||
k8sNamespace: varchar('k8s_namespace', { length: 63 }).default('agents'),
|
||||
nodeName: varchar('node_name', { length: 253 }),
|
||||
|
||||
// Status
|
||||
status: mysqlEnum('status', ['idle', 'busy', 'error', 'offline', 'initializing']).default('initializing'),
|
||||
currentTaskId: varchar('current_task_id', { length: 36 }),
|
||||
|
||||
// Capabilities
|
||||
capabilities: json('capabilities').$type<string[]>(),
|
||||
maxConcurrentTasks: int('max_concurrent_tasks').default(1),
|
||||
|
||||
// Health
|
||||
lastHeartbeat: timestamp('last_heartbeat'),
|
||||
errorMessage: text('error_message'),
|
||||
restartsCount: int('restarts_count').default(0),
|
||||
|
||||
// Metrics
|
||||
tasksCompleted: int('tasks_completed').default(0),
|
||||
totalRuntimeMinutes: int('total_runtime_minutes').default(0),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
}, (table) => ({
|
||||
statusIdx: index('idx_status').on(table.status),
|
||||
podNameIdx: index('idx_pod_name').on(table.podName),
|
||||
lastHeartbeatIdx: index('idx_last_heartbeat').on(table.lastHeartbeat),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// TASKS TABLE
|
||||
// ============================================
|
||||
|
||||
export const tasks = mysqlTable('tasks', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 36 }).notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Task info
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
priority: mysqlEnum('priority', ['low', 'medium', 'high', 'urgent']).default('medium'),
|
||||
|
||||
// State
|
||||
state: mysqlEnum('state', [
|
||||
'backlog',
|
||||
'in_progress',
|
||||
'needs_input',
|
||||
'ready_to_test',
|
||||
'approved',
|
||||
'staging',
|
||||
'production',
|
||||
'cancelled'
|
||||
]).default('backlog'),
|
||||
|
||||
// Assignment
|
||||
assignedAgentId: varchar('assigned_agent_id', { length: 36 }).references(() => agents.id, { onDelete: 'set null' }),
|
||||
assignedAt: timestamp('assigned_at'),
|
||||
|
||||
// Git
|
||||
branchName: varchar('branch_name', { length: 255 }),
|
||||
prNumber: int('pr_number'),
|
||||
prUrl: varchar('pr_url', { length: 512 }),
|
||||
|
||||
// Preview
|
||||
previewNamespace: varchar('preview_namespace', { length: 63 }),
|
||||
previewUrl: varchar('preview_url', { length: 512 }),
|
||||
previewDeployedAt: timestamp('preview_deployed_at'),
|
||||
|
||||
// Metadata
|
||||
estimatedComplexity: mysqlEnum('estimated_complexity', ['trivial', 'simple', 'medium', 'complex']).default('medium'),
|
||||
actualDurationMinutes: int('actual_duration_minutes'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
startedAt: timestamp('started_at'),
|
||||
completedAt: timestamp('completed_at'),
|
||||
deployedStagingAt: timestamp('deployed_staging_at'),
|
||||
deployedProductionAt: timestamp('deployed_production_at'),
|
||||
}, (table) => ({
|
||||
projectStateIdx: index('idx_project_state').on(table.projectId, table.state, table.createdAt),
|
||||
stateIdx: index('idx_state').on(table.state),
|
||||
assignedAgentIdx: index('idx_assigned_agent').on(table.assignedAgentId),
|
||||
createdAtIdx: index('idx_created_at').on(table.createdAt),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// TASK QUESTIONS TABLE
|
||||
// ============================================
|
||||
|
||||
export const taskQuestions = mysqlTable('task_questions', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
taskId: varchar('task_id', { length: 36 }).notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Question
|
||||
question: text('question').notNull(),
|
||||
context: text('context'),
|
||||
askedAt: timestamp('asked_at').defaultNow(),
|
||||
|
||||
// Response
|
||||
response: text('response'),
|
||||
respondedAt: timestamp('responded_at'),
|
||||
respondedBy: varchar('responded_by', { length: 36 }),
|
||||
|
||||
// Status
|
||||
status: mysqlEnum('status', ['pending', 'answered', 'skipped']).default('pending'),
|
||||
}, (table) => ({
|
||||
taskStatusIdx: index('idx_task_status').on(table.taskId, table.status),
|
||||
statusIdx: index('idx_status').on(table.status),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// TASK GROUPS TABLE
|
||||
// ============================================
|
||||
|
||||
export const taskGroups = mysqlTable('task_groups', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 36 }).notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Grouping
|
||||
taskIds: json('task_ids').$type<string[]>().notNull(),
|
||||
|
||||
// Staging
|
||||
stagingBranch: varchar('staging_branch', { length: 255 }),
|
||||
stagingPrNumber: int('staging_pr_number'),
|
||||
stagingPrUrl: varchar('staging_pr_url', { length: 512 }),
|
||||
stagingDeployedAt: timestamp('staging_deployed_at'),
|
||||
|
||||
// Production
|
||||
productionDeployedAt: timestamp('production_deployed_at'),
|
||||
productionRollbackAvailable: boolean('production_rollback_available').default(true),
|
||||
|
||||
// Status
|
||||
status: mysqlEnum('status', ['pending', 'staging', 'production', 'rolled_back']).default('pending'),
|
||||
|
||||
// Metadata
|
||||
createdBy: varchar('created_by', { length: 36 }),
|
||||
notes: text('notes'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||
}, (table) => ({
|
||||
projectStatusIdx: index('idx_project_status').on(table.projectId, table.status),
|
||||
statusIdx: index('idx_status').on(table.status),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// DEPLOYMENTS TABLE
|
||||
// ============================================
|
||||
|
||||
export const deployments = mysqlTable('deployments', {
|
||||
id: varchar('id', { length: 36 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 36 }).notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||
taskGroupId: varchar('task_group_id', { length: 36 }).references(() => taskGroups.id, { onDelete: 'set null' }),
|
||||
|
||||
// Deployment info
|
||||
environment: mysqlEnum('environment', ['preview', 'staging', 'production']).notNull(),
|
||||
deploymentType: mysqlEnum('deployment_type', ['manual', 'automatic', 'rollback']).default('manual'),
|
||||
|
||||
// Git
|
||||
branch: varchar('branch', { length: 255 }),
|
||||
commitHash: varchar('commit_hash', { length: 40 }),
|
||||
|
||||
// K8s
|
||||
k8sNamespace: varchar('k8s_namespace', { length: 63 }),
|
||||
k8sDeploymentName: varchar('k8s_deployment_name', { length: 253 }),
|
||||
imageTag: varchar('image_tag', { length: 255 }),
|
||||
|
||||
// Status
|
||||
status: mysqlEnum('status', ['pending', 'in_progress', 'completed', 'failed', 'rolled_back']).default('pending'),
|
||||
|
||||
// Results
|
||||
url: varchar('url', { length: 512 }),
|
||||
errorMessage: text('error_message'),
|
||||
logs: text('logs'),
|
||||
|
||||
// Timing
|
||||
startedAt: timestamp('started_at'),
|
||||
completedAt: timestamp('completed_at'),
|
||||
durationSeconds: int('duration_seconds'),
|
||||
|
||||
// Metadata
|
||||
triggeredBy: varchar('triggered_by', { length: 36 }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
}, (table) => ({
|
||||
projectEnvIdx: index('idx_project_env').on(table.projectId, table.environment),
|
||||
statusIdx: index('idx_status').on(table.status),
|
||||
createdAtIdx: index('idx_created_at').on(table.createdAt),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// AGENT LOGS TABLE
|
||||
// ============================================
|
||||
|
||||
export const agentLogs = mysqlTable('agent_logs', {
|
||||
id: bigint('id', { mode: 'number' }).autoincrement().primaryKey(),
|
||||
agentId: varchar('agent_id', { length: 36 }).notNull().references(() => agents.id, { onDelete: 'cascade' }),
|
||||
taskId: varchar('task_id', { length: 36 }).references(() => tasks.id, { onDelete: 'set null' }),
|
||||
|
||||
// Log entry
|
||||
level: mysqlEnum('level', ['debug', 'info', 'warn', 'error']).default('info'),
|
||||
message: text('message').notNull(),
|
||||
metadata: json('metadata').$type<Record<string, any>>(),
|
||||
|
||||
// Timestamp
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
}, (table) => ({
|
||||
agentCreatedIdx: index('idx_agent_created').on(table.agentId, table.createdAt),
|
||||
taskCreatedIdx: index('idx_task_created').on(table.taskId, table.createdAt),
|
||||
levelIdx: index('idx_level').on(table.level),
|
||||
}))
|
||||
|
||||
// ============================================
|
||||
// RELATIONS
|
||||
// ============================================
|
||||
|
||||
export const projectsRelations = relations(projects, ({ many }) => ({
|
||||
tasks: many(tasks),
|
||||
taskGroups: many(taskGroups),
|
||||
deployments: many(deployments),
|
||||
}))
|
||||
|
||||
export const tasksRelations = relations(tasks, ({ one, many }) => ({
|
||||
project: one(projects, {
|
||||
fields: [tasks.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
assignedAgent: one(agents, {
|
||||
fields: [tasks.assignedAgentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
questions: many(taskQuestions),
|
||||
}))
|
||||
|
||||
export const agentsRelations = relations(agents, ({ one, many }) => ({
|
||||
currentTask: one(tasks, {
|
||||
fields: [agents.currentTaskId],
|
||||
references: [tasks.id],
|
||||
}),
|
||||
logs: many(agentLogs),
|
||||
}))
|
||||
|
||||
export const taskQuestionsRelations = relations(taskQuestions, ({ one }) => ({
|
||||
task: one(tasks, {
|
||||
fields: [taskQuestions.taskId],
|
||||
references: [tasks.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const taskGroupsRelations = relations(taskGroups, ({ one, many }) => ({
|
||||
project: one(projects, {
|
||||
fields: [taskGroups.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
deployments: many(deployments),
|
||||
}))
|
||||
|
||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [deployments.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
taskGroup: one(taskGroups, {
|
||||
fields: [deployments.taskGroupId],
|
||||
references: [taskGroups.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const agentLogsRelations = relations(agentLogs, ({ one }) => ({
|
||||
agent: one(agents, {
|
||||
fields: [agentLogs.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
task: one(tasks, {
|
||||
fields: [agentLogs.taskId],
|
||||
references: [tasks.id],
|
||||
}),
|
||||
}))
|
||||
```
|
||||
|
||||
## Drizzle Configuration
|
||||
|
||||
```typescript
|
||||
// drizzle.config.ts
|
||||
import type { Config } from 'drizzle-kit'
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle/migrations',
|
||||
driver: 'mysql2',
|
||||
dbCredentials: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'aiworker',
|
||||
},
|
||||
} satisfies Config
|
||||
```
|
||||
|
||||
## Database Client
|
||||
|
||||
```typescript
|
||||
// db/client.ts
|
||||
import { drizzle } from 'drizzle-orm/mysql2'
|
||||
import mysql from 'mysql2/promise'
|
||||
import * as schema from './schema'
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
})
|
||||
|
||||
export const db = drizzle(pool, { schema, mode: 'default' })
|
||||
```
|
||||
|
||||
## Ejemplos de Queries
|
||||
|
||||
```typescript
|
||||
// Get all tasks for a project
|
||||
const projectTasks = await db.query.tasks.findMany({
|
||||
where: eq(tasks.projectId, projectId),
|
||||
with: {
|
||||
assignedAgent: true,
|
||||
questions: {
|
||||
where: eq(taskQuestions.status, 'pending')
|
||||
}
|
||||
},
|
||||
orderBy: [desc(tasks.createdAt)]
|
||||
})
|
||||
|
||||
// Get next available task
|
||||
const nextTask = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
orderBy: [desc(tasks.priority), asc(tasks.createdAt)]
|
||||
})
|
||||
|
||||
// Get idle agents
|
||||
const idleAgents = await db.query.agents.findMany({
|
||||
where: and(
|
||||
eq(agents.status, 'idle'),
|
||||
gt(agents.lastHeartbeat, new Date(Date.now() - 60000))
|
||||
)
|
||||
})
|
||||
|
||||
// Insert new task
|
||||
const newTask = await db.insert(tasks).values({
|
||||
id: crypto.randomUUID(),
|
||||
projectId: projectId,
|
||||
title: 'New task',
|
||||
description: 'Task description',
|
||||
state: 'backlog',
|
||||
priority: 'medium',
|
||||
})
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
```bash
|
||||
# Generate migration
|
||||
bun run drizzle-kit generate:mysql
|
||||
|
||||
# Push changes directly (dev only)
|
||||
bun run drizzle-kit push:mysql
|
||||
|
||||
# Run migrations
|
||||
bun run scripts/migrate.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// scripts/migrate.ts
|
||||
import { migrate } from 'drizzle-orm/mysql2/migrator'
|
||||
import { db } from '../src/db/client'
|
||||
|
||||
async function runMigrations() {
|
||||
await migrate(db, { migrationsFolder: './drizzle/migrations' })
|
||||
console.log('✓ Migrations completed')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
runMigrations().catch(console.error)
|
||||
```
|
||||
480
docs/02-backend/estructura.md
Normal file
480
docs/02-backend/estructura.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# Estructura del Backend
|
||||
|
||||
## Árbol de Directorios
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── config/
|
||||
│ │ ├── database.ts # MySQL connection
|
||||
│ │ ├── redis.ts # Redis connection
|
||||
│ │ └── env.ts # Environment variables
|
||||
│ │
|
||||
│ ├── api/
|
||||
│ │ ├── app.ts # Express app setup
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── projects.ts # /api/projects
|
||||
│ │ │ ├── tasks.ts # /api/tasks
|
||||
│ │ │ ├── agents.ts # /api/agents
|
||||
│ │ │ ├── deployments.ts# /api/deployments
|
||||
│ │ │ └── health.ts # /api/health
|
||||
│ │ │
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── auth.ts # JWT validation
|
||||
│ │ │ ├── error.ts # Error handler
|
||||
│ │ │ ├── logger.ts # Request logging
|
||||
│ │ │ └── validate.ts # Schema validation
|
||||
│ │ │
|
||||
│ │ └── websocket/
|
||||
│ │ ├── server.ts # Socket.io setup
|
||||
│ │ └── handlers.ts # WS event handlers
|
||||
│ │
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # Drizzle schema
|
||||
│ │ ├── migrations/ # SQL migrations
|
||||
│ │ └── client.ts # DB client instance
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── mcp/
|
||||
│ │ │ ├── server.ts # MCP server for agents
|
||||
│ │ │ ├── tools.ts # MCP tool definitions
|
||||
│ │ │ └── handlers.ts # Tool implementations
|
||||
│ │ │
|
||||
│ │ ├── gitea/
|
||||
│ │ │ ├── client.ts # Gitea API client
|
||||
│ │ │ ├── repos.ts # Repo operations
|
||||
│ │ │ ├── pulls.ts # PR operations
|
||||
│ │ │ └── webhooks.ts # Webhook handling
|
||||
│ │ │
|
||||
│ │ ├── kubernetes/
|
||||
│ │ │ ├── client.ts # K8s API client
|
||||
│ │ │ ├── namespaces.ts # Namespace management
|
||||
│ │ │ ├── deployments.ts# Deployment management
|
||||
│ │ │ ├── pods.ts # Pod operations
|
||||
│ │ │ └── ingress.ts # Ingress management
|
||||
│ │ │
|
||||
│ │ ├── queue/
|
||||
│ │ │ ├── task-queue.ts # Task queue
|
||||
│ │ │ ├── deploy-queue.ts# Deploy queue
|
||||
│ │ │ └── workers.ts # Queue workers
|
||||
│ │ │
|
||||
│ │ └── cache/
|
||||
│ │ ├── redis.ts # Redis operations
|
||||
│ │ └── strategies.ts # Caching strategies
|
||||
│ │
|
||||
│ ├── models/
|
||||
│ │ ├── Project.ts # Project model
|
||||
│ │ ├── Task.ts # Task model
|
||||
│ │ ├── Agent.ts # Agent model
|
||||
│ │ ├── TaskGroup.ts # TaskGroup model
|
||||
│ │ └── Deployment.ts # Deployment model
|
||||
│ │
|
||||
│ ├── types/
|
||||
│ │ ├── api.ts # API types
|
||||
│ │ ├── mcp.ts # MCP types
|
||||
│ │ ├── k8s.ts # K8s types
|
||||
│ │ └── common.ts # Common types
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ ├── logger.ts # Winston logger
|
||||
│ ├── errors.ts # Custom errors
|
||||
│ ├── validators.ts # Validation helpers
|
||||
│ └── helpers.ts # General helpers
|
||||
│
|
||||
├── drizzle/ # Drizzle config
|
||||
│ ├── drizzle.config.ts
|
||||
│ └── migrations/
|
||||
│
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── e2e/
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── seed.ts # Seed database
|
||||
│ ├── migrate.ts # Run migrations
|
||||
│ └── generate-types.ts # Generate types
|
||||
│
|
||||
├── .env.example
|
||||
├── .eslintrc.json
|
||||
├── .prettierrc
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Entry Point (index.ts)
|
||||
|
||||
```typescript
|
||||
import { startServer } from './api/app'
|
||||
import { connectDatabase } from './config/database'
|
||||
import { connectRedis } from './config/redis'
|
||||
import { startMCPServer } from './services/mcp/server'
|
||||
import { startQueueWorkers } from './services/queue/workers'
|
||||
import { logger } from './utils/logger'
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
// Connect to MySQL
|
||||
await connectDatabase()
|
||||
logger.info('✓ MySQL connected')
|
||||
|
||||
// Connect to Redis
|
||||
await connectRedis()
|
||||
logger.info('✓ Redis connected')
|
||||
|
||||
// Start MCP Server for agents
|
||||
await startMCPServer()
|
||||
logger.info('✓ MCP Server started')
|
||||
|
||||
// Start BullMQ workers
|
||||
await startQueueWorkers()
|
||||
logger.info('✓ Queue workers started')
|
||||
|
||||
// Start HTTP + WebSocket server
|
||||
await startServer()
|
||||
logger.info('✓ API Server started on port 3000')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
```
|
||||
|
||||
## Express App Setup (api/app.ts)
|
||||
|
||||
```typescript
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { createServer } from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import routes from './routes'
|
||||
import { errorHandler } from './middleware/error'
|
||||
import { requestLogger } from './middleware/logger'
|
||||
import { setupWebSocket } from './websocket/server'
|
||||
|
||||
export async function startServer() {
|
||||
const app = express()
|
||||
const httpServer = createServer(app)
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: process.env.FRONTEND_URL }
|
||||
})
|
||||
|
||||
// Middleware
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(requestLogger)
|
||||
|
||||
// Routes
|
||||
app.use('/api', routes)
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler)
|
||||
|
||||
// WebSocket
|
||||
setupWebSocket(io)
|
||||
|
||||
// Start
|
||||
const port = process.env.PORT || 3000
|
||||
httpServer.listen(port)
|
||||
|
||||
return { app, httpServer, io }
|
||||
}
|
||||
```
|
||||
|
||||
## Configuración de Base de Datos
|
||||
|
||||
```typescript
|
||||
// config/database.ts
|
||||
import { drizzle } from 'drizzle-orm/mysql2'
|
||||
import mysql from 'mysql2/promise'
|
||||
import * as schema from '../db/schema'
|
||||
|
||||
let connection: mysql.Connection
|
||||
let db: ReturnType<typeof drizzle>
|
||||
|
||||
export async function connectDatabase() {
|
||||
connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
})
|
||||
|
||||
db = drizzle(connection, { schema, mode: 'default' })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized')
|
||||
}
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
## Configuración de Redis
|
||||
|
||||
```typescript
|
||||
// config/redis.ts
|
||||
import Redis from 'ioredis'
|
||||
|
||||
let redis: Redis
|
||||
|
||||
export async function connectRedis() {
|
||||
redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000)
|
||||
return delay
|
||||
}
|
||||
})
|
||||
|
||||
await redis.ping()
|
||||
return redis
|
||||
}
|
||||
|
||||
export function getRedis() {
|
||||
if (!redis) {
|
||||
throw new Error('Redis not initialized')
|
||||
}
|
||||
return redis
|
||||
}
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
```bash
|
||||
# .env.example
|
||||
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=aiworker
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Gitea
|
||||
GITEA_URL=http://localhost:3001
|
||||
GITEA_TOKEN=your-gitea-token
|
||||
GITEA_OWNER=aiworker
|
||||
|
||||
# Kubernetes
|
||||
K8S_IN_CLUSTER=false
|
||||
K8S_CONFIG_PATH=~/.kube/config
|
||||
K8S_DEFAULT_NAMESPACE=aiworker
|
||||
|
||||
# MCP Server
|
||||
MCP_SERVER_PORT=3100
|
||||
MCP_AUTH_TOKEN=your-mcp-token
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secret-key
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
## Scripts de Package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "aiworker-backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target node",
|
||||
"start": "bun dist/index.js",
|
||||
"db:generate": "drizzle-kit generate:mysql",
|
||||
"db:push": "drizzle-kit push:mysql",
|
||||
"db:migrate": "bun run scripts/migrate.ts",
|
||||
"db:seed": "bun run scripts/seed.ts",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"format": "prettier --write src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"bullmq": "^5.23.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@kubernetes/client-node": "^0.22.0",
|
||||
"axios": "^1.7.9",
|
||||
"zod": "^3.24.1",
|
||||
"winston": "^3.17.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"typescript": "^5.7.2",
|
||||
"prettier": "^3.4.2",
|
||||
"eslint": "^9.18.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Estructura de Rutas
|
||||
|
||||
```typescript
|
||||
// api/routes/index.ts
|
||||
import { Router } from 'express'
|
||||
import projectRoutes from './projects'
|
||||
import taskRoutes from './tasks'
|
||||
import agentRoutes from './agents'
|
||||
import deploymentRoutes from './deployments'
|
||||
import healthRoutes from './health'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/projects', projectRoutes)
|
||||
router.use('/tasks', taskRoutes)
|
||||
router.use('/agents', agentRoutes)
|
||||
router.use('/deployments', deploymentRoutes)
|
||||
router.use('/health', healthRoutes)
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Middleware de Validación
|
||||
|
||||
```typescript
|
||||
// middleware/validate.ts
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { ZodSchema } from 'zod'
|
||||
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
schema.parse({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
})
|
||||
next()
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: 'Validation error',
|
||||
details: error
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Logger Setup
|
||||
|
||||
```typescript
|
||||
// utils/logger.ts
|
||||
import winston from 'winston'
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}),
|
||||
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'combined.log' })
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Manejo de Errores
|
||||
|
||||
```typescript
|
||||
// middleware/error.ts
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export class AppError extends Error {
|
||||
statusCode: number
|
||||
isOperational: boolean
|
||||
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.isOperational = true
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error | AppError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
logger.error('Error:', err)
|
||||
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Comandos Útiles
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
bun run dev
|
||||
|
||||
# Generar migraciones
|
||||
bun run db:generate
|
||||
|
||||
# Aplicar migraciones
|
||||
bun run db:migrate
|
||||
|
||||
# Seed inicial
|
||||
bun run db:seed
|
||||
|
||||
# Tests
|
||||
bun test
|
||||
|
||||
# Build para producción
|
||||
bun run build
|
||||
|
||||
# Producción
|
||||
bun run start
|
||||
```
|
||||
459
docs/02-backend/gitea-integration.md
Normal file
459
docs/02-backend/gitea-integration.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# Integración con Gitea
|
||||
|
||||
## Cliente de Gitea
|
||||
|
||||
```typescript
|
||||
// services/gitea/client.ts
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export interface GiteaConfig {
|
||||
url: string
|
||||
token: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
private client: AxiosInstance
|
||||
private owner: string
|
||||
|
||||
constructor(config?: GiteaConfig) {
|
||||
const url = config?.url || process.env.GITEA_URL!
|
||||
const token = config?.token || process.env.GITEA_TOKEN!
|
||||
this.owner = config?.owner || process.env.GITEA_OWNER!
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `${url}/api/v1`,
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// Log requests
|
||||
this.client.interceptors.request.use((config) => {
|
||||
logger.debug(`Gitea API: ${config.method?.toUpperCase()} ${config.url}`)
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle errors
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
logger.error('Gitea API Error:', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
})
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPOSITORIES
|
||||
// ============================================
|
||||
|
||||
async createRepo(name: string, options: {
|
||||
description?: string
|
||||
private?: boolean
|
||||
autoInit?: boolean
|
||||
defaultBranch?: string
|
||||
} = {}) {
|
||||
const response = await this.client.post('/user/repos', {
|
||||
name,
|
||||
description: options.description || '',
|
||||
private: options.private !== false,
|
||||
auto_init: options.autoInit !== false,
|
||||
default_branch: options.defaultBranch || 'main',
|
||||
trust_model: 'default'
|
||||
})
|
||||
|
||||
logger.info(`Gitea: Created repo ${name}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getRepo(owner: string, repo: string) {
|
||||
const response = await this.client.get(`/repos/${owner}/${repo}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteRepo(owner: string, repo: string) {
|
||||
await this.client.delete(`/repos/${owner}/${repo}`)
|
||||
logger.info(`Gitea: Deleted repo ${owner}/${repo}`)
|
||||
}
|
||||
|
||||
async listRepos(owner?: string) {
|
||||
const targetOwner = owner || this.owner
|
||||
const response = await this.client.get(`/users/${targetOwner}/repos`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BRANCHES
|
||||
// ============================================
|
||||
|
||||
async createBranch(owner: string, repo: string, branchName: string, fromBranch: string = 'main') {
|
||||
// Get reference commit
|
||||
const refResponse = await this.client.get(
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${fromBranch}`
|
||||
)
|
||||
const sha = refResponse.data.object.sha
|
||||
|
||||
// Create new branch
|
||||
const response = await this.client.post(
|
||||
`/repos/${owner}/${repo}/git/refs`,
|
||||
{
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Created branch ${branchName} from ${fromBranch}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/branches/${branch}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async listBranches(owner: string, repo: string) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/branches`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string) {
|
||||
await this.client.delete(
|
||||
`/repos/${owner}/${repo}/branches/${branch}`
|
||||
)
|
||||
logger.info(`Gitea: Deleted branch ${branch}`)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PULL REQUESTS
|
||||
// ============================================
|
||||
|
||||
async createPullRequest(owner: string, repo: string, data: {
|
||||
title: string
|
||||
body: string
|
||||
head: string
|
||||
base: string
|
||||
}) {
|
||||
const response = await this.client.post(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
head: data.head,
|
||||
base: data.base
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Created PR #${response.data.number}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/pulls/${index}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async listPullRequests(owner: string, repo: string, state: 'open' | 'closed' | 'all' = 'open') {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{ params: { state } }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method: 'merge' | 'rebase' | 'squash' = 'merge') {
|
||||
const response = await this.client.post(
|
||||
`/repos/${owner}/${repo}/pulls/${index}/merge`,
|
||||
{
|
||||
Do: method,
|
||||
MergeMessageField: '',
|
||||
MergeTitleField: ''
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Merged PR #${index}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async closePullRequest(owner: string, repo: string, index: number) {
|
||||
const response = await this.client.patch(
|
||||
`/repos/${owner}/${repo}/pulls/${index}`,
|
||||
{ state: 'closed' }
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Closed PR #${index}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMMITS
|
||||
// ============================================
|
||||
|
||||
async getCommit(owner: string, repo: string, sha: string) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/git/commits/${sha}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async listCommits(owner: string, repo: string, options: {
|
||||
sha?: string
|
||||
path?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
} = {}) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/commits`,
|
||||
{ params: options }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WEBHOOKS
|
||||
// ============================================
|
||||
|
||||
async createWebhook(owner: string, repo: string, config: {
|
||||
url: string
|
||||
contentType?: 'json' | 'form'
|
||||
secret?: string
|
||||
events?: string[]
|
||||
}) {
|
||||
const response = await this.client.post(
|
||||
`/repos/${owner}/${repo}/hooks`,
|
||||
{
|
||||
type: 'gitea',
|
||||
config: {
|
||||
url: config.url,
|
||||
content_type: config.contentType || 'json',
|
||||
secret: config.secret || ''
|
||||
},
|
||||
events: config.events || ['push', 'pull_request'],
|
||||
active: true
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Created webhook for ${owner}/${repo}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async listWebhooks(owner: string, repo: string) {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/hooks`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteWebhook(owner: string, repo: string, hookId: number) {
|
||||
await this.client.delete(
|
||||
`/repos/${owner}/${repo}/hooks/${hookId}`
|
||||
)
|
||||
logger.info(`Gitea: Deleted webhook ${hookId}`)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FILES
|
||||
// ============================================
|
||||
|
||||
async getFileContents(owner: string, repo: string, filepath: string, ref: string = 'main') {
|
||||
const response = await this.client.get(
|
||||
`/repos/${owner}/${repo}/contents/${filepath}`,
|
||||
{ params: { ref } }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createOrUpdateFile(owner: string, repo: string, filepath: string, data: {
|
||||
content: string // base64 encoded
|
||||
message: string
|
||||
branch?: string
|
||||
sha?: string // for updates
|
||||
}) {
|
||||
const response = await this.client.post(
|
||||
`/repos/${owner}/${repo}/contents/${filepath}`,
|
||||
{
|
||||
content: data.content,
|
||||
message: data.message,
|
||||
branch: data.branch || 'main',
|
||||
sha: data.sha
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Gitea: Updated file ${filepath}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USERS
|
||||
// ============================================
|
||||
|
||||
async getCurrentUser() {
|
||||
const response = await this.client.get('/user')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getUser(username: string) {
|
||||
const response = await this.client.get(`/users/${username}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORGANIZATIONS (if needed)
|
||||
// ============================================
|
||||
|
||||
async createOrg(name: string, options: {
|
||||
fullName?: string
|
||||
description?: string
|
||||
} = {}) {
|
||||
const response = await this.client.post('/orgs', {
|
||||
username: name,
|
||||
full_name: options.fullName || name,
|
||||
description: options.description || ''
|
||||
})
|
||||
|
||||
logger.info(`Gitea: Created org ${name}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const giteaClient = new GiteaClient()
|
||||
```
|
||||
|
||||
## Webhook Handler
|
||||
|
||||
```typescript
|
||||
// services/gitea/webhooks.ts
|
||||
import { Request, Response } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { db } from '../../db/client'
|
||||
import { tasks } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { emitWebSocketEvent } from '../../api/websocket/server'
|
||||
|
||||
export async function handleGiteaWebhook(req: Request, res: Response) {
|
||||
const signature = req.headers['x-gitea-signature'] as string
|
||||
const event = req.headers['x-gitea-event'] as string
|
||||
const payload = req.body
|
||||
|
||||
// Verify signature
|
||||
const secret = process.env.GITEA_WEBHOOK_SECRET || ''
|
||||
if (secret && signature) {
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
hmac.update(JSON.stringify(payload))
|
||||
const calculatedSignature = hmac.digest('hex')
|
||||
|
||||
if (signature !== calculatedSignature) {
|
||||
logger.warn('Gitea webhook: Invalid signature')
|
||||
return res.status(401).json({ error: 'Invalid signature' })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Gitea webhook: ${event}`, {
|
||||
repo: payload.repository?.full_name,
|
||||
ref: payload.ref
|
||||
})
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'push':
|
||||
await handlePushEvent(payload)
|
||||
break
|
||||
|
||||
case 'pull_request':
|
||||
await handlePullRequestEvent(payload)
|
||||
break
|
||||
|
||||
default:
|
||||
logger.debug(`Unhandled webhook event: ${event}`)
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Webhook handler error:', error)
|
||||
res.status(500).json({ error: 'Internal error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePushEvent(payload: any) {
|
||||
const branch = payload.ref.replace('refs/heads/', '')
|
||||
const commits = payload.commits || []
|
||||
|
||||
logger.info(`Push to ${branch}: ${commits.length} commits`)
|
||||
|
||||
// Find task by branch name
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.branchName, branch)
|
||||
})
|
||||
|
||||
if (task) {
|
||||
emitWebSocketEvent('task:push', {
|
||||
taskId: task.id,
|
||||
branch,
|
||||
commitsCount: commits.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePullRequestEvent(payload: any) {
|
||||
const action = payload.action // opened, closed, reopened, edited, synchronized
|
||||
const prNumber = payload.pull_request.number
|
||||
const state = payload.pull_request.state
|
||||
|
||||
logger.info(`PR #${prNumber}: ${action}`)
|
||||
|
||||
// Find task by PR number
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.prNumber, prNumber)
|
||||
})
|
||||
|
||||
if (task) {
|
||||
if (action === 'closed' && payload.pull_request.merged) {
|
||||
// PR was merged
|
||||
await db.update(tasks)
|
||||
.set({ state: 'staging' })
|
||||
.where(eq(tasks.id, task.id))
|
||||
|
||||
emitWebSocketEvent('task:merged', {
|
||||
taskId: task.id,
|
||||
prNumber
|
||||
})
|
||||
}
|
||||
|
||||
emitWebSocketEvent('task:pr_updated', {
|
||||
taskId: task.id,
|
||||
prNumber,
|
||||
action,
|
||||
state
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Router para Webhooks
|
||||
|
||||
```typescript
|
||||
// api/routes/webhooks.ts
|
||||
import { Router } from 'express'
|
||||
import { handleGiteaWebhook } from '../../services/gitea/webhooks'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/gitea', handleGiteaWebhook)
|
||||
|
||||
export default router
|
||||
```
|
||||
788
docs/02-backend/mcp-server.md
Normal file
788
docs/02-backend/mcp-server.md
Normal file
@@ -0,0 +1,788 @@
|
||||
# MCP Server para Agentes
|
||||
|
||||
El MCP (Model Context Protocol) Server es la interfaz que permite a los agentes Claude Code comunicarse con el backend y ejecutar operaciones.
|
||||
|
||||
## Arquitectura MCP
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Claude Code │ MCP Protocol │ MCP Server │
|
||||
│ (Agent Pod) │◄──────────────────►│ (Backend) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
|
||||
│ MySQL │ │ Gitea │ │ K8s │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Setup del MCP Server
|
||||
|
||||
```typescript
|
||||
// services/mcp/server.ts
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { tools } from './tools'
|
||||
import { handleToolCall } from './handlers'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class AgentMCPServer {
|
||||
private server: Server
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'aiworker-orchestrator',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
this.setupHandlers()
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
logger.info(`MCP: Tool called: ${name}`, { args })
|
||||
|
||||
try {
|
||||
const result = await handleToolCall(name, args)
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error(`MCP: Tool error: ${name}`, error)
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Error: ${error.message}`
|
||||
}],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
const transport = new StdioServerTransport()
|
||||
await this.server.connect(transport)
|
||||
logger.info('MCP Server started')
|
||||
}
|
||||
}
|
||||
|
||||
// Start MCP server
|
||||
let mcpServer: AgentMCPServer
|
||||
|
||||
export async function startMCPServer() {
|
||||
mcpServer = new AgentMCPServer()
|
||||
await mcpServer.start()
|
||||
return mcpServer
|
||||
}
|
||||
|
||||
export function getMCPServer() {
|
||||
return mcpServer
|
||||
}
|
||||
```
|
||||
|
||||
## Definición de Herramientas
|
||||
|
||||
```typescript
|
||||
// services/mcp/tools.ts
|
||||
import { z } from 'zod'
|
||||
|
||||
export const tools = [
|
||||
{
|
||||
name: 'get_next_task',
|
||||
description: 'Obtiene la siguiente tarea disponible de la cola',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agentId: {
|
||||
type: 'string',
|
||||
description: 'ID del agente solicitante'
|
||||
},
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Capacidades del agente (ej: ["javascript", "react"])'
|
||||
}
|
||||
},
|
||||
required: ['agentId']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'update_task_status',
|
||||
description: 'Actualiza el estado de una tarea',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['in_progress', 'needs_input', 'ready_to_test', 'completed'],
|
||||
description: 'Nuevo estado'
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Metadata adicional (duración, errores, etc.)'
|
||||
}
|
||||
},
|
||||
required: ['taskId', 'status']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'ask_user_question',
|
||||
description: 'Solicita información al usuario',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
},
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'Pregunta para el usuario'
|
||||
},
|
||||
context: {
|
||||
type: 'string',
|
||||
description: 'Contexto adicional'
|
||||
}
|
||||
},
|
||||
required: ['taskId', 'question']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'check_question_response',
|
||||
description: 'Verifica si el usuario ha respondido una pregunta',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
}
|
||||
},
|
||||
required: ['taskId']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create_branch',
|
||||
description: 'Crea una nueva rama en Gitea',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
},
|
||||
branchName: {
|
||||
type: 'string',
|
||||
description: 'Nombre de la rama (opcional, se genera automático)'
|
||||
}
|
||||
},
|
||||
required: ['taskId']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create_pull_request',
|
||||
description: 'Crea un Pull Request en Gitea',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Título del PR'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Descripción del PR'
|
||||
}
|
||||
},
|
||||
required: ['taskId', 'title', 'description']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'trigger_preview_deploy',
|
||||
description: 'Despliega un preview environment en K8s',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
}
|
||||
},
|
||||
required: ['taskId']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'get_task_details',
|
||||
description: 'Obtiene detalles completos de una tarea',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'ID de la tarea'
|
||||
}
|
||||
},
|
||||
required: ['taskId']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'log_activity',
|
||||
description: 'Registra actividad del agente',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agentId: {
|
||||
type: 'string',
|
||||
description: 'ID del agente'
|
||||
},
|
||||
level: {
|
||||
type: 'string',
|
||||
enum: ['debug', 'info', 'warn', 'error'],
|
||||
description: 'Nivel de log'
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Mensaje'
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Metadata adicional'
|
||||
}
|
||||
},
|
||||
required: ['agentId', 'message']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'heartbeat',
|
||||
description: 'Envía heartbeat para indicar que el agente está activo',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agentId: {
|
||||
type: 'string',
|
||||
description: 'ID del agente'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['idle', 'busy', 'error'],
|
||||
description: 'Estado actual'
|
||||
}
|
||||
},
|
||||
required: ['agentId', 'status']
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Implementación de Handlers
|
||||
|
||||
```typescript
|
||||
// services/mcp/handlers.ts
|
||||
import { db } from '../../db/client'
|
||||
import { tasks, agents, taskQuestions, agentLogs } from '../../db/schema'
|
||||
import { eq, and, desc, asc } from 'drizzle-orm'
|
||||
import { GiteaClient } from '../gitea/client'
|
||||
import { K8sClient } from '../kubernetes/client'
|
||||
import { getRedis } from '../../config/redis'
|
||||
import { emitWebSocketEvent } from '../../api/websocket/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const giteaClient = new GiteaClient()
|
||||
const k8sClient = new K8sClient()
|
||||
const redis = getRedis()
|
||||
|
||||
export async function handleToolCall(name: string, args: any) {
|
||||
switch (name) {
|
||||
case 'get_next_task':
|
||||
return await getNextTask(args)
|
||||
|
||||
case 'update_task_status':
|
||||
return await updateTaskStatus(args)
|
||||
|
||||
case 'ask_user_question':
|
||||
return await askUserQuestion(args)
|
||||
|
||||
case 'check_question_response':
|
||||
return await checkQuestionResponse(args)
|
||||
|
||||
case 'create_branch':
|
||||
return await createBranch(args)
|
||||
|
||||
case 'create_pull_request':
|
||||
return await createPullRequest(args)
|
||||
|
||||
case 'trigger_preview_deploy':
|
||||
return await triggerPreviewDeploy(args)
|
||||
|
||||
case 'get_task_details':
|
||||
return await getTaskDetails(args)
|
||||
|
||||
case 'log_activity':
|
||||
return await logActivity(args)
|
||||
|
||||
case 'heartbeat':
|
||||
return await heartbeat(args)
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TOOL IMPLEMENTATIONS
|
||||
// ============================================
|
||||
|
||||
async function getNextTask(args: { agentId: string; capabilities?: string[] }) {
|
||||
const { agentId } = args
|
||||
|
||||
// Get next task from backlog
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
with: {
|
||||
project: true
|
||||
},
|
||||
orderBy: [desc(tasks.priority), asc(tasks.createdAt)]
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ message: 'No tasks available' })
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// Assign task to agent
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'in_progress',
|
||||
assignedAgentId: agentId,
|
||||
assignedAt: new Date(),
|
||||
startedAt: new Date()
|
||||
})
|
||||
.where(eq(tasks.id, task.id))
|
||||
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'busy',
|
||||
currentTaskId: task.id
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
// Emit WebSocket event
|
||||
emitWebSocketEvent('task:status_changed', {
|
||||
taskId: task.id,
|
||||
oldState: 'backlog',
|
||||
newState: 'in_progress',
|
||||
agentId
|
||||
})
|
||||
|
||||
// Cache invalidation
|
||||
await redis.del(`task:${task.id}`)
|
||||
await redis.del(`task:list:${task.projectId}`)
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
task: {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
project: task.project
|
||||
}
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskStatus(args: { taskId: string; status: string; metadata?: any }) {
|
||||
const { taskId, status, metadata } = args
|
||||
|
||||
const updates: any = { state: status }
|
||||
|
||||
if (status === 'completed') {
|
||||
updates.completedAt = new Date()
|
||||
}
|
||||
|
||||
if (metadata?.durationMinutes) {
|
||||
updates.actualDurationMinutes = metadata.durationMinutes
|
||||
}
|
||||
|
||||
await db.update(tasks)
|
||||
.set(updates)
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
// If task completed, free up agent
|
||||
if (status === 'completed' || status === 'ready_to_test') {
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, taskId)
|
||||
})
|
||||
|
||||
if (task?.assignedAgentId) {
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
tasksCompleted: db.$sql`tasks_completed + 1`
|
||||
})
|
||||
.where(eq(agents.id, task.assignedAgentId))
|
||||
}
|
||||
}
|
||||
|
||||
emitWebSocketEvent('task:status_changed', {
|
||||
taskId,
|
||||
newState: status,
|
||||
metadata
|
||||
})
|
||||
|
||||
await redis.del(`task:${taskId}`)
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: true })
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function askUserQuestion(args: { taskId: string; question: string; context?: string }) {
|
||||
const { taskId, question, context } = args
|
||||
|
||||
// Update task state
|
||||
await db.update(tasks)
|
||||
.set({ state: 'needs_input' })
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
// Insert question
|
||||
const questionId = crypto.randomUUID()
|
||||
await db.insert(taskQuestions).values({
|
||||
id: questionId,
|
||||
taskId,
|
||||
question,
|
||||
context,
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
// Notify frontend
|
||||
emitWebSocketEvent('task:needs_input', {
|
||||
taskId,
|
||||
questionId,
|
||||
question,
|
||||
context
|
||||
})
|
||||
|
||||
await redis.del(`task:${taskId}`)
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Question sent to user',
|
||||
questionId
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function checkQuestionResponse(args: { taskId: string }) {
|
||||
const { taskId } = args
|
||||
|
||||
const question = await db.query.taskQuestions.findFirst({
|
||||
where: and(
|
||||
eq(taskQuestions.taskId, taskId),
|
||||
eq(taskQuestions.status, 'answered')
|
||||
),
|
||||
orderBy: [desc(taskQuestions.respondedAt)]
|
||||
})
|
||||
|
||||
if (!question || !question.response) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
hasResponse: false,
|
||||
message: 'No response yet'
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// Update task back to in_progress
|
||||
await db.update(tasks)
|
||||
.set({ state: 'in_progress' })
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
hasResponse: true,
|
||||
response: question.response,
|
||||
question: question.question
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function createBranch(args: { taskId: string; branchName?: string }) {
|
||||
const { taskId, branchName } = args
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, taskId),
|
||||
with: { project: true }
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('Task not found')
|
||||
}
|
||||
|
||||
const branch = branchName || `task-${taskId.slice(0, 8)}-${task.title.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}`
|
||||
|
||||
// Create branch in Gitea
|
||||
await giteaClient.createBranch(
|
||||
task.project.giteaOwner!,
|
||||
task.project.giteaRepoName!,
|
||||
branch,
|
||||
task.project.defaultBranch!
|
||||
)
|
||||
|
||||
// Update task
|
||||
await db.update(tasks)
|
||||
.set({ branchName: branch })
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
branchName: branch,
|
||||
repoUrl: task.project.giteaRepoUrl
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function createPullRequest(args: { taskId: string; title: string; description: string }) {
|
||||
const { taskId, title, description } = args
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, taskId),
|
||||
with: { project: true }
|
||||
})
|
||||
|
||||
if (!task || !task.branchName) {
|
||||
throw new Error('Task not found or branch not created')
|
||||
}
|
||||
|
||||
const pr = await giteaClient.createPullRequest(
|
||||
task.project.giteaOwner!,
|
||||
task.project.giteaRepoName!,
|
||||
{
|
||||
title,
|
||||
body: description,
|
||||
head: task.branchName,
|
||||
base: task.project.defaultBranch!
|
||||
}
|
||||
)
|
||||
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
prNumber: pr.number,
|
||||
prUrl: pr.html_url
|
||||
})
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
emitWebSocketEvent('task:pr_created', {
|
||||
taskId,
|
||||
prUrl: pr.html_url,
|
||||
prNumber: pr.number
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
prUrl: pr.html_url,
|
||||
prNumber: pr.number
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerPreviewDeploy(args: { taskId: string }) {
|
||||
const { taskId } = args
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, taskId),
|
||||
with: { project: true }
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('Task not found')
|
||||
}
|
||||
|
||||
const previewNamespace = `preview-task-${taskId.slice(0, 8)}`
|
||||
const previewUrl = `https://${previewNamespace}.preview.aiworker.dev`
|
||||
|
||||
// Deploy to K8s
|
||||
await k8sClient.createPreviewDeployment({
|
||||
namespace: previewNamespace,
|
||||
taskId,
|
||||
projectId: task.projectId,
|
||||
image: task.project.dockerImage!,
|
||||
branch: task.branchName!,
|
||||
envVars: task.project.envVars as Record<string, string>
|
||||
})
|
||||
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'ready_to_test',
|
||||
previewNamespace,
|
||||
previewUrl,
|
||||
previewDeployedAt: new Date()
|
||||
})
|
||||
.where(eq(tasks.id, taskId))
|
||||
|
||||
emitWebSocketEvent('task:ready_to_test', {
|
||||
taskId,
|
||||
previewUrl
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
previewUrl,
|
||||
namespace: previewNamespace
|
||||
})
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function getTaskDetails(args: { taskId: string }) {
|
||||
const { taskId } = args
|
||||
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.id, taskId),
|
||||
with: {
|
||||
project: true,
|
||||
questions: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('Task not found')
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ task })
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function logActivity(args: { agentId: string; level?: string; message: string; metadata?: any }) {
|
||||
const { agentId, level = 'info', message, metadata } = args
|
||||
|
||||
await db.insert(agentLogs).values({
|
||||
agentId,
|
||||
level: level as any,
|
||||
message,
|
||||
metadata
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: true })
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
async function heartbeat(args: { agentId: string; status: string }) {
|
||||
const { agentId, status } = args
|
||||
|
||||
await db.update(agents)
|
||||
.set({
|
||||
lastHeartbeat: new Date(),
|
||||
status: status as any
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: true })
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Uso desde Claude Code Agent
|
||||
|
||||
Desde el pod del agente, Claude Code usaría las herramientas así:
|
||||
|
||||
```bash
|
||||
# En el pod del agente, configurar MCP
|
||||
# claude-code config add-mcp-server aiworker stdio \
|
||||
# "bun run /app/mcp-client.js"
|
||||
|
||||
# Ejemplo de uso en conversación con Claude Code:
|
||||
# User: "Toma la siguiente tarea y trabaja en ella"
|
||||
# Claude Code internamente llama:
|
||||
# - get_next_task({ agentId: "agent-xyz" })
|
||||
# - Si necesita info: ask_user_question({ taskId: "...", question: "..." })
|
||||
# - Trabaja en el código
|
||||
# - create_branch({ taskId: "..." })
|
||||
# - (commits and pushes)
|
||||
# - create_pull_request({ taskId: "...", title: "...", description: "..." })
|
||||
# - trigger_preview_deploy({ taskId: "..." })
|
||||
# - update_task_status({ taskId: "...", status: "ready_to_test" })
|
||||
```
|
||||
520
docs/02-backend/queue-system.md
Normal file
520
docs/02-backend/queue-system.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Sistema de Colas con BullMQ
|
||||
|
||||
## Setup de BullMQ
|
||||
|
||||
```typescript
|
||||
// services/queue/config.ts
|
||||
import { Queue, Worker, QueueScheduler } from 'bullmq'
|
||||
import { getRedis } from '../../config/redis'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
const connection = getRedis()
|
||||
|
||||
export const queues = {
|
||||
tasks: new Queue('tasks', { connection }),
|
||||
deploys: new Queue('deploys', { connection }),
|
||||
merges: new Queue('merges', { connection }),
|
||||
cleanup: new Queue('cleanup', { connection }),
|
||||
}
|
||||
|
||||
// Queue options
|
||||
export const defaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 3600, // 1 hour
|
||||
count: 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 86400, // 24 hours
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Task Queue
|
||||
|
||||
```typescript
|
||||
// services/queue/task-queue.ts
|
||||
import { queues, defaultJobOptions } from './config'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export interface TaskJob {
|
||||
taskId: string
|
||||
projectId: string
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||
}
|
||||
|
||||
export async function enqueueTask(data: TaskJob) {
|
||||
const priorityMap = {
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
}
|
||||
|
||||
await queues.tasks.add('process-task', data, {
|
||||
...defaultJobOptions,
|
||||
priority: priorityMap[data.priority],
|
||||
jobId: data.taskId,
|
||||
})
|
||||
|
||||
logger.info(`Task queued: ${data.taskId}`)
|
||||
}
|
||||
|
||||
export async function dequeueTask(taskId: string) {
|
||||
const job = await queues.tasks.getJob(taskId)
|
||||
if (job) {
|
||||
await job.remove()
|
||||
logger.info(`Task dequeued: ${taskId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQueuedTasks() {
|
||||
const jobs = await queues.tasks.getJobs(['waiting', 'active'])
|
||||
return jobs.map(job => ({
|
||||
id: job.id,
|
||||
data: job.data,
|
||||
state: await job.getState(),
|
||||
progress: job.progress,
|
||||
attemptsMade: job.attemptsMade,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## Deploy Queue
|
||||
|
||||
```typescript
|
||||
// services/queue/deploy-queue.ts
|
||||
import { queues, defaultJobOptions } from './config'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export interface DeployJob {
|
||||
deploymentId: string
|
||||
projectId: string
|
||||
taskId?: string
|
||||
environment: 'preview' | 'staging' | 'production'
|
||||
branch: string
|
||||
commitHash: string
|
||||
}
|
||||
|
||||
export async function enqueueDeploy(data: DeployJob) {
|
||||
await queues.deploys.add('deploy', data, {
|
||||
...defaultJobOptions,
|
||||
priority: data.environment === 'production' ? 1 : 2,
|
||||
jobId: data.deploymentId,
|
||||
})
|
||||
|
||||
logger.info(`Deploy queued: ${data.environment} - ${data.deploymentId}`)
|
||||
}
|
||||
|
||||
export async function getDeployStatus(deploymentId: string) {
|
||||
const job = await queues.deploys.getJob(deploymentId)
|
||||
if (!job) return null
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
state: await job.getState(),
|
||||
progress: job.progress,
|
||||
result: job.returnvalue,
|
||||
failedReason: job.failedReason,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Merge Queue
|
||||
|
||||
```typescript
|
||||
// services/queue/merge-queue.ts
|
||||
import { queues, defaultJobOptions } from './config'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export interface MergeJob {
|
||||
taskGroupId: string
|
||||
projectId: string
|
||||
taskIds: string[]
|
||||
targetBranch: 'staging' | 'main'
|
||||
}
|
||||
|
||||
export async function enqueueMerge(data: MergeJob) {
|
||||
await queues.merges.add('merge-tasks', data, {
|
||||
...defaultJobOptions,
|
||||
priority: data.targetBranch === 'main' ? 1 : 2,
|
||||
jobId: data.taskGroupId,
|
||||
})
|
||||
|
||||
logger.info(`Merge queued: ${data.taskGroupId}`)
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup Queue
|
||||
|
||||
```typescript
|
||||
// services/queue/cleanup-queue.ts
|
||||
import { queues, defaultJobOptions } from './config'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export interface CleanupJob {
|
||||
type: 'preview-namespace' | 'old-logs' | 'completed-jobs'
|
||||
namespaceOrResource: string
|
||||
ageHours: number
|
||||
}
|
||||
|
||||
export async function enqueueCleanup(data: CleanupJob) {
|
||||
await queues.cleanup.add('cleanup', data, {
|
||||
...defaultJobOptions,
|
||||
attempts: 1,
|
||||
})
|
||||
|
||||
logger.info(`Cleanup queued: ${data.type}`)
|
||||
}
|
||||
|
||||
// Schedule recurring cleanup
|
||||
export async function scheduleRecurringCleanup() {
|
||||
// Clean preview namespaces older than 7 days
|
||||
await queues.cleanup.add(
|
||||
'cleanup-preview-namespaces',
|
||||
{
|
||||
type: 'preview-namespace',
|
||||
ageHours: 168, // 7 days
|
||||
},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '0 2 * * *', // Daily at 2 AM
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Clean old logs
|
||||
await queues.cleanup.add(
|
||||
'cleanup-old-logs',
|
||||
{
|
||||
type: 'old-logs',
|
||||
ageHours: 720, // 30 days
|
||||
},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '0 3 * * 0', // Weekly on Sunday at 3 AM
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('Recurring cleanup jobs scheduled')
|
||||
}
|
||||
```
|
||||
|
||||
## Workers Implementation
|
||||
|
||||
```typescript
|
||||
// services/queue/workers.ts
|
||||
import { Worker, Job } from 'bullmq'
|
||||
import { getRedis } from '../../config/redis'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { db } from '../../db/client'
|
||||
import { tasks, agents, deployments } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { K8sClient } from '../kubernetes/client'
|
||||
import { GiteaClient } from '../gitea/client'
|
||||
import { TaskJob, DeployJob, MergeJob, CleanupJob } from './types'
|
||||
|
||||
const connection = getRedis()
|
||||
const k8sClient = new K8sClient()
|
||||
const giteaClient = new GiteaClient()
|
||||
|
||||
// ============================================
|
||||
// TASK WORKER
|
||||
// ============================================
|
||||
|
||||
const taskWorker = new Worker(
|
||||
'tasks',
|
||||
async (job: Job<TaskJob>) => {
|
||||
logger.info(`Processing task job: ${job.id}`)
|
||||
|
||||
// Check if there's an available agent
|
||||
const availableAgent = await db.query.agents.findFirst({
|
||||
where: eq(agents.status, 'idle'),
|
||||
})
|
||||
|
||||
if (!availableAgent) {
|
||||
logger.info('No available agents, task will be retried')
|
||||
throw new Error('No available agents')
|
||||
}
|
||||
|
||||
// Task will be picked up by agent via MCP get_next_task
|
||||
logger.info(`Task ${job.data.taskId} ready for agent pickup`)
|
||||
|
||||
return { success: true, readyForPickup: true }
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
}
|
||||
)
|
||||
|
||||
taskWorker.on('completed', (job) => {
|
||||
logger.info(`Task job completed: ${job.id}`)
|
||||
})
|
||||
|
||||
taskWorker.on('failed', (job, err) => {
|
||||
logger.error(`Task job failed: ${job?.id}`, err)
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// DEPLOY WORKER
|
||||
// ============================================
|
||||
|
||||
const deployWorker = new Worker(
|
||||
'deploys',
|
||||
async (job: Job<DeployJob>) => {
|
||||
const { deploymentId, projectId, environment, branch, commitHash } = job.data
|
||||
|
||||
logger.info(`Deploying: ${environment} - ${deploymentId}`)
|
||||
|
||||
// Update deployment 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(deployments.projectId, projectId),
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
job.updateProgress(20)
|
||||
|
||||
// Prepare deployment
|
||||
const namespace = environment === 'production'
|
||||
? `${project.k8sNamespace}-prod`
|
||||
: environment === 'staging'
|
||||
? `${project.k8sNamespace}-staging`
|
||||
: job.data.taskId
|
||||
? `preview-task-${job.data.taskId.slice(0, 8)}`
|
||||
: project.k8sNamespace
|
||||
|
||||
job.updateProgress(40)
|
||||
|
||||
// Deploy to K8s
|
||||
await k8sClient.createOrUpdateDeployment({
|
||||
namespace,
|
||||
name: `${project.name}-${environment}`,
|
||||
image: `${project.dockerImage}:${commitHash.slice(0, 7)}`,
|
||||
envVars: project.envVars as Record<string, string>,
|
||||
replicas: project.replicas || 1,
|
||||
resources: {
|
||||
cpu: project.cpuLimit || '500m',
|
||||
memory: project.memoryLimit || '512Mi',
|
||||
},
|
||||
})
|
||||
|
||||
job.updateProgress(70)
|
||||
|
||||
// Create/update ingress
|
||||
const url = await k8sClient.createOrUpdateIngress({
|
||||
namespace,
|
||||
name: `${project.name}-${environment}`,
|
||||
host: environment === 'production'
|
||||
? `${project.name}.aiworker.dev`
|
||||
: `${environment}-${project.name}.aiworker.dev`,
|
||||
serviceName: `${project.name}-${environment}`,
|
||||
servicePort: 3000,
|
||||
})
|
||||
|
||||
job.updateProgress(90)
|
||||
|
||||
// Update deployment record
|
||||
await db.update(deployments)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
url,
|
||||
durationSeconds: Math.floor(
|
||||
(new Date().getTime() - job.processedOn!) / 1000
|
||||
),
|
||||
})
|
||||
.where(eq(deployments.id, deploymentId))
|
||||
|
||||
job.updateProgress(100)
|
||||
|
||||
logger.info(`Deploy completed: ${environment} - ${url}`)
|
||||
|
||||
return { success: true, url }
|
||||
} catch (error) {
|
||||
// Update deployment as failed
|
||||
await db.update(deployments)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: error.message,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(deployments.id, deploymentId))
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 3,
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// MERGE WORKER
|
||||
// ============================================
|
||||
|
||||
const mergeWorker = new Worker(
|
||||
'merges',
|
||||
async (job: Job<MergeJob>) => {
|
||||
const { taskGroupId, projectId, taskIds, targetBranch } = job.data
|
||||
|
||||
logger.info(`Merging tasks: ${taskIds.join(', ')} to ${targetBranch}`)
|
||||
|
||||
// Get project and tasks
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(deployments.projectId, projectId),
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
const tasksList = await db.query.tasks.findMany({
|
||||
where: (tasks, { inArray }) => inArray(tasks.id, taskIds),
|
||||
})
|
||||
|
||||
job.updateProgress(20)
|
||||
|
||||
// Merge each PR
|
||||
for (const task of tasksList) {
|
||||
if (task.prNumber) {
|
||||
await giteaClient.mergePullRequest(
|
||||
project.giteaOwner!,
|
||||
project.giteaRepoName!,
|
||||
task.prNumber,
|
||||
'squash'
|
||||
)
|
||||
|
||||
job.updateProgress(20 + (40 / tasksList.length))
|
||||
}
|
||||
}
|
||||
|
||||
job.updateProgress(60)
|
||||
|
||||
// Create staging/production branch if needed
|
||||
// Then trigger deploy
|
||||
// ... implementation
|
||||
|
||||
job.updateProgress(100)
|
||||
|
||||
logger.info(`Merge completed: ${taskGroupId}`)
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 2,
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CLEANUP WORKER
|
||||
// ============================================
|
||||
|
||||
const cleanupWorker = new Worker(
|
||||
'cleanup',
|
||||
async (job: Job<CleanupJob>) => {
|
||||
const { type, ageHours } = job.data
|
||||
|
||||
logger.info(`Cleanup: ${type}`)
|
||||
|
||||
switch (type) {
|
||||
case 'preview-namespace':
|
||||
await k8sClient.cleanupOldPreviewNamespaces(ageHours)
|
||||
break
|
||||
|
||||
case 'old-logs':
|
||||
const cutoffDate = new Date(Date.now() - ageHours * 60 * 60 * 1000)
|
||||
await db.delete(agentLogs)
|
||||
.where(lt(agentLogs.createdAt, cutoffDate))
|
||||
break
|
||||
}
|
||||
|
||||
logger.info(`Cleanup completed: ${type}`)
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1,
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// START ALL WORKERS
|
||||
// ============================================
|
||||
|
||||
export async function startQueueWorkers() {
|
||||
logger.info('Starting BullMQ workers...')
|
||||
|
||||
// Workers are already instantiated above
|
||||
// Just schedule recurring jobs
|
||||
await scheduleRecurringCleanup()
|
||||
|
||||
logger.info('✓ All workers started')
|
||||
|
||||
return {
|
||||
taskWorker,
|
||||
deployWorker,
|
||||
mergeWorker,
|
||||
cleanupWorker,
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('Shutting down workers...')
|
||||
await taskWorker.close()
|
||||
await deployWorker.close()
|
||||
await mergeWorker.close()
|
||||
await cleanupWorker.close()
|
||||
logger.info('Workers shut down')
|
||||
process.exit(0)
|
||||
})
|
||||
```
|
||||
|
||||
## Monitorización de Colas
|
||||
|
||||
```typescript
|
||||
// api/routes/queues.ts
|
||||
import { Router } from 'express'
|
||||
import { queues } from '../../services/queue/config'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
const status = await Promise.all(
|
||||
Object.entries(queues).map(async ([name, queue]) => ({
|
||||
name,
|
||||
waiting: await queue.getWaitingCount(),
|
||||
active: await queue.getActiveCount(),
|
||||
completed: await queue.getCompletedCount(),
|
||||
failed: await queue.getFailedCount(),
|
||||
}))
|
||||
)
|
||||
|
||||
res.json({ queues: status })
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
498
docs/03-frontend/componentes.md
Normal file
498
docs/03-frontend/componentes.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Componentes Principales
|
||||
|
||||
## KanbanBoard
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanBoard.tsx
|
||||
import { useMemo } from 'react'
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { useTasks, useUpdateTask } from '@/hooks/useTasks'
|
||||
import KanbanColumn from './KanbanColumn'
|
||||
import { Task, TaskState } from '@/types/task'
|
||||
|
||||
const COLUMNS: { id: TaskState; title: string; color: string }[] = [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'gray' },
|
||||
{ id: 'in_progress', title: 'En Progreso', color: 'blue' },
|
||||
{ id: 'needs_input', title: 'Necesita Respuestas', color: 'yellow' },
|
||||
{ id: 'ready_to_test', title: 'Listo para Probar', color: 'purple' },
|
||||
{ id: 'approved', title: 'Aprobado', color: 'green' },
|
||||
{ id: 'staging', title: 'Staging', color: 'indigo' },
|
||||
{ id: 'production', title: 'Producción', color: 'emerald' },
|
||||
]
|
||||
|
||||
interface KanbanBoardProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export function KanbanBoard({ projectId }: KanbanBoardProps) {
|
||||
const { data: tasks = [], isLoading } = useTasks({ projectId })
|
||||
const updateTask = useUpdateTask()
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const tasksByState = useMemo(() => {
|
||||
return COLUMNS.reduce((acc, column) => {
|
||||
acc[column.id] = tasks.filter((task) => task.state === column.id)
|
||||
return acc
|
||||
}, {} as Record<TaskState, Task[]>)
|
||||
}, [tasks])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const taskId = active.id as string
|
||||
const newState = over.id as TaskState
|
||||
|
||||
updateTask.mutate({
|
||||
taskId,
|
||||
updates: { state: newState },
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center p-8">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
tasks={tasksByState[column.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## KanbanColumn
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanColumn.tsx
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import TaskCard from './TaskCard'
|
||||
import { Task, TaskState } from '@/types/task'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskState
|
||||
title: string
|
||||
color: string
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
export default function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
|
||||
const { setNodeRef } = useDroppable({ id })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 flex-shrink-0">
|
||||
<div className={`bg-${color}-100 border-${color}-300 border-t-4 rounded-t-lg p-3`}>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{title}
|
||||
<span className="ml-2 text-sm text-gray-500">({tasks.length})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="flex-1 bg-gray-50 border border-t-0 border-gray-200 rounded-b-lg p-3 min-h-[200px]"
|
||||
>
|
||||
<SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-center text-gray-400 text-sm py-8">
|
||||
Sin tareas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## TaskCard
|
||||
|
||||
```typescript
|
||||
// components/kanban/TaskCard.tsx
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Clock, User, GitBranch, AlertCircle } from 'lucide-react'
|
||||
import { Task } from '@/types/task'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-blue-100 text-blue-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
export default function TaskCard({ task }: TaskCardProps) {
|
||||
const navigate = useNavigate()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="card cursor-move hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/tasks/${task.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-sm line-clamp-2">{task.title}</h4>
|
||||
<span className={`badge ${PRIORITY_COLORS[task.priority]}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-600 line-clamp-2 mb-3">{task.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{task.assignedAgent && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>Agent {task.assignedAgent.podName.slice(0, 8)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.branchName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{task.branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.state === 'needs_input' && (
|
||||
<div className="flex items-center gap-1 text-yellow-600">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>Pregunta pendiente</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.actualDurationMinutes && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{task.actualDurationMinutes}min</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.previewUrl && (
|
||||
<a
|
||||
href={task.previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 text-xs text-primary-600 hover:underline block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Ver Preview →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## WebTerminal
|
||||
|
||||
```typescript
|
||||
// components/terminal/WebTerminal.tsx
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
interface WebTerminalProps {
|
||||
agentId: string
|
||||
podName: string
|
||||
}
|
||||
|
||||
export function WebTerminal({ agentId, podName }: WebTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal>()
|
||||
const fitAddonRef = useRef<FitAddon>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return
|
||||
|
||||
// Create terminal
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(terminalRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect to backend WebSocket for terminal
|
||||
const ws = new WebSocket(`ws://localhost:3000/terminal/${agentId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
term.writeln(`Connected to ${podName}`)
|
||||
term.writeln('')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data)
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
ws.send(data)
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit()
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
term.dispose()
|
||||
ws.close()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [agentId, podName])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#1e1e1e] rounded-lg overflow-hidden">
|
||||
<div ref={terminalRef} className="h-full w-full p-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## TaskForm
|
||||
|
||||
```typescript
|
||||
// components/tasks/TaskForm.tsx
|
||||
import { useState } from 'react'
|
||||
import { useCreateTask } from '@/hooks/useTasks'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface TaskFormProps {
|
||||
projectId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function TaskForm({ projectId, onSuccess }: TaskFormProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium')
|
||||
|
||||
const createTask = useCreateTask()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!title.trim()) {
|
||||
toast.error('El título es requerido')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createTask.mutateAsync({
|
||||
projectId,
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
})
|
||||
|
||||
toast.success('Tarea creada')
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setPriority('medium')
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
toast.error('Error al crear tarea')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Título"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ej: Implementar autenticación"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe la tarea en detalle..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Prioridad"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as any)}
|
||||
options={[
|
||||
{ value: 'low', label: 'Baja' },
|
||||
{ value: 'medium', label: 'Media' },
|
||||
{ value: 'high', label: 'Alta' },
|
||||
{ value: 'urgent', label: 'Urgente' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={createTask.isPending} className="w-full">
|
||||
Crear Tarea
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## AgentCard
|
||||
|
||||
```typescript
|
||||
// components/agents/AgentCard.tsx
|
||||
import { Agent } from '@/types/agent'
|
||||
import { Activity, Clock, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent
|
||||
onOpenTerminal?: (agentId: string) => void
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
idle: { color: 'green', icon: CheckCircle, label: 'Inactivo' },
|
||||
busy: { color: 'blue', icon: Activity, label: 'Trabajando' },
|
||||
error: { color: 'red', icon: AlertCircle, label: 'Error' },
|
||||
offline: { color: 'gray', icon: AlertCircle, label: 'Offline' },
|
||||
initializing: { color: 'yellow', icon: Clock, label: 'Inicializando' },
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, onOpenTerminal }: AgentCardProps) {
|
||||
const config = STATUS_CONFIG[agent.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{agent.podName}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">ID: {agent.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
|
||||
<span className={`badge bg-${config.color}-100 text-${config.color}-800`}>
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Tareas completadas:</span>
|
||||
<span className="font-medium">{agent.tasksCompleted}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Tiempo total:</span>
|
||||
<span className="font-medium">{agent.totalRuntimeMinutes}min</span>
|
||||
</div>
|
||||
|
||||
{agent.lastHeartbeat && (
|
||||
<div className="flex justify-between">
|
||||
<span>Último heartbeat:</span>
|
||||
<span className="font-medium">
|
||||
{formatDistanceToNow(new Date(agent.lastHeartbeat), {
|
||||
addSuffix: true,
|
||||
locale: es,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent.currentTask && (
|
||||
<div className="mt-3 p-2 bg-blue-50 rounded text-sm">
|
||||
<p className="text-blue-900 font-medium">Tarea actual:</p>
|
||||
<p className="text-blue-700 text-xs mt-1">{agent.currentTask.title}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.capabilities && agent.capabilities.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{agent.capabilities.map((cap) => (
|
||||
<span key={cap} className="badge bg-gray-100 text-gray-700 text-xs">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onOpenTerminal && (
|
||||
<button
|
||||
onClick={() => onOpenTerminal(agent.id)}
|
||||
className="mt-3 w-full btn-secondary text-sm"
|
||||
>
|
||||
Abrir Terminal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
422
docs/03-frontend/consolas-web.md
Normal file
422
docs/03-frontend/consolas-web.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Consolas Web con xterm.js
|
||||
|
||||
## Implementación del Terminal Web
|
||||
|
||||
### WebTerminal Component
|
||||
|
||||
```typescript
|
||||
// components/terminal/WebTerminal.tsx
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links'
|
||||
import { SearchAddon } from 'xterm-addon-search'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
interface WebTerminalProps {
|
||||
agentId: string
|
||||
podName: string
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export function WebTerminal({ agentId, podName, namespace = 'agents' }: WebTerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal>()
|
||||
const fitAddonRef = useRef<FitAddon>()
|
||||
const wsRef = useRef<WebSocket>()
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return
|
||||
|
||||
// Create terminal instance
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
selection: '#264f78',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
})
|
||||
|
||||
// Addons
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.loadAddon(searchAddon)
|
||||
|
||||
// Open terminal
|
||||
term.open(terminalRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
// Store refs
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// Connect to backend WebSocket
|
||||
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:3000'}/terminal/${agentId}`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true)
|
||||
setError(null)
|
||||
term.writeln(`\x1b[32m✓\x1b[0m Connected to ${podName}`)
|
||||
term.writeln('')
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
setError('Connection error')
|
||||
term.writeln(`\x1b[31m✗\x1b[0m Connection error`)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false)
|
||||
term.writeln('')
|
||||
term.writeln(`\x1b[33m⚠\x1b[0m Disconnected from ${podName}`)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data)
|
||||
}
|
||||
|
||||
// Send input to backend
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle terminal resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit()
|
||||
// Send resize info to backend
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
term.dispose()
|
||||
ws.close()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [agentId, podName, namespace])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-800 text-white px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="font-mono text-sm">{podName}</span>
|
||||
<span className="text-gray-400 text-xs">({namespace})</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<span className="text-red-400 text-xs">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 bg-[#1e1e1e] overflow-hidden">
|
||||
<div ref={terminalRef} className="h-full w-full p-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal Tabs Manager
|
||||
|
||||
```typescript
|
||||
// components/terminal/TerminalTabs.tsx
|
||||
import { X } from 'lucide-react'
|
||||
import { useTerminalStore } from '@/store/terminalStore'
|
||||
import { WebTerminal } from './WebTerminal'
|
||||
|
||||
export function TerminalTabs() {
|
||||
const { tabs, activeTabId, setActiveTab, closeTerminal } = useTerminalStore()
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
<p>No hay terminales abiertas</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center bg-gray-800 border-b border-gray-700 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 cursor-pointer
|
||||
${tab.isActive ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}
|
||||
border-r border-gray-700
|
||||
`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="font-mono text-sm truncate max-w-[150px]">
|
||||
{tab.podName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTerminal(tab.id)
|
||||
}}
|
||||
className="hover:text-red-400"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active terminal */}
|
||||
<div className="flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`h-full ${tab.isActive ? 'block' : 'hidden'}`}
|
||||
>
|
||||
<WebTerminal
|
||||
agentId={tab.agentId}
|
||||
podName={tab.podName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal Page/View
|
||||
|
||||
```typescript
|
||||
// pages/TerminalsView.tsx
|
||||
import { TerminalTabs } from '@/components/terminal/TerminalTabs'
|
||||
import { useAgents } from '@/hooks/useAgents'
|
||||
import { useTerminalStore } from '@/store/terminalStore'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
export default function TerminalsView() {
|
||||
const { data: agents = [] } = useAgents()
|
||||
const { openTerminal } = useTerminalStore()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar with agents */}
|
||||
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Agentes Disponibles</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => openTerminal(agent.id, agent.podName)}
|
||||
className="w-full text-left p-3 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm truncate">{agent.podName}</span>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
agent.status === 'idle' ? 'bg-green-400' :
|
||||
agent.status === 'busy' ? 'bg-blue-400' :
|
||||
'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{agent.status}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminals */}
|
||||
<div className="flex-1">
|
||||
<TerminalTabs />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Backend WebSocket Handler
|
||||
|
||||
```typescript
|
||||
// backend: api/websocket/terminal.ts
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import { K8sClient } from '../../services/kubernetes/client'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
const k8sClient = new K8sClient()
|
||||
|
||||
export function setupTerminalWebSocket(io: SocketIOServer) {
|
||||
io.of('/terminal').on('connection', async (socket: Socket) => {
|
||||
const agentId = socket.handshake.query.agentId as string
|
||||
|
||||
if (!agentId) {
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Terminal connection: agent ${agentId}`)
|
||||
|
||||
try {
|
||||
// Get agent pod info
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
socket.emit('error', { message: 'Agent not found' })
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to K8s pod exec
|
||||
const stream = await k8sClient.execInPod({
|
||||
namespace: agent.k8sNamespace,
|
||||
podName: agent.podName,
|
||||
command: ['/bin/bash'],
|
||||
})
|
||||
|
||||
// Forward data from K8s to client
|
||||
stream.stdout.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString())
|
||||
})
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString())
|
||||
})
|
||||
|
||||
// Forward data from client to K8s
|
||||
socket.on('data', (data: string) => {
|
||||
stream.stdin.write(data)
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
socket.on('resize', ({ cols, rows }: { cols: number; rows: number }) => {
|
||||
stream.resize({ cols, rows })
|
||||
})
|
||||
|
||||
// Cleanup on disconnect
|
||||
socket.on('disconnect', () => {
|
||||
logger.info(`Terminal disconnected: agent ${agentId}`)
|
||||
stream.stdin.end()
|
||||
stream.destroy()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Terminal connection error:', error)
|
||||
socket.emit('error', { message: 'Failed to connect to pod' })
|
||||
socket.disconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Features Adicionales
|
||||
|
||||
### Copy/Paste
|
||||
|
||||
```typescript
|
||||
// En WebTerminal component
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
// Ctrl+C / Cmd+C
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(text)
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
```
|
||||
|
||||
### Clear Terminal
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={() => xtermRef.current?.clear()}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
```
|
||||
|
||||
### Download Log
|
||||
|
||||
```typescript
|
||||
const downloadLog = () => {
|
||||
if (!xtermRef.current) return
|
||||
|
||||
const buffer = xtermRef.current.buffer.active
|
||||
let content = ''
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i)
|
||||
if (line) {
|
||||
content += line.translateToString(true) + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${podName}-${Date.now()}.log`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
```
|
||||
504
docs/03-frontend/estado.md
Normal file
504
docs/03-frontend/estado.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Gestión de Estado
|
||||
|
||||
## React Query para Server State
|
||||
|
||||
```typescript
|
||||
// hooks/useTasks.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Task, CreateTaskInput, UpdateTaskInput } from '@/types/task'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export function useTasks(filters?: { projectId?: string; state?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ tasks: Task[] }>('/tasks', { params: filters })
|
||||
return data.tasks
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTask(taskId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', taskId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ task: Task }>(`/tasks/${taskId}`)
|
||||
return data.task
|
||||
},
|
||||
enabled: !!taskId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateTaskInput) => {
|
||||
const { data } = await api.post('/tasks', input)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ taskId, updates }: { taskId: string; updates: UpdateTaskInput }) => {
|
||||
const { data } = await api.patch(`/tasks/${taskId}`, updates)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRespondToQuestion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
questionId,
|
||||
response,
|
||||
}: {
|
||||
taskId: string
|
||||
questionId: string
|
||||
response: string
|
||||
}) => {
|
||||
const { data } = await api.post(`/tasks/${taskId}/respond`, {
|
||||
questionId,
|
||||
response,
|
||||
})
|
||||
return data
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success('Respuesta enviada')
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveTask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const { data } = await api.post(`/tasks/${taskId}/approve`)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Tarea aprobada')
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// hooks/useProjects.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Project, CreateProjectInput } from '@/types/project'
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ projects: Project[] }>('/projects')
|
||||
return data.projects
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useProject(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', projectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ project: Project }>(`/projects/${projectId}`)
|
||||
return data.project
|
||||
},
|
||||
enabled: !!projectId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateProjectInput) => {
|
||||
const { data } = await api.post('/projects', input)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// hooks/useAgents.ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/api/client'
|
||||
import { Agent } from '@/types/agent'
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ agents: Agent[] }>('/agents')
|
||||
return data.agents
|
||||
},
|
||||
refetchInterval: 5000, // Refetch every 5s
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgent(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', agentId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ agent: Agent }>(`/agents/${agentId}`)
|
||||
return data.agent
|
||||
},
|
||||
enabled: !!agentId,
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentLogs(agentId: string, limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', agentId, 'logs', limit],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/agents/${agentId}/logs`, {
|
||||
params: { limit },
|
||||
})
|
||||
return data.logs
|
||||
},
|
||||
enabled: !!agentId,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Zustand para Client State
|
||||
|
||||
```typescript
|
||||
// store/authStore.ts
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
login: (token: string, user: User) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (token, user) => {
|
||||
set({ token, user, isAuthenticated: true })
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, token: null, isAuthenticated: false })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// store/uiStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean
|
||||
activeModal: string | null
|
||||
toggleSidebar: () => void
|
||||
openModal: (modalId: string) => void
|
||||
closeModal: () => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
activeModal: null,
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
openModal: (modalId) => set({ activeModal: modalId }),
|
||||
|
||||
closeModal: () => set({ activeModal: null }),
|
||||
}))
|
||||
```
|
||||
|
||||
```typescript
|
||||
// store/terminalStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface TerminalTab {
|
||||
id: string
|
||||
agentId: string
|
||||
podName: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface TerminalState {
|
||||
tabs: TerminalTab[]
|
||||
activeTabId: string | null
|
||||
openTerminal: (agentId: string, podName: string) => void
|
||||
closeTerminal: (tabId: string) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
|
||||
openTerminal: (agentId, podName) =>
|
||||
set((state) => {
|
||||
const existingTab = state.tabs.find((t) => t.agentId === agentId)
|
||||
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabs: state.tabs.map((t) => ({
|
||||
...t,
|
||||
isActive: t.id === existingTab.id,
|
||||
})),
|
||||
activeTabId: existingTab.id,
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: TerminalTab = {
|
||||
id: `term-${Date.now()}`,
|
||||
agentId,
|
||||
podName,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: [
|
||||
...state.tabs.map((t) => ({ ...t, isActive: false })),
|
||||
newTab,
|
||||
],
|
||||
activeTabId: newTab.id,
|
||||
}
|
||||
}),
|
||||
|
||||
closeTerminal: (tabId) =>
|
||||
set((state) => {
|
||||
const newTabs = state.tabs.filter((t) => t.id !== tabId)
|
||||
const newActiveTab = newTabs.length > 0 ? newTabs[0].id : null
|
||||
|
||||
return {
|
||||
tabs: newTabs.map((t, i) => ({
|
||||
...t,
|
||||
isActive: i === 0,
|
||||
})),
|
||||
activeTabId: newActiveTab,
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveTab: (tabId) =>
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((t) => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
})),
|
||||
activeTabId: tabId,
|
||||
})),
|
||||
}))
|
||||
```
|
||||
|
||||
## WebSocket Hook
|
||||
|
||||
```typescript
|
||||
// hooks/useWebSocket.ts
|
||||
import { useEffect } from 'use'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export function useWebSocket() {
|
||||
const queryClient = useQueryClient()
|
||||
const token = useAuthStore((state) => state.token)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
// Initialize socket
|
||||
socket = io(import.meta.env.VITE_WS_URL || 'http://localhost:3000', {
|
||||
auth: { token },
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('WebSocket disconnected')
|
||||
})
|
||||
|
||||
// Task events
|
||||
socket.on('task:created', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
toast.success(`Nueva tarea: ${data.title}`)
|
||||
})
|
||||
|
||||
socket.on('task:status_changed', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
||||
|
||||
if (data.newState === 'ready_to_test') {
|
||||
toast.success('Tarea lista para probar!', {
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('task:needs_input', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
||||
toast((t) => (
|
||||
<div>
|
||||
<p className="font-medium">El agente necesita información</p>
|
||||
<p className="text-sm text-gray-600 mt-1">{data.question}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Navigate to task
|
||||
window.location.href = `/tasks/${data.taskId}`
|
||||
toast.dismiss(t.id)
|
||||
}}
|
||||
className="mt-2 text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Ver tarea →
|
||||
</button>
|
||||
</div>
|
||||
), {
|
||||
duration: 10000,
|
||||
icon: '❓',
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('task:pr_created', (data) => {
|
||||
toast.success('Pull Request creado!', {
|
||||
action: {
|
||||
label: 'Ver PR',
|
||||
onClick: () => window.open(data.prUrl, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('task:ready_to_test', (data) => {
|
||||
toast.success('Preview deploy completado!', {
|
||||
action: {
|
||||
label: 'Ver Preview',
|
||||
onClick: () => window.open(data.previewUrl, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Agent events
|
||||
socket.on('agent:status', (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
})
|
||||
|
||||
// Deploy events
|
||||
socket.on('deploy:started', (data) => {
|
||||
toast.loading(`Desplegando a ${data.environment}...`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('deploy:completed', (data) => {
|
||||
toast.success(`Deploy completado: ${data.environment}`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
action: {
|
||||
label: 'Abrir',
|
||||
onClick: () => window.open(data.url, '_blank'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('deploy:failed', (data) => {
|
||||
toast.error(`Deploy falló: ${data.environment}`, {
|
||||
id: `deploy-${data.deploymentId}`,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
}, [token, queryClient])
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
// Export for manual usage
|
||||
export function getSocket() {
|
||||
return socket
|
||||
}
|
||||
```
|
||||
|
||||
## API Client
|
||||
|
||||
```typescript
|
||||
// api/client.ts
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
```
|
||||
420
docs/03-frontend/estructura.md
Normal file
420
docs/03-frontend/estructura.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Estructura del Frontend
|
||||
|
||||
## Árbol de Directorios
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/
|
||||
│ └── favicon.ico
|
||||
│
|
||||
├── src/
|
||||
│ ├── main.tsx # Entry point
|
||||
│ ├── App.tsx # App root
|
||||
│ │
|
||||
│ ├── pages/
|
||||
│ │ ├── Dashboard.tsx # Main dashboard
|
||||
│ │ ├── ProjectView.tsx # Single project view
|
||||
│ │ ├── TaskDetail.tsx # Task details modal
|
||||
│ │ └── AgentsView.tsx # Agents monitoring
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── kanban/
|
||||
│ │ │ ├── KanbanBoard.tsx
|
||||
│ │ │ ├── KanbanColumn.tsx
|
||||
│ │ │ ├── TaskCard.tsx
|
||||
│ │ │ └── TaskCardActions.tsx
|
||||
│ │ │
|
||||
│ │ ├── terminal/
|
||||
│ │ │ ├── WebTerminal.tsx
|
||||
│ │ │ └── TerminalTab.tsx
|
||||
│ │ │
|
||||
│ │ ├── projects/
|
||||
│ │ │ ├── ProjectCard.tsx
|
||||
│ │ │ ├── ProjectForm.tsx
|
||||
│ │ │ └── ProjectSettings.tsx
|
||||
│ │ │
|
||||
│ │ ├── tasks/
|
||||
│ │ │ ├── TaskForm.tsx
|
||||
│ │ │ ├── TaskQuestion.tsx
|
||||
│ │ │ └── TaskTimeline.tsx
|
||||
│ │ │
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── AgentCard.tsx
|
||||
│ │ │ ├── AgentStatus.tsx
|
||||
│ │ │ └── AgentLogs.tsx
|
||||
│ │ │
|
||||
│ │ ├── deployments/
|
||||
│ │ │ ├── DeploymentList.tsx
|
||||
│ │ │ ├── DeploymentCard.tsx
|
||||
│ │ │ └── DeployButton.tsx
|
||||
│ │ │
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── Button.tsx
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── Card.tsx
|
||||
│ │ │ ├── Badge.tsx
|
||||
│ │ │ ├── Input.tsx
|
||||
│ │ │ ├── Select.tsx
|
||||
│ │ │ └── Spinner.tsx
|
||||
│ │ │
|
||||
│ │ └── layout/
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ └── Navigation.tsx
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useProjects.ts
|
||||
│ │ ├── useTasks.ts
|
||||
│ │ ├── useAgents.ts
|
||||
│ │ ├── useWebSocket.ts
|
||||
│ │ ├── useTaskActions.ts
|
||||
│ │ └── useDeployments.ts
|
||||
│ │
|
||||
│ ├── store/
|
||||
│ │ ├── authStore.ts
|
||||
│ │ ├── uiStore.ts
|
||||
│ │ └── terminalStore.ts
|
||||
│ │
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # Axios instance
|
||||
│ │ ├── projects.ts
|
||||
│ │ ├── tasks.ts
|
||||
│ │ ├── agents.ts
|
||||
│ │ ├── deployments.ts
|
||||
│ │ └── websocket.ts
|
||||
│ │
|
||||
│ ├── types/
|
||||
│ │ ├── project.ts
|
||||
│ │ ├── task.ts
|
||||
│ │ ├── agent.ts
|
||||
│ │ ├── deployment.ts
|
||||
│ │ └── common.ts
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── format.ts
|
||||
│ │ ├── validation.ts
|
||||
│ │ └── constants.ts
|
||||
│ │
|
||||
│ └── styles/
|
||||
│ └── index.css # Tailwind imports
|
||||
│
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Setup Inicial
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "aiworker-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx",
|
||||
"format": "prettier --write src/**/*.{ts,tsx}"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"@tanstack/react-query": "^6.3.0",
|
||||
"zustand": "^5.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"axios": "^1.7.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^9.1.0",
|
||||
"xterm": "^5.5.0",
|
||||
"xterm-addon-fit": "^0.10.0",
|
||||
"xterm-addon-web-links": "^0.11.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"recharts": "^2.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.4.49",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### tailwind.config.js
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
},
|
||||
warning: {
|
||||
50: '#fefce8',
|
||||
500: '#eab308',
|
||||
600: '#ca8a04',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### main.tsx
|
||||
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
```
|
||||
|
||||
### App.tsx
|
||||
|
||||
```typescript
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/layout/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ProjectView from './pages/ProjectView'
|
||||
import AgentsView from './pages/AgentsView'
|
||||
import { WebSocketProvider } from './api/websocket'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WebSocketProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectView />} />
|
||||
<Route path="/agents" element={<AgentsView />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</WebSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
### styles/index.css
|
||||
|
||||
```css
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-200 text-gray-700 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
bun run dev
|
||||
|
||||
# Build
|
||||
bun run build
|
||||
|
||||
# Preview build
|
||||
bun run preview
|
||||
|
||||
# Lint
|
||||
bun run lint
|
||||
|
||||
# Format
|
||||
bun run format
|
||||
```
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
```bash
|
||||
# .env
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_WS_URL=ws://localhost:3000
|
||||
```
|
||||
|
||||
## Estructura de Componentes
|
||||
|
||||
Los componentes siguen esta estructura:
|
||||
|
||||
```typescript
|
||||
// Imports
|
||||
import { useState } from 'react'
|
||||
import { SomeIcon } from 'lucide-react'
|
||||
|
||||
// Types
|
||||
interface ComponentProps {
|
||||
prop1: string
|
||||
prop2?: number
|
||||
}
|
||||
|
||||
// Component
|
||||
export function Component({ prop1, prop2 = 0 }: ComponentProps) {
|
||||
// State
|
||||
const [state, setState] = useState<string>('')
|
||||
|
||||
// Handlers
|
||||
const handleAction = () => {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Render
|
||||
return (
|
||||
<div className="component">
|
||||
{/* JSX */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
444
docs/03-frontend/kanban.md
Normal file
444
docs/03-frontend/kanban.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Kanban Board - Implementación Detallada
|
||||
|
||||
## Drag & Drop con dnd-kit
|
||||
|
||||
### Configuración del DndContext
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanBoard.tsx
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function KanbanBoard({ projectId }: KanbanBoardProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const { data: tasks = [] } = useTasks({ projectId })
|
||||
const updateTask = useUpdateTask()
|
||||
|
||||
// Configure sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Require 8px movement before dragging starts
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
setActiveId(null)
|
||||
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const taskId = active.id as string
|
||||
const newState = over.id as TaskState
|
||||
|
||||
// Optimistic update
|
||||
updateTask.mutate({
|
||||
taskId,
|
||||
updates: { state: newState },
|
||||
})
|
||||
}
|
||||
|
||||
const activeTask = tasks.find((t) => t.id === activeId)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
tasks={tasksByState[column.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay for better UX */}
|
||||
<DragOverlay>
|
||||
{activeTask ? <TaskCard task={activeTask} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Column como Droppable
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanColumn.tsx
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
|
||||
export default function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className={`bg-${color}-100 border-${color}-300 border-t-4 rounded-t-lg p-3`}>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{title}
|
||||
<span className="ml-2 text-sm text-gray-500">({tasks.length})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`
|
||||
flex-1 bg-gray-50 border border-t-0 border-gray-200 rounded-b-lg p-3 min-h-[200px]
|
||||
${isOver ? 'bg-blue-50 border-blue-300' : ''}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
<SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-center text-gray-400 text-sm py-8">
|
||||
{isOver ? 'Suelta aquí' : 'Sin tareas'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Task Card como Draggable
|
||||
|
||||
```typescript
|
||||
// components/kanban/TaskCard.tsx
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
export default function TaskCard({ task }: TaskCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
},
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'move',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="card hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Task content */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Acciones Rápidas
|
||||
|
||||
```typescript
|
||||
// components/kanban/TaskCardActions.tsx
|
||||
import { MoreVertical, ExternalLink, MessageSquare, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { Task } from '@/types/task'
|
||||
import { useApproveTask, useRejectTask } from '@/hooks/useTasks'
|
||||
|
||||
interface TaskCardActionsProps {
|
||||
task: Task
|
||||
}
|
||||
|
||||
export function TaskCardActions({ task }: TaskCardActionsProps) {
|
||||
const approveTask = useApproveTask()
|
||||
const rejectTask = useRejectTask()
|
||||
|
||||
const handleApprove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('¿Aprobar esta tarea?')) {
|
||||
approveTask.mutate(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const reason = prompt('Razón del rechazo:')
|
||||
if (reason) {
|
||||
rejectTask.mutate({ taskId: task.id, reason })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Preview link */}
|
||||
{task.previewUrl && (
|
||||
<a
|
||||
href={task.previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Abrir preview"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-gray-600" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Questions */}
|
||||
{task.state === 'needs_input' && (
|
||||
<button
|
||||
className="p-1 hover:bg-yellow-100 rounded"
|
||||
title="Responder pregunta"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 text-yellow-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject for ready_to_test */}
|
||||
{task.state === 'ready_to_test' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
className="p-1 hover:bg-green-100 rounded"
|
||||
title="Aprobar"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="p-1 hover:bg-red-100 rounded"
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* More actions */}
|
||||
<button className="p-1 hover:bg-gray-100 rounded">
|
||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Filtros y Búsqueda
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanFilters.tsx
|
||||
import { useState } from 'react'
|
||||
import { Search, Filter } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
|
||||
interface KanbanFiltersProps {
|
||||
onFilterChange: (filters: TaskFilters) => void
|
||||
}
|
||||
|
||||
export function KanbanFilters({ onFilterChange }: KanbanFiltersProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [priority, setPriority] = useState<string>('all')
|
||||
const [assignedAgent, setAssignedAgent] = useState<string>('all')
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value)
|
||||
onFilterChange({ search: value, priority, assignedAgent })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Buscar tareas..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={priority}
|
||||
onChange={(e) => {
|
||||
setPriority(e.target.value)
|
||||
onFilterChange({ search, priority: e.target.value, assignedAgent })
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: 'Todas las prioridades' },
|
||||
{ value: 'urgent', label: 'Urgente' },
|
||||
{ value: 'high', label: 'Alta' },
|
||||
{ value: 'medium', label: 'Media' },
|
||||
{ value: 'low', label: 'Baja' },
|
||||
]}
|
||||
className="w-48"
|
||||
/>
|
||||
|
||||
<button className="btn-secondary">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Más filtros
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Bulk Actions
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanBulkActions.tsx
|
||||
import { useState } from 'react'
|
||||
import { CheckSquare, GitMerge, Trash2 } from 'lucide-react'
|
||||
import { Task } from '@/types/task'
|
||||
|
||||
interface KanbanBulkActionsProps {
|
||||
selectedTasks: Task[]
|
||||
onMergeToStaging: (taskIds: string[]) => void
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
export function KanbanBulkActions({
|
||||
selectedTasks,
|
||||
onMergeToStaging,
|
||||
onClearSelection,
|
||||
}: KanbanBulkActionsProps) {
|
||||
if (selectedTasks.length === 0) return null
|
||||
|
||||
const approvedTasks = selectedTasks.filter((t) => t.state === 'approved')
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-white rounded-lg shadow-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="w-5 h-5 text-primary-600" />
|
||||
<span className="font-medium">
|
||||
{selectedTasks.length} tarea{selectedTasks.length !== 1 ? 's' : ''} seleccionada{selectedTasks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
{approvedTasks.length >= 2 && (
|
||||
<button
|
||||
onClick={() => onMergeToStaging(approvedTasks.map((t) => t.id))}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<GitMerge className="w-4 h-4" />
|
||||
Merge a Staging ({approvedTasks.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={onClearSelection} className="btn-secondary">
|
||||
Limpiar selección
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Estadísticas del Kanban
|
||||
|
||||
```typescript
|
||||
// components/kanban/KanbanStats.tsx
|
||||
import { Task } from '@/types/task'
|
||||
import { Activity, CheckCircle, Clock, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface KanbanStatsProps {
|
||||
tasks: Task[]
|
||||
}
|
||||
|
||||
export function KanbanStats({ tasks }: KanbanStatsProps) {
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.state === 'in_progress').length,
|
||||
completed: tasks.filter((t) => t.state === 'production').length,
|
||||
needsInput: tasks.filter((t) => t.state === 'needs_input').length,
|
||||
avgDuration: tasks
|
||||
.filter((t) => t.actualDurationMinutes)
|
||||
.reduce((acc, t) => acc + (t.actualDurationMinutes || 0), 0) / tasks.length || 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">En Progreso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.inProgress}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Completadas</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Necesitan Input</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{stats.needsInput}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
456
docs/04-kubernetes/cluster-setup.md
Normal file
456
docs/04-kubernetes/cluster-setup.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# Setup del Cluster Kubernetes
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Kubernetes 1.28+
|
||||
- kubectl CLI
|
||||
- helm 3.x
|
||||
- 4 GB RAM mínimo
|
||||
- 20 GB storage
|
||||
|
||||
## Instalación Local (Kind/Minikube)
|
||||
|
||||
### Con Kind (recomendado para desarrollo)
|
||||
|
||||
```bash
|
||||
# Instalar kind
|
||||
brew install kind # macOS
|
||||
# o
|
||||
curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
|
||||
chmod +x ./kind
|
||||
sudo mv ./kind /usr/local/bin/kind
|
||||
|
||||
# Crear cluster con configuración personalizada
|
||||
cat <<EOF | kind create cluster --name aiworker --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
nodes:
|
||||
- role: control-plane
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
- role: worker
|
||||
EOF
|
||||
|
||||
# Verificar
|
||||
kubectl cluster-info --context kind-aiworker
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
### Con Minikube
|
||||
|
||||
```bash
|
||||
# Instalar minikube
|
||||
brew install minikube # macOS
|
||||
|
||||
# Iniciar cluster
|
||||
minikube start --cpus=4 --memory=8192 --disk-size=40g --driver=docker
|
||||
|
||||
# Habilitar addons
|
||||
minikube addons enable ingress
|
||||
minikube addons enable metrics-server
|
||||
minikube addons enable storage-provisioner
|
||||
|
||||
# Verificar
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
## Instalación en Cloud
|
||||
|
||||
### Google Kubernetes Engine (GKE)
|
||||
|
||||
```bash
|
||||
# Instalar gcloud CLI
|
||||
brew install --cask google-cloud-sdk
|
||||
|
||||
# Autenticar
|
||||
gcloud auth login
|
||||
gcloud config set project YOUR_PROJECT_ID
|
||||
|
||||
# Crear cluster
|
||||
gcloud container clusters create aiworker \
|
||||
--zone us-central1-a \
|
||||
--num-nodes 3 \
|
||||
--machine-type n1-standard-2 \
|
||||
--disk-size 30 \
|
||||
--enable-autoscaling \
|
||||
--min-nodes 2 \
|
||||
--max-nodes 5 \
|
||||
--enable-autorepair \
|
||||
--enable-autoupgrade
|
||||
|
||||
# Obtener credenciales
|
||||
gcloud container clusters get-credentials aiworker --zone us-central1-a
|
||||
|
||||
# Verificar
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
### Amazon EKS
|
||||
|
||||
```bash
|
||||
# Instalar eksctl
|
||||
brew install eksctl
|
||||
|
||||
# Crear cluster
|
||||
eksctl create cluster \
|
||||
--name aiworker \
|
||||
--region us-west-2 \
|
||||
--nodegroup-name workers \
|
||||
--node-type t3.medium \
|
||||
--nodes 3 \
|
||||
--nodes-min 2 \
|
||||
--nodes-max 5 \
|
||||
--managed
|
||||
|
||||
# Verificar
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
### Azure AKS
|
||||
|
||||
```bash
|
||||
# Instalar Azure CLI
|
||||
brew install azure-cli
|
||||
|
||||
# Login
|
||||
az login
|
||||
|
||||
# Crear resource group
|
||||
az group create --name aiworker-rg --location eastus
|
||||
|
||||
# Crear cluster
|
||||
az aks create \
|
||||
--resource-group aiworker-rg \
|
||||
--name aiworker \
|
||||
--node-count 3 \
|
||||
--node-vm-size Standard_D2s_v3 \
|
||||
--enable-cluster-autoscaler \
|
||||
--min-count 2 \
|
||||
--max-count 5 \
|
||||
--generate-ssh-keys
|
||||
|
||||
# Obtener credenciales
|
||||
az aks get-credentials --resource-group aiworker-rg --name aiworker
|
||||
|
||||
# Verificar
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
## Instalación de Componentes Base
|
||||
|
||||
### Nginx Ingress Controller
|
||||
|
||||
```bash
|
||||
# Instalar con Helm
|
||||
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
||||
helm repo update
|
||||
|
||||
helm install ingress-nginx ingress-nginx/ingress-nginx \
|
||||
--namespace ingress-nginx \
|
||||
--create-namespace \
|
||||
--set controller.replicaCount=2 \
|
||||
--set controller.nodeSelector."kubernetes\.io/os"=linux \
|
||||
--set controller.admissionWebhooks.patch.nodeSelector."kubernetes\.io/os"=linux
|
||||
|
||||
# Verificar
|
||||
kubectl get pods -n ingress-nginx
|
||||
kubectl get svc -n ingress-nginx
|
||||
```
|
||||
|
||||
### Cert-Manager (TLS)
|
||||
|
||||
```bash
|
||||
# Instalar cert-manager
|
||||
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
|
||||
|
||||
# Verificar
|
||||
kubectl get pods -n cert-manager
|
||||
|
||||
# Crear ClusterIssuer para Let's Encrypt
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: your-email@example.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Metrics Server
|
||||
|
||||
```bash
|
||||
# Instalar metrics-server
|
||||
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
|
||||
|
||||
# Verificar
|
||||
kubectl get deployment metrics-server -n kube-system
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
### Prometheus & Grafana (opcional)
|
||||
|
||||
```bash
|
||||
# Añadir repo
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm repo update
|
||||
|
||||
# Instalar kube-prometheus-stack
|
||||
helm install prometheus prometheus-community/kube-prometheus-stack \
|
||||
--namespace monitoring \
|
||||
--create-namespace \
|
||||
--set prometheus.prometheusSpec.retention=30d \
|
||||
--set grafana.adminPassword=admin
|
||||
|
||||
# Verificar
|
||||
kubectl get pods -n monitoring
|
||||
|
||||
# Port-forward para acceder a Grafana
|
||||
kubectl port-forward -n monitoring svc/prometheus-grafana 3001:80
|
||||
# http://localhost:3001 (admin/admin)
|
||||
```
|
||||
|
||||
## Creación de Namespaces
|
||||
|
||||
```bash
|
||||
# Script de creación de namespaces
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: control-plane
|
||||
labels:
|
||||
name: control-plane
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: agents
|
||||
labels:
|
||||
name: agents
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
labels:
|
||||
name: gitea
|
||||
environment: production
|
||||
EOF
|
||||
|
||||
# Verificar
|
||||
kubectl get namespaces
|
||||
```
|
||||
|
||||
## Configuración de RBAC
|
||||
|
||||
```bash
|
||||
# ServiceAccount para backend
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "pods/exec"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets"]
|
||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
verbs: ["get", "list", "create", "update", "delete"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses"]
|
||||
verbs: ["get", "list", "create", "update", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: aiworker-backend
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
EOF
|
||||
```
|
||||
|
||||
## Secrets y ConfigMaps
|
||||
|
||||
```bash
|
||||
# Crear secret para credentials
|
||||
kubectl create secret generic aiworker-secrets \
|
||||
--namespace=control-plane \
|
||||
--from-literal=db-password='your-db-password' \
|
||||
--from-literal=gitea-token='your-gitea-token' \
|
||||
--from-literal=anthropic-api-key='your-anthropic-key'
|
||||
|
||||
# ConfigMap para configuración
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: aiworker-config
|
||||
namespace: control-plane
|
||||
data:
|
||||
GITEA_URL: "http://gitea.gitea.svc.cluster.local:3000"
|
||||
K8S_DEFAULT_NAMESPACE: "aiworker"
|
||||
NODE_ENV: "production"
|
||||
EOF
|
||||
```
|
||||
|
||||
## Storage Classes
|
||||
|
||||
```bash
|
||||
# Crear StorageClass para preview environments (fast SSD)
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: fast-ssd
|
||||
provisioner: kubernetes.io/gce-pd # Cambiar según cloud provider
|
||||
parameters:
|
||||
type: pd-ssd
|
||||
replication-type: none
|
||||
reclaimPolicy: Delete
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
EOF
|
||||
```
|
||||
|
||||
## Network Policies
|
||||
|
||||
```bash
|
||||
# Aislar namespaces de preview
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: preview-isolation
|
||||
namespace: agents
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
env: preview
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: control-plane
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: gitea
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
EOF
|
||||
```
|
||||
|
||||
## Verificación Final
|
||||
|
||||
```bash
|
||||
# Script de verificación
|
||||
cat > verify-cluster.sh <<'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔍 Verificando cluster..."
|
||||
|
||||
echo "✓ Nodes:"
|
||||
kubectl get nodes
|
||||
|
||||
echo "✓ Namespaces:"
|
||||
kubectl get namespaces
|
||||
|
||||
echo "✓ Ingress Controller:"
|
||||
kubectl get pods -n ingress-nginx
|
||||
|
||||
echo "✓ Cert-Manager:"
|
||||
kubectl get pods -n cert-manager
|
||||
|
||||
echo "✓ Metrics Server:"
|
||||
kubectl top nodes 2>/dev/null || echo "⚠️ Metrics not available yet"
|
||||
|
||||
echo "✓ Storage Classes:"
|
||||
kubectl get storageclass
|
||||
|
||||
echo "✅ Cluster setup complete!"
|
||||
EOF
|
||||
|
||||
chmod +x verify-cluster.sh
|
||||
./verify-cluster.sh
|
||||
```
|
||||
|
||||
## Mantenimiento
|
||||
|
||||
```bash
|
||||
# Actualizar componentes
|
||||
helm repo update
|
||||
helm upgrade ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx
|
||||
|
||||
# Limpiar recursos viejos
|
||||
kubectl delete pods --field-selector=status.phase=Failed -A
|
||||
kubectl delete pods --field-selector=status.phase=Succeeded -A
|
||||
|
||||
# Backup de configuración
|
||||
kubectl get all --all-namespaces -o yaml > cluster-backup.yaml
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Ver logs de componentes
|
||||
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller
|
||||
kubectl logs -n cert-manager deployment/cert-manager
|
||||
|
||||
# Describir recursos con problemas
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
|
||||
# Eventos del cluster
|
||||
kubectl get events --all-namespaces --sort-by='.lastTimestamp'
|
||||
|
||||
# Recursos consumidos
|
||||
kubectl top nodes
|
||||
kubectl top pods -A
|
||||
```
|
||||
706
docs/04-kubernetes/deployments.md
Normal file
706
docs/04-kubernetes/deployments.md
Normal file
@@ -0,0 +1,706 @@
|
||||
# Deployments en Kubernetes
|
||||
|
||||
## Backend API Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/control-plane/backend-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
labels:
|
||||
app: aiworker-backend
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: aiworker-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: aiworker-backend
|
||||
version: v1
|
||||
spec:
|
||||
serviceAccountName: aiworker-backend
|
||||
containers:
|
||||
- name: backend
|
||||
image: aiworker/backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
- name: mcp
|
||||
containerPort: 3100
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: DB_HOST
|
||||
value: "mysql.control-plane.svc.cluster.local"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_NAME
|
||||
value: "aiworker"
|
||||
- name: DB_USER
|
||||
value: "root"
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: db-password
|
||||
- name: REDIS_HOST
|
||||
value: "redis.control-plane.svc.cluster.local"
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
- name: GITEA_URL
|
||||
value: "http://gitea.gitea.svc.cluster.local:3000"
|
||||
- name: GITEA_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: gitea-token
|
||||
- name: K8S_IN_CLUSTER
|
||||
value: "true"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
spec:
|
||||
selector:
|
||||
app: aiworker-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: mcp
|
||||
port: 3100
|
||||
targetPort: 3100
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/websocket-services: "aiworker-backend"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- api.aiworker.dev
|
||||
secretName: aiworker-backend-tls
|
||||
rules:
|
||||
- host: api.aiworker.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: aiworker-backend
|
||||
port:
|
||||
number: 3000
|
||||
```
|
||||
|
||||
## MySQL Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/control-plane/mysql-deployment.yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: mysql-pvc
|
||||
namespace: control-plane
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mysql
|
||||
namespace: control-plane
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mysql
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mysql
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
name: mysql
|
||||
env:
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: db-password
|
||||
- name: MYSQL_DATABASE
|
||||
value: "aiworker"
|
||||
volumeMounts:
|
||||
- name: mysql-storage
|
||||
mountPath: /var/lib/mysql
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- mysqladmin
|
||||
- ping
|
||||
- -h
|
||||
- localhost
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: mysql-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: mysql-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mysql
|
||||
namespace: control-plane
|
||||
spec:
|
||||
selector:
|
||||
app: mysql
|
||||
ports:
|
||||
- port: 3306
|
||||
targetPort: 3306
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
## Redis Deployment
|
||||
|
||||
```yaml
|
||||
# k8s/control-plane/redis-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: control-plane
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
args:
|
||||
- --maxmemory
|
||||
- 2gb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: "2Gi"
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 6379
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: control-plane
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
## Claude Code Agent Pod Template
|
||||
|
||||
```yaml
|
||||
# k8s/agents/agent-pod-template.yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: claude-agent-{agent-id}
|
||||
namespace: agents
|
||||
labels:
|
||||
app: claude-agent
|
||||
agent-id: "{agent-id}"
|
||||
managed-by: aiworker
|
||||
spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: aiworker/claude-agent:latest
|
||||
env:
|
||||
- name: AGENT_ID
|
||||
value: "{agent-id}"
|
||||
- name: MCP_SERVER_URL
|
||||
value: "http://aiworker-backend.control-plane.svc.cluster.local:3100"
|
||||
- name: ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: anthropic-api-key
|
||||
- name: GITEA_URL
|
||||
value: "http://gitea.gitea.svc.cluster.local:3000"
|
||||
- name: GIT_SSH_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: git-ssh-keys
|
||||
key: private-key
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
- name: git-config
|
||||
mountPath: /root/.gitconfig
|
||||
subPath: .gitconfig
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir: {}
|
||||
- name: git-config
|
||||
configMap:
|
||||
name: git-config
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## Preview Deployment Template
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/templates/preview-deployment.ts
|
||||
export function generatePreviewDeployment(params: {
|
||||
taskId: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
image: string
|
||||
branch: string
|
||||
envVars: Record<string, string>
|
||||
}) {
|
||||
const namespace = `preview-task-${params.taskId.slice(0, 8)}`
|
||||
const name = `${params.projectName}-preview`
|
||||
|
||||
return {
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name,
|
||||
namespace,
|
||||
labels: {
|
||||
app: name,
|
||||
project: params.projectId,
|
||||
task: params.taskId,
|
||||
environment: 'preview',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: name,
|
||||
project: params.projectId,
|
||||
task: params.taskId,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'app',
|
||||
image: `${params.image}:${params.branch}`,
|
||||
ports: [
|
||||
{
|
||||
name: 'http',
|
||||
containerPort: 3000,
|
||||
},
|
||||
],
|
||||
env: Object.entries(params.envVars).map(([key, value]) => ({
|
||||
name: key,
|
||||
value,
|
||||
})),
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: '250m',
|
||||
memory: '512Mi',
|
||||
},
|
||||
limits: {
|
||||
cpu: '1',
|
||||
memory: '2Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePreviewService(params: {
|
||||
taskId: string
|
||||
projectName: string
|
||||
}) {
|
||||
const namespace = `preview-task-${params.taskId.slice(0, 8)}`
|
||||
const name = `${params.projectName}-preview`
|
||||
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: {
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
app: name,
|
||||
},
|
||||
ports: [
|
||||
{
|
||||
port: 80,
|
||||
targetPort: 3000,
|
||||
},
|
||||
],
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePreviewIngress(params: {
|
||||
taskId: string
|
||||
projectName: string
|
||||
}) {
|
||||
const namespace = `preview-task-${params.taskId.slice(0, 8)}`
|
||||
const name = `${params.projectName}-preview`
|
||||
const host = `task-${params.taskId.slice(0, 8)}.preview.aiworker.dev`
|
||||
|
||||
return {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
metadata: {
|
||||
name,
|
||||
namespace,
|
||||
annotations: {
|
||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
ingressClassName: 'nginx',
|
||||
tls: [
|
||||
{
|
||||
hosts: [host],
|
||||
secretName: `${name}-tls`,
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
host,
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: '/',
|
||||
pathType: 'Prefix',
|
||||
backend: {
|
||||
service: {
|
||||
name,
|
||||
port: {
|
||||
number: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kubernetes Client Implementation
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/client.ts
|
||||
import { KubeConfig, AppsV1Api, CoreV1Api, NetworkingV1Api } from '@kubernetes/client-node'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class K8sClient {
|
||||
private kc: KubeConfig
|
||||
private appsApi: AppsV1Api
|
||||
private coreApi: CoreV1Api
|
||||
private networkingApi: NetworkingV1Api
|
||||
|
||||
constructor() {
|
||||
this.kc = new KubeConfig()
|
||||
|
||||
if (process.env.K8S_IN_CLUSTER === 'true') {
|
||||
this.kc.loadFromCluster()
|
||||
} else {
|
||||
this.kc.loadFromDefault()
|
||||
}
|
||||
|
||||
this.appsApi = this.kc.makeApiClient(AppsV1Api)
|
||||
this.coreApi = this.kc.makeApiClient(CoreV1Api)
|
||||
this.networkingApi = this.kc.makeApiClient(NetworkingV1Api)
|
||||
}
|
||||
|
||||
async createPreviewDeployment(params: {
|
||||
namespace: string
|
||||
taskId: string
|
||||
projectId: string
|
||||
image: string
|
||||
branch: string
|
||||
envVars: Record<string, string>
|
||||
}) {
|
||||
const { namespace, taskId, projectId } = params
|
||||
|
||||
// Create namespace
|
||||
await this.createNamespace(namespace, {
|
||||
project: projectId,
|
||||
environment: 'preview',
|
||||
taskId,
|
||||
})
|
||||
|
||||
// Create deployment
|
||||
const deployment = generatePreviewDeployment(params)
|
||||
await this.appsApi.createNamespacedDeployment(namespace, deployment)
|
||||
|
||||
// Create service
|
||||
const service = generatePreviewService(params)
|
||||
await this.coreApi.createNamespacedService(namespace, service)
|
||||
|
||||
// Create ingress
|
||||
const ingress = generatePreviewIngress(params)
|
||||
await this.networkingApi.createNamespacedIngress(namespace, ingress)
|
||||
|
||||
logger.info(`Created preview deployment for task ${taskId}`)
|
||||
|
||||
return {
|
||||
namespace,
|
||||
url: ingress.spec.rules[0].host,
|
||||
}
|
||||
}
|
||||
|
||||
async deletePreviewDeployment(namespace: string) {
|
||||
await this.deleteNamespace(namespace)
|
||||
logger.info(`Deleted preview deployment namespace: ${namespace}`)
|
||||
}
|
||||
|
||||
async createNamespace(name: string, labels: Record<string, string> = {}) {
|
||||
try {
|
||||
await this.coreApi.createNamespace({
|
||||
metadata: {
|
||||
name,
|
||||
labels: {
|
||||
'managed-by': 'aiworker',
|
||||
...labels,
|
||||
},
|
||||
},
|
||||
})
|
||||
logger.info(`Created namespace: ${name}`)
|
||||
} catch (error: any) {
|
||||
if (error.statusCode !== 409) { // Ignore if already exists
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNamespace(name: string) {
|
||||
await this.coreApi.deleteNamespace(name)
|
||||
}
|
||||
|
||||
async createAgentPod(agentId: string) {
|
||||
const podSpec = {
|
||||
metadata: {
|
||||
name: `claude-agent-${agentId.slice(0, 8)}`,
|
||||
namespace: 'agents',
|
||||
labels: {
|
||||
app: 'claude-agent',
|
||||
'agent-id': agentId,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'agent',
|
||||
image: 'aiworker/claude-agent:latest',
|
||||
env: [
|
||||
{ name: 'AGENT_ID', value: agentId },
|
||||
{
|
||||
name: 'MCP_SERVER_URL',
|
||||
value: 'http://aiworker-backend.control-plane.svc.cluster.local:3100',
|
||||
},
|
||||
{
|
||||
name: 'ANTHROPIC_API_KEY',
|
||||
valueFrom: {
|
||||
secretKeyRef: {
|
||||
name: 'aiworker-secrets',
|
||||
key: 'anthropic-api-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: {
|
||||
requests: { cpu: '500m', memory: '1Gi' },
|
||||
limits: { cpu: '2', memory: '4Gi' },
|
||||
},
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
}
|
||||
|
||||
await this.coreApi.createNamespacedPod('agents', podSpec)
|
||||
logger.info(`Created agent pod: ${agentId}`)
|
||||
|
||||
return {
|
||||
podName: podSpec.metadata.name,
|
||||
namespace: 'agents',
|
||||
}
|
||||
}
|
||||
|
||||
async deletePod(namespace: string, podName: string) {
|
||||
await this.coreApi.deleteNamespacedPod(podName, namespace)
|
||||
}
|
||||
|
||||
async getPodLogs(namespace: string, podName: string, tailLines = 100) {
|
||||
const response = await this.coreApi.readNamespacedPodLog(
|
||||
podName,
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
tailLines
|
||||
)
|
||||
return response.body
|
||||
}
|
||||
|
||||
async execInPod(params: {
|
||||
namespace: string
|
||||
podName: string
|
||||
command: string[]
|
||||
}) {
|
||||
// Implementation using WebSocketStream
|
||||
const exec = new Exec(this.kc)
|
||||
const stream = await exec.exec(
|
||||
params.namespace,
|
||||
params.podName,
|
||||
'agent',
|
||||
params.command,
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
process.stdin,
|
||||
true // tty
|
||||
)
|
||||
|
||||
return stream
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# deploy-all.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying AiWorker to Kubernetes..."
|
||||
|
||||
# Apply secrets (should be done once manually with real values)
|
||||
echo "📦 Creating secrets..."
|
||||
kubectl apply -f k8s/secrets/
|
||||
|
||||
# Deploy control-plane
|
||||
echo "🎛️ Deploying control-plane..."
|
||||
kubectl apply -f k8s/control-plane/
|
||||
|
||||
# Deploy agents namespace
|
||||
echo "🤖 Setting up agents namespace..."
|
||||
kubectl apply -f k8s/agents/
|
||||
|
||||
# Deploy Gitea
|
||||
echo "📚 Deploying Gitea..."
|
||||
kubectl apply -f k8s/gitea/
|
||||
|
||||
# Wait for pods
|
||||
echo "⏳ Waiting for pods to be ready..."
|
||||
kubectl wait --for=condition=ready pod -l app=aiworker-backend -n control-plane --timeout=300s
|
||||
kubectl wait --for=condition=ready pod -l app=mysql -n control-plane --timeout=300s
|
||||
kubectl wait --for=condition=ready pod -l app=redis -n control-plane --timeout=300s
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo "📍 Backend API: https://api.aiworker.dev"
|
||||
echo "📍 Gitea: https://git.aiworker.dev"
|
||||
```
|
||||
456
docs/04-kubernetes/gitea-deployment.md
Normal file
456
docs/04-kubernetes/gitea-deployment.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# Gitea Deployment en Kubernetes
|
||||
|
||||
## Gitea StatefulSet
|
||||
|
||||
```yaml
|
||||
# k8s/gitea/gitea-statefulset.yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: gitea-data
|
||||
namespace: gitea
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: gitea
|
||||
spec:
|
||||
serviceName: gitea
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gitea
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gitea
|
||||
spec:
|
||||
containers:
|
||||
- name: gitea
|
||||
image: gitea/gitea:1.22
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
- name: ssh
|
||||
containerPort: 22
|
||||
env:
|
||||
- name: USER_UID
|
||||
value: "1000"
|
||||
- name: USER_GID
|
||||
value: "1000"
|
||||
- name: GITEA__database__DB_TYPE
|
||||
value: "mysql"
|
||||
- name: GITEA__database__HOST
|
||||
value: "mysql.control-plane.svc.cluster.local:3306"
|
||||
- name: GITEA__database__NAME
|
||||
value: "gitea"
|
||||
- name: GITEA__database__USER
|
||||
value: "root"
|
||||
- name: GITEA__database__PASSWD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: db-password
|
||||
- name: GITEA__server__DOMAIN
|
||||
value: "git.aiworker.dev"
|
||||
- name: GITEA__server__SSH_DOMAIN
|
||||
value: "git.aiworker.dev"
|
||||
- name: GITEA__server__ROOT_URL
|
||||
value: "https://git.aiworker.dev"
|
||||
- name: GITEA__server__HTTP_PORT
|
||||
value: "3000"
|
||||
- name: GITEA__server__SSH_PORT
|
||||
value: "2222"
|
||||
- name: GITEA__security__INSTALL_LOCK
|
||||
value: "true"
|
||||
- name: GITEA__webhook__ALLOWED_HOST_LIST
|
||||
value: "*.svc.cluster.local"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: 3000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: gitea-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: gitea
|
||||
spec:
|
||||
selector:
|
||||
app: gitea
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: ssh
|
||||
port: 2222
|
||||
targetPort: 22
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-ssh
|
||||
namespace: gitea
|
||||
annotations:
|
||||
service.beta.kubernetes.io/external-traffic: OnlyLocal
|
||||
spec:
|
||||
selector:
|
||||
app: gitea
|
||||
ports:
|
||||
- name: ssh
|
||||
port: 2222
|
||||
targetPort: 22
|
||||
protocol: TCP
|
||||
type: LoadBalancer
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: gitea
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "512m"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- git.aiworker.dev
|
||||
secretName: gitea-tls
|
||||
rules:
|
||||
- host: git.aiworker.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea
|
||||
port:
|
||||
number: 3000
|
||||
```
|
||||
|
||||
## Gitea Configuration
|
||||
|
||||
```yaml
|
||||
# k8s/gitea/gitea-config.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gitea-config
|
||||
namespace: gitea
|
||||
data:
|
||||
app.ini: |
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
DOMAIN = git.aiworker.dev
|
||||
ROOT_URL = https://git.aiworker.dev
|
||||
HTTP_PORT = 3000
|
||||
SSH_PORT = 2222
|
||||
DISABLE_SSH = false
|
||||
START_SSH_SERVER = true
|
||||
SSH_LISTEN_HOST = 0.0.0.0
|
||||
SSH_LISTEN_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
OFFLINE_MODE = false
|
||||
|
||||
[database]
|
||||
DB_TYPE = mysql
|
||||
HOST = mysql.control-plane.svc.cluster.local:3306
|
||||
NAME = gitea
|
||||
USER = root
|
||||
SSL_MODE = disable
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = your-secret-key-here
|
||||
INTERNAL_TOKEN = your-internal-token-here
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = false
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
|
||||
[webhook]
|
||||
ALLOWED_HOST_LIST = *.svc.cluster.local,*.aiworker.dev
|
||||
|
||||
[api]
|
||||
ENABLE_SWAGGER = true
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
||||
[repository]
|
||||
DEFAULT_BRANCH = main
|
||||
FORCE_PRIVATE = false
|
||||
|
||||
[ui]
|
||||
DEFAULT_THEME = arc-green
|
||||
```
|
||||
|
||||
## Inicialización de Gitea
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/init-gitea.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Initializing Gitea..."
|
||||
|
||||
# Wait for Gitea to be ready
|
||||
echo "⏳ Waiting for Gitea pod..."
|
||||
kubectl wait --for=condition=ready pod -l app=gitea -n gitea --timeout=300s
|
||||
|
||||
# Port-forward temporalmente
|
||||
echo "🔌 Port-forwarding Gitea..."
|
||||
kubectl port-forward -n gitea svc/gitea 3001:3000 &
|
||||
PF_PID=$!
|
||||
sleep 5
|
||||
|
||||
# Create admin user
|
||||
echo "👤 Creating admin user..."
|
||||
kubectl exec -n gitea gitea-0 -- gitea admin user create \
|
||||
--username aiworker \
|
||||
--password admin123 \
|
||||
--email admin@aiworker.dev \
|
||||
--admin \
|
||||
--must-change-password=false
|
||||
|
||||
# Create organization
|
||||
echo "🏢 Creating organization..."
|
||||
kubectl exec -n gitea gitea-0 -- gitea admin user create \
|
||||
--username aiworker-bot \
|
||||
--password bot123 \
|
||||
--email bot@aiworker.dev
|
||||
|
||||
# Generate access token
|
||||
echo "🔑 Generating access token..."
|
||||
TOKEN=$(kubectl exec -n gitea gitea-0 -- gitea admin user generate-access-token \
|
||||
--username aiworker-bot \
|
||||
--scopes write:repository,write:issue,write:user \
|
||||
--raw)
|
||||
|
||||
echo "✅ Gitea initialized!"
|
||||
echo "📍 URL: https://git.aiworker.dev"
|
||||
echo "👤 User: aiworker / admin123"
|
||||
echo "🔑 Bot Token: $TOKEN"
|
||||
echo ""
|
||||
echo "⚠️ Save this token and update the secret:"
|
||||
echo "kubectl create secret generic aiworker-secrets -n control-plane \\"
|
||||
echo " --from-literal=gitea-token='$TOKEN' --dry-run=client -o yaml | kubectl apply -f -"
|
||||
|
||||
# Stop port-forward
|
||||
kill $PF_PID
|
||||
```
|
||||
|
||||
## Gitea Webhook Configuration
|
||||
|
||||
```typescript
|
||||
// services/gitea/setup.ts
|
||||
import { giteaClient } from './client'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export async function setupGiteaWebhooks(owner: string, repo: string) {
|
||||
const backendUrl = process.env.BACKEND_URL || 'https://api.aiworker.dev'
|
||||
|
||||
try {
|
||||
// Create webhook for push events
|
||||
await giteaClient.createWebhook(owner, repo, {
|
||||
url: `${backendUrl}/api/webhooks/gitea`,
|
||||
contentType: 'json',
|
||||
secret: process.env.GITEA_WEBHOOK_SECRET || '',
|
||||
events: ['push', 'pull_request', 'pull_request_closed'],
|
||||
})
|
||||
|
||||
logger.info(`Webhooks configured for ${owner}/${repo}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to setup webhooks:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeGiteaForProject(projectName: string) {
|
||||
const owner = process.env.GITEA_OWNER || 'aiworker'
|
||||
|
||||
// Create repository
|
||||
const repo = await giteaClient.createRepo(projectName, {
|
||||
description: `AiWorker project: ${projectName}`,
|
||||
private: true,
|
||||
autoInit: true,
|
||||
defaultBranch: 'main',
|
||||
})
|
||||
|
||||
// Setup webhooks
|
||||
await setupGiteaWebhooks(owner, projectName)
|
||||
|
||||
// Create initial branches
|
||||
await giteaClient.createBranch(owner, projectName, 'develop', 'main')
|
||||
await giteaClient.createBranch(owner, projectName, 'staging', 'main')
|
||||
|
||||
logger.info(`Gitea initialized for project: ${projectName}`)
|
||||
|
||||
return {
|
||||
repoUrl: repo.html_url,
|
||||
cloneUrl: repo.clone_url,
|
||||
sshUrl: repo.ssh_url,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Backup de Gitea
|
||||
|
||||
```yaml
|
||||
# k8s/gitea/gitea-backup-cronjob.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: gitea-backup
|
||||
namespace: gitea
|
||||
spec:
|
||||
schedule: "0 2 * * *" # Daily at 2 AM
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backup
|
||||
image: gitea/gitea:1.22
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
echo "Starting backup..."
|
||||
gitea dump -c /data/gitea/conf/app.ini -f /backups/gitea-backup-$(date +%Y%m%d).zip
|
||||
echo "Backup complete!"
|
||||
# Upload to S3 or other storage
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: gitea-data
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: gitea-backups
|
||||
restartPolicy: OnFailure
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: gitea-backups
|
||||
namespace: gitea
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
```
|
||||
|
||||
## Monitoreo de Gitea
|
||||
|
||||
```yaml
|
||||
# k8s/gitea/gitea-servicemonitor.yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: gitea
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gitea
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Ver logs
|
||||
kubectl logs -n gitea gitea-0 --tail=100 -f
|
||||
|
||||
# Entrar al pod
|
||||
kubectl exec -it -n gitea gitea-0 -- /bin/sh
|
||||
|
||||
# Verificar config
|
||||
kubectl exec -n gitea gitea-0 -- cat /data/gitea/conf/app.ini
|
||||
|
||||
# Regenerar admin user
|
||||
kubectl exec -n gitea gitea-0 -- gitea admin user change-password \
|
||||
--username aiworker --password newpassword
|
||||
|
||||
# Limpiar cache
|
||||
kubectl exec -n gitea gitea-0 -- rm -rf /data/gitea/queues/*
|
||||
```
|
||||
|
||||
## SSH Keys Setup
|
||||
|
||||
```bash
|
||||
# Generar SSH key para agentes
|
||||
ssh-keygen -t ed25519 -C "aiworker-agent" -f agent-key -N ""
|
||||
|
||||
# Crear secret
|
||||
kubectl create secret generic git-ssh-keys -n agents \
|
||||
--from-file=private-key=agent-key \
|
||||
--from-file=public-key=agent-key.pub
|
||||
|
||||
# Añadir public key a Gitea
|
||||
# (via API o manualmente en UI)
|
||||
```
|
||||
|
||||
## Git Config para Agentes
|
||||
|
||||
```yaml
|
||||
# k8s/agents/git-config.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: git-config
|
||||
namespace: agents
|
||||
data:
|
||||
.gitconfig: |
|
||||
[user]
|
||||
name = AiWorker Agent
|
||||
email = agent@aiworker.dev
|
||||
[core]
|
||||
sshCommand = ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=no
|
||||
[credential]
|
||||
helper = store
|
||||
```
|
||||
481
docs/04-kubernetes/namespaces.md
Normal file
481
docs/04-kubernetes/namespaces.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Estructura de Namespaces
|
||||
|
||||
## Arquitectura de Namespaces
|
||||
|
||||
```
|
||||
aiworker-cluster/
|
||||
├── control-plane/ # Backend, API, MCP Server
|
||||
├── agents/ # Claude Code agent pods
|
||||
├── gitea/ # Gitea server
|
||||
├── projects/
|
||||
│ └── <project-name>/
|
||||
│ ├── dev/ # Desarrollo continuo
|
||||
│ ├── preview-*/ # Preview deployments por tarea
|
||||
│ ├── staging/ # Staging environment
|
||||
│ └── production/ # Production environment
|
||||
└── monitoring/ # Prometheus, Grafana
|
||||
```
|
||||
|
||||
## Namespace: control-plane
|
||||
|
||||
**Propósito**: Backend API, MCP Server, servicios core
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: control-plane
|
||||
labels:
|
||||
name: control-plane
|
||||
environment: production
|
||||
managed-by: aiworker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: control-plane-quota
|
||||
namespace: control-plane
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "4"
|
||||
requests.memory: 8Gi
|
||||
limits.cpu: "8"
|
||||
limits.memory: 16Gi
|
||||
persistentvolumeclaims: "5"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: LimitRange
|
||||
metadata:
|
||||
name: control-plane-limits
|
||||
namespace: control-plane
|
||||
spec:
|
||||
limits:
|
||||
- max:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
min:
|
||||
cpu: "100m"
|
||||
memory: 128Mi
|
||||
default:
|
||||
cpu: "500m"
|
||||
memory: 512Mi
|
||||
defaultRequest:
|
||||
cpu: "250m"
|
||||
memory: 256Mi
|
||||
type: Container
|
||||
```
|
||||
|
||||
### Servicios en control-plane
|
||||
|
||||
- **Backend API**: Express + Bun
|
||||
- **MCP Server**: Comunicación con agentes
|
||||
- **MySQL**: Base de datos
|
||||
- **Redis**: Cache y colas
|
||||
- **BullMQ Workers**: Procesamiento de jobs
|
||||
|
||||
## Namespace: agents
|
||||
|
||||
**Propósito**: Pods de Claude Code agents
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: agents
|
||||
labels:
|
||||
name: agents
|
||||
environment: production
|
||||
managed-by: aiworker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: agents-quota
|
||||
namespace: agents
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "20"
|
||||
requests.memory: 40Gi
|
||||
limits.cpu: "40"
|
||||
limits.memory: 80Gi
|
||||
pods: "50"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: LimitRange
|
||||
metadata:
|
||||
name: agents-limits
|
||||
namespace: agents
|
||||
spec:
|
||||
limits:
|
||||
- max:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
min:
|
||||
cpu: "500m"
|
||||
memory: 1Gi
|
||||
default:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
defaultRequest:
|
||||
cpu: "500m"
|
||||
memory: 1Gi
|
||||
type: Container
|
||||
```
|
||||
|
||||
### Network Policy para Agents
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: agents-network-policy
|
||||
namespace: agents
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
# Permitir tráfico desde control-plane
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: control-plane
|
||||
egress:
|
||||
# Permitir salida a control-plane (MCP Server)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: control-plane
|
||||
# Permitir salida a gitea
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: gitea
|
||||
# Permitir DNS
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
# Permitir HTTPS externo (para Claude API)
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
```
|
||||
|
||||
## Namespace: gitea
|
||||
|
||||
**Propósito**: Servidor Git auto-alojado
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
labels:
|
||||
name: gitea
|
||||
environment: production
|
||||
managed-by: aiworker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: gitea-quota
|
||||
namespace: gitea
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "2"
|
||||
requests.memory: 4Gi
|
||||
limits.cpu: "4"
|
||||
limits.memory: 8Gi
|
||||
persistentvolumeclaims: "2"
|
||||
```
|
||||
|
||||
## Namespaces por Proyecto
|
||||
|
||||
### Estructura Dinámica
|
||||
|
||||
Para cada proyecto creado, se generan automáticamente 4 namespaces:
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/namespaces.ts
|
||||
export async function createProjectNamespaces(projectName: string) {
|
||||
const baseName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
||||
|
||||
const namespaces = [
|
||||
`${baseName}-dev`,
|
||||
`${baseName}-staging`,
|
||||
`${baseName}-production`,
|
||||
]
|
||||
|
||||
for (const ns of namespaces) {
|
||||
await k8sClient.createNamespace({
|
||||
name: ns,
|
||||
labels: {
|
||||
project: baseName,
|
||||
'managed-by': 'aiworker',
|
||||
},
|
||||
})
|
||||
|
||||
// Aplicar resource quotas
|
||||
await k8sClient.applyResourceQuota(ns, {
|
||||
requests: { cpu: '2', memory: '4Gi' },
|
||||
limits: { cpu: '4', memory: '8Gi' },
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Namespace: project-dev
|
||||
|
||||
**Propósito**: Desarrollo continuo, deploy automático de main/develop
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: my-project-dev
|
||||
labels:
|
||||
project: my-project
|
||||
environment: dev
|
||||
managed-by: aiworker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: dev-quota
|
||||
namespace: my-project-dev
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "1"
|
||||
requests.memory: 2Gi
|
||||
limits.cpu: "2"
|
||||
limits.memory: 4Gi
|
||||
pods: "5"
|
||||
```
|
||||
|
||||
### Namespace: preview-task-{id}
|
||||
|
||||
**Propósito**: Preview deployment temporal para una tarea específica
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: preview-task-abc123
|
||||
labels:
|
||||
project: my-project
|
||||
environment: preview
|
||||
task-id: abc123
|
||||
managed-by: aiworker
|
||||
ttl: "168h" # 7 days
|
||||
annotations:
|
||||
created-at: "2026-01-19T12:00:00Z"
|
||||
---
|
||||
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: "3"
|
||||
```
|
||||
|
||||
**Limpieza automática**:
|
||||
```typescript
|
||||
// Cleanup job que corre diariamente
|
||||
export async function cleanupOldPreviewNamespaces() {
|
||||
const allNamespaces = await k8sClient.listNamespaces()
|
||||
|
||||
for (const ns of allNamespaces) {
|
||||
if (ns.metadata?.labels?.environment === 'preview') {
|
||||
const createdAt = new Date(ns.metadata.annotations?.['created-at'])
|
||||
const ageHours = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
if (ageHours > 168) { // 7 days
|
||||
await k8sClient.deleteNamespace(ns.metadata.name)
|
||||
logger.info(`Deleted old preview namespace: ${ns.metadata.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Namespace: project-staging
|
||||
|
||||
**Propósito**: Staging environment, testing antes de producción
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: my-project-staging
|
||||
labels:
|
||||
project: my-project
|
||||
environment: staging
|
||||
managed-by: aiworker
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: staging-quota
|
||||
namespace: my-project-staging
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "2"
|
||||
requests.memory: 4Gi
|
||||
limits.cpu: "4"
|
||||
limits.memory: 8Gi
|
||||
pods: "10"
|
||||
```
|
||||
|
||||
### Namespace: project-production
|
||||
|
||||
**Propósito**: Production environment
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: my-project-production
|
||||
labels:
|
||||
project: my-project
|
||||
environment: production
|
||||
managed-by: aiworker
|
||||
protected: "true"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: production-quota
|
||||
namespace: my-project-production
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "4"
|
||||
requests.memory: 8Gi
|
||||
limits.cpu: "8"
|
||||
limits.memory: 16Gi
|
||||
pods: "20"
|
||||
---
|
||||
# Pod Disruption Budget para alta disponibilidad
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: app-pdb
|
||||
namespace: my-project-production
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-project
|
||||
```
|
||||
|
||||
## Namespace: monitoring
|
||||
|
||||
**Propósito**: Prometheus, Grafana, logs
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: monitoring
|
||||
labels:
|
||||
name: monitoring
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: monitoring-quota
|
||||
namespace: monitoring
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "4"
|
||||
requests.memory: 8Gi
|
||||
limits.cpu: "8"
|
||||
limits.memory: 16Gi
|
||||
persistentvolumeclaims: "10"
|
||||
```
|
||||
|
||||
## Gestión de Namespaces desde el Backend
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/namespaces.ts
|
||||
import { KubeConfig, CoreV1Api } from '@kubernetes/client-node'
|
||||
|
||||
export class NamespaceManager {
|
||||
private k8sApi: CoreV1Api
|
||||
|
||||
constructor() {
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromDefault()
|
||||
this.k8sApi = kc.makeApiClient(CoreV1Api)
|
||||
}
|
||||
|
||||
async createNamespace(name: string, labels: Record<string, string> = {}) {
|
||||
await this.k8sApi.createNamespace({
|
||||
metadata: {
|
||||
name,
|
||||
labels: {
|
||||
'managed-by': 'aiworker',
|
||||
...labels,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async deleteNamespace(name: string) {
|
||||
await this.k8sApi.deleteNamespace(name)
|
||||
}
|
||||
|
||||
async listNamespaces(labelSelector?: string) {
|
||||
const response = await this.k8sApi.listNamespace(undefined, undefined, undefined, undefined, labelSelector)
|
||||
return response.body.items
|
||||
}
|
||||
|
||||
async namespaceExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
await this.k8sApi.readNamespace(name)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboard de Namespaces
|
||||
|
||||
En el frontend, mostrar todos los namespaces con sus recursos:
|
||||
|
||||
```typescript
|
||||
// hooks/useNamespaces.ts
|
||||
export function useNamespaces(projectId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['namespaces', projectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/namespaces', {
|
||||
params: { projectId },
|
||||
})
|
||||
return data.namespaces
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Vista en el dashboard:
|
||||
- **Mapa de namespaces** por proyecto
|
||||
- **Uso de recursos** (CPU, memoria) por namespace
|
||||
- **Número de pods** activos
|
||||
- **Botón de cleanup** para preview namespaces antiguos
|
||||
474
docs/04-kubernetes/networking.md
Normal file
474
docs/04-kubernetes/networking.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Networking e Ingress
|
||||
|
||||
## Arquitectura de Red
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
[LoadBalancer] (Cloud Provider)
|
||||
│
|
||||
▼
|
||||
[Nginx Ingress Controller]
|
||||
│
|
||||
├──► api.aiworker.dev ──► Backend (control-plane)
|
||||
├──► git.aiworker.dev ──► Gitea (gitea)
|
||||
├──► app.aiworker.dev ──► Frontend (control-plane)
|
||||
├──► *.preview.aiworker.dev ──► Preview Deployments
|
||||
├──► staging-*.aiworker.dev ──► Staging Envs
|
||||
└──► *.aiworker.dev ──► Production Apps
|
||||
```
|
||||
|
||||
## Ingress Configuration
|
||||
|
||||
### Wildcard Certificate
|
||||
|
||||
```yaml
|
||||
# k8s/ingress/wildcard-certificate.yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: wildcard-aiworker
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
secretName: wildcard-aiworker-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
commonName: "*.aiworker.dev"
|
||||
dnsNames:
|
||||
- "aiworker.dev"
|
||||
- "*.aiworker.dev"
|
||||
- "*.preview.aiworker.dev"
|
||||
```
|
||||
|
||||
### Backend Ingress
|
||||
|
||||
```yaml
|
||||
# k8s/ingress/backend-ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: backend-ingress
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/websocket-services: "aiworker-backend"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "https://app.aiworker.dev"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- api.aiworker.dev
|
||||
secretName: backend-tls
|
||||
rules:
|
||||
- host: api.aiworker.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: aiworker-backend
|
||||
port:
|
||||
number: 3000
|
||||
```
|
||||
|
||||
### Frontend Ingress
|
||||
|
||||
```yaml
|
||||
# k8s/ingress/frontend-ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: frontend-ingress
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
more_set_headers "X-Frame-Options: DENY";
|
||||
more_set_headers "X-Content-Type-Options: nosniff";
|
||||
more_set_headers "X-XSS-Protection: 1; mode=block";
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- app.aiworker.dev
|
||||
secretName: frontend-tls
|
||||
rules:
|
||||
- host: app.aiworker.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: aiworker-frontend
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
### Preview Deployments Ingress Template
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/ingress.ts
|
||||
export function generatePreviewIngress(params: {
|
||||
taskId: string
|
||||
projectName: string
|
||||
namespace: string
|
||||
}) {
|
||||
const shortId = params.taskId.slice(0, 8)
|
||||
const host = `task-${shortId}.preview.aiworker.dev`
|
||||
|
||||
return {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
metadata: {
|
||||
name: `${params.projectName}-preview`,
|
||||
namespace: params.namespace,
|
||||
annotations: {
|
||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||
'nginx.ingress.kubernetes.io/ssl-redirect': 'true',
|
||||
'nginx.ingress.kubernetes.io/auth-type': 'basic',
|
||||
'nginx.ingress.kubernetes.io/auth-secret': 'preview-basic-auth',
|
||||
'nginx.ingress.kubernetes.io/auth-realm': 'Preview Environment',
|
||||
},
|
||||
labels: {
|
||||
environment: 'preview',
|
||||
task: params.taskId,
|
||||
project: params.projectName,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
ingressClassName: 'nginx',
|
||||
tls: [
|
||||
{
|
||||
hosts: [host],
|
||||
secretName: `${params.projectName}-preview-tls`,
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
host,
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: '/',
|
||||
pathType: 'Prefix',
|
||||
backend: {
|
||||
service: {
|
||||
name: `${params.projectName}-preview`,
|
||||
port: {
|
||||
number: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service Mesh (Opcional)
|
||||
|
||||
Si necesitas más control sobre el tráfico, considera usar Istio o Linkerd:
|
||||
|
||||
### Istio Gateway
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: Gateway
|
||||
metadata:
|
||||
name: aiworker-gateway
|
||||
namespace: istio-system
|
||||
spec:
|
||||
selector:
|
||||
istio: ingressgateway
|
||||
servers:
|
||||
- port:
|
||||
number: 443
|
||||
name: https
|
||||
protocol: HTTPS
|
||||
tls:
|
||||
mode: SIMPLE
|
||||
credentialName: wildcard-aiworker-tls
|
||||
hosts:
|
||||
- "*.aiworker.dev"
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: backend-vs
|
||||
namespace: control-plane
|
||||
spec:
|
||||
hosts:
|
||||
- "api.aiworker.dev"
|
||||
gateways:
|
||||
- istio-system/aiworker-gateway
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: /api
|
||||
route:
|
||||
- destination:
|
||||
host: aiworker-backend
|
||||
port:
|
||||
number: 3000
|
||||
```
|
||||
|
||||
## DNS Configuration
|
||||
|
||||
### Cloudflare DNS Records
|
||||
|
||||
```bash
|
||||
# A records
|
||||
api.aiworker.dev A <loadbalancer-ip>
|
||||
git.aiworker.dev A <loadbalancer-ip>
|
||||
app.aiworker.dev A <loadbalancer-ip>
|
||||
|
||||
# Wildcard for preview and dynamic environments
|
||||
*.preview.aiworker.dev A <loadbalancer-ip>
|
||||
*.aiworker.dev A <loadbalancer-ip>
|
||||
```
|
||||
|
||||
### External DNS (Automated)
|
||||
|
||||
```yaml
|
||||
# k8s/external-dns/external-dns-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: external-dns
|
||||
namespace: kube-system
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: external-dns
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: external-dns
|
||||
spec:
|
||||
serviceAccountName: external-dns
|
||||
containers:
|
||||
- name: external-dns
|
||||
image: registry.k8s.io/external-dns/external-dns:v0.14.0
|
||||
args:
|
||||
- --source=ingress
|
||||
- --domain-filter=aiworker.dev
|
||||
- --provider=cloudflare
|
||||
env:
|
||||
- name: CF_API_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cloudflare-api-token
|
||||
key: token
|
||||
```
|
||||
|
||||
## Network Policies
|
||||
|
||||
### Isolate Preview Environments
|
||||
|
||||
```yaml
|
||||
# k8s/network-policies/preview-isolation.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: preview-isolation
|
||||
namespace: agents
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
environment: preview
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
# Allow from ingress controller
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: ingress-nginx
|
||||
# Allow from control-plane
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: control-plane
|
||||
egress:
|
||||
# Allow to gitea
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: gitea
|
||||
# Allow to external HTTPS (npm, apt, etc)
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
# Allow DNS
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
```
|
||||
|
||||
### Allow Backend to All
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: backend-egress
|
||||
namespace: control-plane
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: aiworker-backend
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- {} # Allow all egress
|
||||
```
|
||||
|
||||
## Load Balancing
|
||||
|
||||
### Session Affinity for WebSocket
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
service.beta.kubernetes.io/external-traffic: OnlyLocal
|
||||
spec:
|
||||
selector:
|
||||
app: aiworker-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
sessionAffinity: ClientIP
|
||||
sessionAffinityConfig:
|
||||
clientIP:
|
||||
timeoutSeconds: 3600
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: backend-ingress
|
||||
namespace: control-plane
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/rate-limit-burst: "200"
|
||||
nginx.ingress.kubernetes.io/rate-limit-key: "$binary_remote_addr"
|
||||
spec:
|
||||
# ... spec
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Liveness and Readiness Probes
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
httpHeaders:
|
||||
- name: X-Health-Check
|
||||
value: liveness
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health/ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
### Health Endpoint Implementation
|
||||
|
||||
```typescript
|
||||
// api/routes/health.ts
|
||||
import { Router } from 'express'
|
||||
import { getDatabase } from '../../config/database'
|
||||
import { getRedis } from '../../config/redis'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/health', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/health/ready', async (req, res) => {
|
||||
try {
|
||||
// Check DB
|
||||
const db = getDatabase()
|
||||
await db.execute('SELECT 1')
|
||||
|
||||
// Check Redis
|
||||
const redis = getRedis()
|
||||
await redis.ping()
|
||||
|
||||
res.json({
|
||||
status: 'ready',
|
||||
services: {
|
||||
database: 'connected',
|
||||
redis: 'connected',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'not ready',
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Monitoring Traffic
|
||||
|
||||
```bash
|
||||
# Ver logs de Nginx Ingress
|
||||
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller --tail=100 -f
|
||||
|
||||
# Ver métricas
|
||||
kubectl top pods -n ingress-nginx
|
||||
|
||||
# Ver configuración generada
|
||||
kubectl exec -n ingress-nginx <pod> -- cat /etc/nginx/nginx.conf
|
||||
```
|
||||
452
docs/05-agents/ciclo-vida.md
Normal file
452
docs/05-agents/ciclo-vida.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Ciclo de Vida de los Agentes
|
||||
|
||||
## Estados del Agente
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Initializing │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────┐ ┌──────┐
|
||||
│ Idle │◄───►│ Busy │
|
||||
└───┬──┘ └──┬───┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────┐ ┌───────┐
|
||||
│ Error │ │Offline│
|
||||
└───────┘ └───────┘
|
||||
```
|
||||
|
||||
## Inicialización
|
||||
|
||||
### 1. Creación del Pod
|
||||
|
||||
```typescript
|
||||
// Backend crea el pod
|
||||
const agentManager = new AgentManager()
|
||||
const agent = await agentManager.createAgent(['javascript', 'react'])
|
||||
|
||||
// Resultado
|
||||
{
|
||||
id: 'agent-abc123',
|
||||
podName: 'claude-agent-abc123',
|
||||
namespace: 'agents',
|
||||
status: 'initializing'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Arranque del Contenedor
|
||||
|
||||
```bash
|
||||
# En el pod (entrypoint.sh)
|
||||
echo "🤖 Starting agent: $AGENT_ID"
|
||||
|
||||
# 1. Setup SSH
|
||||
echo "$GIT_SSH_KEY" > /root/.ssh/id_ed25519
|
||||
chmod 600 /root/.ssh/id_ed25519
|
||||
|
||||
# 2. Configure Claude Code MCP
|
||||
cat > /root/.claude-code/config.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"aiworker": {
|
||||
"url": "$MCP_SERVER_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3. Send initial heartbeat
|
||||
curl -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: $AGENT_ID" \
|
||||
-d '{"status":"idle"}'
|
||||
|
||||
# 4. Start work loop
|
||||
exec /usr/local/bin/agent-loop.sh
|
||||
```
|
||||
|
||||
### 3. Registro en el Sistema
|
||||
|
||||
```typescript
|
||||
// Backend detecta el heartbeat y actualiza
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'idle',
|
||||
lastHeartbeat: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Agent ${agentId} is now active`)
|
||||
```
|
||||
|
||||
## Asignación de Tarea
|
||||
|
||||
### 1. Agent Polling
|
||||
|
||||
```bash
|
||||
# agent-loop.sh
|
||||
while true; do
|
||||
echo "📋 Checking for tasks..."
|
||||
|
||||
TASK=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"get_next_task\",
|
||||
\"arguments\": {\"agentId\": \"$AGENT_ID\"}
|
||||
}")
|
||||
|
||||
TASK_ID=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.id // empty')
|
||||
|
||||
if [ -n "$TASK_ID" ]; then
|
||||
echo "🎯 Got task: $TASK_ID"
|
||||
process_task "$TASK_ID"
|
||||
else
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### 2. Backend Asigna Tarea
|
||||
|
||||
```typescript
|
||||
// services/mcp/handlers.ts - getNextTask()
|
||||
async function getNextTask(args: { agentId: string }) {
|
||||
// 1. Buscar siguiente tarea en backlog
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
orderBy: [desc(tasks.priority), asc(tasks.createdAt)],
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ message: 'No tasks' }) }] }
|
||||
}
|
||||
|
||||
// 2. Asignar al agente
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'in_progress',
|
||||
assignedAgentId: args.agentId,
|
||||
assignedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, task.id))
|
||||
|
||||
// 3. Actualizar agente
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'busy',
|
||||
currentTaskId: task.id,
|
||||
})
|
||||
.where(eq(agents.id, args.agentId))
|
||||
|
||||
// 4. Retornar tarea
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ task }),
|
||||
}],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Trabajo en Tarea
|
||||
|
||||
### Fase 1: Setup
|
||||
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone "$PROJECT_REPO" "/workspace/task-$TASK_ID"
|
||||
cd "/workspace/task-$TASK_ID"
|
||||
|
||||
# Create branch (via MCP)
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{\"name\": \"create_branch\", \"arguments\": {\"taskId\": \"$TASK_ID\"}}"
|
||||
|
||||
# Checkout branch
|
||||
git fetch origin
|
||||
git checkout "$BRANCH_NAME"
|
||||
```
|
||||
|
||||
### Fase 2: Implementación
|
||||
|
||||
```bash
|
||||
# Start Claude Code session
|
||||
claude-code chat --message "
|
||||
I need you to work on this task:
|
||||
|
||||
Title: $TASK_TITLE
|
||||
Description: $TASK_DESC
|
||||
|
||||
Instructions:
|
||||
1. Analyze the codebase
|
||||
2. Implement the changes
|
||||
3. Write tests
|
||||
4. Commit with clear messages
|
||||
5. Use MCP tools when done
|
||||
|
||||
Start working now.
|
||||
"
|
||||
```
|
||||
|
||||
### Fase 3: Preguntas (opcional)
|
||||
|
||||
```typescript
|
||||
// Si el agente necesita info
|
||||
await mcp.callTool('ask_user_question', {
|
||||
taskId,
|
||||
question: 'Should I add TypeScript types?',
|
||||
context: 'The codebase is in JavaScript...',
|
||||
})
|
||||
|
||||
// Cambiar estado a needs_input
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId,
|
||||
status: 'needs_input',
|
||||
})
|
||||
|
||||
// Hacer polling cada 5s hasta respuesta
|
||||
let response
|
||||
while (!response) {
|
||||
await sleep(5000)
|
||||
const check = await mcp.callTool('check_question_response', { taskId })
|
||||
if (check.hasResponse) {
|
||||
response = check.response
|
||||
}
|
||||
}
|
||||
|
||||
// Continuar con la respuesta
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId,
|
||||
status: 'in_progress',
|
||||
})
|
||||
```
|
||||
|
||||
### Fase 4: Finalización
|
||||
|
||||
```bash
|
||||
# Create PR
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"create_pull_request\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"title\": \"$TASK_TITLE\",
|
||||
\"description\": \"Implemented feature X...\"
|
||||
}
|
||||
}"
|
||||
|
||||
# Deploy preview
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"trigger_preview_deploy\",
|
||||
\"arguments\": {\"taskId\": \"$TASK_ID\"}
|
||||
}"
|
||||
|
||||
# Update status
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"ready_to_test\"
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Liberación del Agente
|
||||
|
||||
```typescript
|
||||
// Cuando tarea completa (ready_to_test o completed)
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
tasksCompleted: sql`tasks_completed + 1`,
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Agent ${agentId} completed task ${taskId}, now idle`)
|
||||
```
|
||||
|
||||
## Manejo de Errores
|
||||
|
||||
### Timeout de Tarea
|
||||
|
||||
```bash
|
||||
# agent-loop.sh con timeout
|
||||
timeout 7200 claude-code chat --message "$TASK_PROMPT" || {
|
||||
STATUS=$?
|
||||
if [ $STATUS -eq 124 ]; then
|
||||
echo "⏰ Task timeout after 2 hours"
|
||||
|
||||
# Notify backend
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"needs_input\",
|
||||
\"metadata\": {\"reason\": \"timeout\"}
|
||||
}
|
||||
}"
|
||||
|
||||
# Log error
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"log_activity\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\",
|
||||
\"level\": \"error\",
|
||||
\"message\": \"Task timeout: $TASK_ID\"
|
||||
}
|
||||
}"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### Crash del Agente
|
||||
|
||||
```typescript
|
||||
// Backend detecta agente sin heartbeat
|
||||
async function checkStaleAgents() {
|
||||
const staleThreshold = new Date(Date.now() - 5 * 60 * 1000) // 5 min
|
||||
|
||||
const staleAgents = await db.query.agents.findMany({
|
||||
where: lt(agents.lastHeartbeat, staleThreshold),
|
||||
})
|
||||
|
||||
for (const agent of staleAgents) {
|
||||
logger.warn(`Agent ${agent.id} is stale`)
|
||||
|
||||
// Mark current task as needs attention
|
||||
if (agent.currentTaskId) {
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'backlog',
|
||||
assignedAgentId: null,
|
||||
})
|
||||
.where(eq(tasks.id, agent.currentTaskId))
|
||||
}
|
||||
|
||||
// Delete agent pod
|
||||
await k8sClient.deletePod(agent.k8sNamespace, agent.podName)
|
||||
|
||||
// Remove from DB
|
||||
await db.delete(agents).where(eq(agents.id, agent.id))
|
||||
|
||||
// Create replacement
|
||||
await agentManager.createAgent()
|
||||
}
|
||||
}
|
||||
|
||||
// Run every minute
|
||||
setInterval(checkStaleAgents, 60000)
|
||||
```
|
||||
|
||||
## Terminación Graciosa
|
||||
|
||||
```bash
|
||||
# agent-entrypoint.sh
|
||||
cleanup() {
|
||||
echo "🛑 Shutting down agent..."
|
||||
|
||||
# Send offline status
|
||||
curl -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-d '{"status":"offline"}' 2>/dev/null || true
|
||||
|
||||
# Kill background jobs
|
||||
kill $HEARTBEAT_PID 2>/dev/null || true
|
||||
|
||||
echo "👋 Goodbye"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Wait for signals
|
||||
wait
|
||||
```
|
||||
|
||||
## Auto-Scaling
|
||||
|
||||
```typescript
|
||||
// Auto-scaler que corre cada 30s
|
||||
async function autoScale() {
|
||||
// Get metrics
|
||||
const pendingTasks = await db.query.tasks.findMany({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
})
|
||||
|
||||
const idleAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'idle'),
|
||||
})
|
||||
|
||||
const busyAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'busy'),
|
||||
})
|
||||
|
||||
const totalAgents = idleAgents.length + busyAgents.length
|
||||
|
||||
// Decision logic
|
||||
let targetAgents = totalAgents
|
||||
|
||||
// Scale up if:
|
||||
// - More than 3 pending tasks
|
||||
// - No idle agents
|
||||
if (pendingTasks.length > 3 && idleAgents.length === 0) {
|
||||
targetAgents = Math.min(totalAgents + 2, 10) // Max 10
|
||||
}
|
||||
|
||||
// Scale down if:
|
||||
// - No pending tasks
|
||||
// - More than 2 idle agents
|
||||
if (pendingTasks.length === 0 && idleAgents.length > 2) {
|
||||
targetAgents = Math.max(totalAgents - 1, 2) // Min 2
|
||||
}
|
||||
|
||||
if (targetAgents !== totalAgents) {
|
||||
logger.info(`Auto-scaling: ${totalAgents} → ${targetAgents}`)
|
||||
await agentManager.scaleAgents(targetAgents)
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(autoScale, 30000)
|
||||
```
|
||||
|
||||
## Métricas del Ciclo de Vida
|
||||
|
||||
```typescript
|
||||
// Endpoint para métricas de agentes
|
||||
router.get('/agents/metrics', async (req, res) => {
|
||||
const agents = await db.query.agents.findMany()
|
||||
|
||||
const metrics = {
|
||||
total: agents.length,
|
||||
byStatus: {
|
||||
idle: agents.filter((a) => a.status === 'idle').length,
|
||||
busy: agents.filter((a) => a.status === 'busy').length,
|
||||
error: agents.filter((a) => a.status === 'error').length,
|
||||
offline: agents.filter((a) => a.status === 'offline').length,
|
||||
},
|
||||
totalTasksCompleted: agents.reduce((sum, a) => sum + a.tasksCompleted, 0),
|
||||
avgTasksPerAgent:
|
||||
agents.reduce((sum, a) => sum + a.tasksCompleted, 0) / agents.length || 0,
|
||||
totalRuntime: agents.reduce((sum, a) => sum + a.totalRuntimeMinutes, 0),
|
||||
}
|
||||
|
||||
res.json(metrics)
|
||||
})
|
||||
```
|
||||
|
||||
## Dashboard Visualization
|
||||
|
||||
En el frontend, mostrar:
|
||||
- **Estado actual** de cada agente (idle/busy/error)
|
||||
- **Tarea actual** si está busy
|
||||
- **Historial** de tareas completadas
|
||||
- **Métricas** (tareas/hora, uptime, etc.)
|
||||
- **Botones** para restart/delete agente
|
||||
- **Logs en tiempo real** de cada agente
|
||||
499
docs/05-agents/claude-code-pods.md
Normal file
499
docs/05-agents/claude-code-pods.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# Claude Code Agents - Pods en Kubernetes
|
||||
|
||||
## Dockerfile del Agente
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
openssh-client \
|
||||
curl \
|
||||
bash \
|
||||
vim
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create workspace
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy agent scripts
|
||||
COPY scripts/agent-entrypoint.sh /usr/local/bin/
|
||||
COPY scripts/agent-loop.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/agent-*.sh
|
||||
|
||||
# Git config
|
||||
RUN git config --global user.name "AiWorker Agent" && \
|
||||
git config --global user.email "agent@aiworker.dev" && \
|
||||
git config --global init.defaultBranch main
|
||||
|
||||
# Setup SSH
|
||||
RUN mkdir -p /root/.ssh && \
|
||||
ssh-keyscan -H git.aiworker.dev >> /root/.ssh/known_hosts
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
|
||||
```
|
||||
|
||||
## Agent Entrypoint Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/agent-entrypoint.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🤖 Starting AiWorker Agent..."
|
||||
echo "Agent ID: $AGENT_ID"
|
||||
|
||||
# Setup SSH key
|
||||
if [ -n "$GIT_SSH_KEY" ]; then
|
||||
echo "$GIT_SSH_KEY" > /root/.ssh/id_ed25519
|
||||
chmod 600 /root/.ssh/id_ed25519
|
||||
fi
|
||||
|
||||
# Configure Claude Code with MCP Server
|
||||
cat > /root/.claude-code/config.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"aiworker": {
|
||||
"command": "curl",
|
||||
"args": [
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", "X-Agent-ID: $AGENT_ID",
|
||||
"$MCP_SERVER_URL/rpc"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Send heartbeat
|
||||
send_heartbeat() {
|
||||
curl -s -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"agentId\":\"$AGENT_ID\",\"status\":\"$1\"}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Start heartbeat loop in background
|
||||
while true; do
|
||||
send_heartbeat "idle"
|
||||
sleep 30
|
||||
done &
|
||||
HEARTBEAT_PID=$!
|
||||
|
||||
# Trap signals for graceful shutdown
|
||||
trap "kill $HEARTBEAT_PID; send_heartbeat 'offline'; exit 0" SIGTERM SIGINT
|
||||
|
||||
# Start agent work loop
|
||||
exec /usr/local/bin/agent-loop.sh
|
||||
```
|
||||
|
||||
## Agent Work Loop
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/agent-loop.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Starting agent work loop..."
|
||||
|
||||
while true; do
|
||||
echo "📋 Checking for tasks..."
|
||||
|
||||
# Get next task via MCP
|
||||
TASK=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"get_next_task\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\"
|
||||
}
|
||||
}")
|
||||
|
||||
TASK_ID=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.id // empty')
|
||||
|
||||
if [ -z "$TASK_ID" ] || [ "$TASK_ID" = "null" ]; then
|
||||
echo "💤 No tasks available, waiting..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "🎯 Got task: $TASK_ID"
|
||||
|
||||
# Extract task details
|
||||
TASK_TITLE=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.title')
|
||||
TASK_DESC=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.description')
|
||||
PROJECT_REPO=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.project.giteaRepoUrl')
|
||||
|
||||
echo "📝 Task: $TASK_TITLE"
|
||||
echo "📦 Repo: $PROJECT_REPO"
|
||||
|
||||
# Log activity
|
||||
curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"log_activity\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\",
|
||||
\"level\": \"info\",
|
||||
\"message\": \"Starting task: $TASK_TITLE\"
|
||||
}
|
||||
}" > /dev/null
|
||||
|
||||
# Clone repository
|
||||
REPO_DIR="/workspace/task-$TASK_ID"
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "📥 Cloning repository..."
|
||||
git clone "$PROJECT_REPO" "$REPO_DIR"
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# Create branch via MCP
|
||||
echo "🌿 Creating branch..."
|
||||
BRANCH_RESULT=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"create_branch\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\"
|
||||
}
|
||||
}")
|
||||
|
||||
BRANCH_NAME=$(echo "$BRANCH_RESULT" | jq -r '.content[0].text | fromjson | .branchName')
|
||||
echo "🌿 Branch: $BRANCH_NAME"
|
||||
|
||||
# Fetch and checkout
|
||||
git fetch origin
|
||||
git checkout "$BRANCH_NAME" 2>/dev/null || git checkout -b "$BRANCH_NAME"
|
||||
|
||||
# Start Claude Code session
|
||||
echo "🧠 Starting Claude Code session..."
|
||||
|
||||
# Create task prompt
|
||||
TASK_PROMPT="I need you to work on the following task:
|
||||
|
||||
Title: $TASK_TITLE
|
||||
|
||||
Description:
|
||||
$TASK_DESC
|
||||
|
||||
Instructions:
|
||||
1. Analyze the codebase
|
||||
2. Implement the required changes
|
||||
3. Write tests if needed
|
||||
4. Commit your changes with clear messages
|
||||
5. When done, use the MCP tools to:
|
||||
- create_pull_request with a summary
|
||||
- trigger_preview_deploy
|
||||
- update_task_status to 'ready_to_test'
|
||||
|
||||
If you need clarification, use ask_user_question.
|
||||
|
||||
Start working on this task now."
|
||||
|
||||
# Run Claude Code (with timeout of 2 hours)
|
||||
timeout 7200 claude-code chat --message "$TASK_PROMPT" || {
|
||||
STATUS=$?
|
||||
if [ $STATUS -eq 124 ]; then
|
||||
echo "⏰ Task timeout"
|
||||
curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"needs_input\",
|
||||
\"metadata\": {\"reason\": \"timeout\"}
|
||||
}
|
||||
}" > /dev/null
|
||||
else
|
||||
echo "❌ Claude Code exited with status $STATUS"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "✅ Task completed: $TASK_ID"
|
||||
|
||||
# Cleanup
|
||||
cd /workspace
|
||||
rm -rf "$REPO_DIR"
|
||||
|
||||
# Brief pause before next task
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
## Pod Specification
|
||||
|
||||
```yaml
|
||||
# k8s/agents/claude-agent-pod.yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: claude-agent-{{ AGENT_ID }}
|
||||
namespace: agents
|
||||
labels:
|
||||
app: claude-agent
|
||||
agent-id: "{{ AGENT_ID }}"
|
||||
managed-by: aiworker
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: claude-agent
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: aiworker/claude-agent:latest
|
||||
imagePullPolicy: Always
|
||||
|
||||
env:
|
||||
- name: AGENT_ID
|
||||
value: "{{ AGENT_ID }}"
|
||||
|
||||
- name: MCP_SERVER_URL
|
||||
value: "http://aiworker-backend.control-plane.svc.cluster.local:3100"
|
||||
|
||||
- name: ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: anthropic-api-key
|
||||
|
||||
- name: GITEA_URL
|
||||
value: "http://gitea.gitea.svc.cluster.local:3000"
|
||||
|
||||
- name: GIT_SSH_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: git-ssh-keys
|
||||
key: private-key
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir:
|
||||
sizeLimit: 10Gi
|
||||
```
|
||||
|
||||
## Agent Manager (Backend)
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/agent-manager.ts
|
||||
import { K8sClient } from './client'
|
||||
import { db } from '../../db/client'
|
||||
import { agents } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import crypto from 'crypto'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class AgentManager {
|
||||
private k8sClient: K8sClient
|
||||
|
||||
constructor() {
|
||||
this.k8sClient = new K8sClient()
|
||||
}
|
||||
|
||||
async createAgent(capabilities: string[] = []) {
|
||||
const agentId = crypto.randomUUID()
|
||||
|
||||
// Create agent pod in K8s
|
||||
const { podName, namespace } = await this.k8sClient.createAgentPod(agentId)
|
||||
|
||||
// Insert in database
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
podName,
|
||||
k8sNamespace: namespace,
|
||||
status: 'initializing',
|
||||
capabilities,
|
||||
lastHeartbeat: new Date(),
|
||||
})
|
||||
|
||||
logger.info(`Created agent: ${agentId}`)
|
||||
|
||||
return {
|
||||
id: agentId,
|
||||
podName,
|
||||
namespace,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string) {
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
throw new Error('Agent not found')
|
||||
}
|
||||
|
||||
// Delete pod
|
||||
await this.k8sClient.deletePod(agent.k8sNamespace, agent.podName)
|
||||
|
||||
// Delete from database
|
||||
await db.delete(agents).where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Deleted agent: ${agentId}`)
|
||||
}
|
||||
|
||||
async scaleAgents(targetCount: number) {
|
||||
const currentAgents = await db.query.agents.findMany()
|
||||
|
||||
if (currentAgents.length < targetCount) {
|
||||
// Scale up
|
||||
const toCreate = targetCount - currentAgents.length
|
||||
logger.info(`Scaling up: creating ${toCreate} agents`)
|
||||
|
||||
for (let i = 0; i < toCreate; i++) {
|
||||
await this.createAgent()
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // Stagger creation
|
||||
}
|
||||
} else if (currentAgents.length > targetCount) {
|
||||
// Scale down
|
||||
const toDelete = currentAgents.length - targetCount
|
||||
logger.info(`Scaling down: deleting ${toDelete} agents`)
|
||||
|
||||
// Delete idle agents first
|
||||
const idleAgents = currentAgents.filter(a => a.status === 'idle').slice(0, toDelete)
|
||||
|
||||
for (const agent of idleAgents) {
|
||||
await this.deleteAgent(agent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async autoScale() {
|
||||
// Get pending tasks
|
||||
const pendingTasks = await db.query.tasks.findMany({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
})
|
||||
|
||||
// Get available agents
|
||||
const availableAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'idle'),
|
||||
})
|
||||
|
||||
const busyAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'busy'),
|
||||
})
|
||||
|
||||
const totalAgents = availableAgents.length + busyAgents.length
|
||||
|
||||
// Simple scaling logic
|
||||
const targetAgents = Math.min(
|
||||
Math.max(2, pendingTasks.length, busyAgents.length + 1), // At least 2, max 1 per pending task
|
||||
10 // Max 10 agents
|
||||
)
|
||||
|
||||
if (targetAgents !== totalAgents) {
|
||||
logger.info(`Auto-scaling agents: ${totalAgents} → ${targetAgents}`)
|
||||
await this.scaleAgents(targetAgents)
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupStaleAgents() {
|
||||
const staleThreshold = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes
|
||||
|
||||
const staleAgents = await db.query.agents.findMany({
|
||||
where: (agents, { lt }) => lt(agents.lastHeartbeat, staleThreshold),
|
||||
})
|
||||
|
||||
for (const agent of staleAgents) {
|
||||
logger.warn(`Cleaning up stale agent: ${agent.id}`)
|
||||
await this.deleteAgent(agent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start autoscaler
|
||||
setInterval(async () => {
|
||||
const manager = new AgentManager()
|
||||
await manager.autoScale()
|
||||
await manager.cleanupStaleAgents()
|
||||
}, 30000) // Every 30 seconds
|
||||
```
|
||||
|
||||
## Agent Logs Streaming
|
||||
|
||||
```typescript
|
||||
// api/routes/agents.ts
|
||||
import { Router } from 'express'
|
||||
import { K8sClient } from '../../services/kubernetes/client'
|
||||
import { db } from '../../db/client'
|
||||
import { agents } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
const router = Router()
|
||||
const k8sClient = new K8sClient()
|
||||
|
||||
router.get('/:agentId/logs/stream', async (req, res) => {
|
||||
const { agentId } = req.params
|
||||
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' })
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
|
||||
try {
|
||||
const logStream = await k8sClient.streamPodLogs(agent.k8sNamespace, agent.podName)
|
||||
|
||||
logStream.on('data', (chunk) => {
|
||||
res.write(`data: ${chunk.toString()}\n\n`)
|
||||
})
|
||||
|
||||
logStream.on('end', () => {
|
||||
res.end()
|
||||
})
|
||||
|
||||
req.on('close', () => {
|
||||
logStream.destroy()
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stream logs' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Monitoring Agents
|
||||
|
||||
```bash
|
||||
# Ver todos los agentes
|
||||
kubectl get pods -n agents -l app=claude-agent
|
||||
|
||||
# Ver logs de un agente
|
||||
kubectl logs -n agents claude-agent-abc123 -f
|
||||
|
||||
# Entrar a un agente
|
||||
kubectl exec -it -n agents claude-agent-abc123 -- /bin/bash
|
||||
|
||||
# Ver recursos consumidos
|
||||
kubectl top pods -n agents
|
||||
```
|
||||
567
docs/05-agents/comunicacion.md
Normal file
567
docs/05-agents/comunicacion.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Comunicación Agentes-Backend
|
||||
|
||||
## Arquitectura de Comunicación
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Claude Code Agent │
|
||||
│ (Pod en K8s) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
│ MCP Protocol
|
||||
│ (HTTP/JSON-RPC)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ MCP Server │
|
||||
│ (Backend Service) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────┐ ┌────────┐
|
||||
│ MySQL │ │ Gitea │
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
## MCP Protocol Implementation
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_next_task",
|
||||
"arguments": {
|
||||
"agentId": "agent-uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "{\"task\": {...}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Client en Agente
|
||||
|
||||
```typescript
|
||||
// agent/mcp-client.ts
|
||||
class MCPClient {
|
||||
private baseUrl: string
|
||||
private agentId: string
|
||||
|
||||
constructor(baseUrl: string, agentId: string) {
|
||||
this.baseUrl = baseUrl
|
||||
this.agentId = agentId
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: any) {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-ID': this.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`MCP call failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-ID': this.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.result.tools
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const mcp = new MCPClient(
|
||||
process.env.MCP_SERVER_URL,
|
||||
process.env.AGENT_ID
|
||||
)
|
||||
|
||||
const task = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID,
|
||||
})
|
||||
```
|
||||
|
||||
## Server-Side Handler
|
||||
|
||||
```typescript
|
||||
// backend: api/routes/mcp.ts
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { handleToolCall } from '../../services/mcp/handlers'
|
||||
import { tools } from '../../services/mcp/tools'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// JSON-RPC endpoint
|
||||
router.post('/rpc', async (req: Request, res: Response) => {
|
||||
const { jsonrpc, id, method, params } = req.body
|
||||
|
||||
if (jsonrpc !== '2.0') {
|
||||
return res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32600,
|
||||
message: 'Invalid Request',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const agentId = req.headers['x-agent-id'] as string
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Missing agent ID',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'tools/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
case 'tools/call':
|
||||
const { name, arguments: args } = params
|
||||
|
||||
logger.info(`MCP call from ${agentId}: ${name}`)
|
||||
|
||||
const result = await handleToolCall(name, {
|
||||
...args,
|
||||
agentId,
|
||||
})
|
||||
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
})
|
||||
|
||||
default:
|
||||
return res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('MCP error:', error)
|
||||
|
||||
return res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
data: error.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Heartbeat System
|
||||
|
||||
### Agent-Side Heartbeat
|
||||
|
||||
```bash
|
||||
# In agent pod
|
||||
while true; do
|
||||
curl -s -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: $AGENT_ID" \
|
||||
-d "{\"status\":\"idle\"}"
|
||||
sleep 30
|
||||
done &
|
||||
```
|
||||
|
||||
### Server-Side Heartbeat Handler
|
||||
|
||||
```typescript
|
||||
// api/routes/mcp.ts
|
||||
router.post('/heartbeat', async (req: Request, res: Response) => {
|
||||
const agentId = req.headers['x-agent-id'] as string
|
||||
const { status } = req.body
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(401).json({ error: 'Missing agent ID' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(agents)
|
||||
.set({
|
||||
lastHeartbeat: new Date(),
|
||||
status: status || 'idle',
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update heartbeat' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## WebSocket for Real-Time Updates
|
||||
|
||||
Alternativamente, para comunicación bidireccional en tiempo real:
|
||||
|
||||
```typescript
|
||||
// backend: api/websocket/agents.ts
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
|
||||
export function setupAgentWebSocket(io: SocketIOServer) {
|
||||
const agentNamespace = io.of('/agents')
|
||||
|
||||
agentNamespace.on('connection', (socket) => {
|
||||
const agentId = socket.handshake.query.agentId as string
|
||||
|
||||
console.log(`Agent connected: ${agentId}`)
|
||||
|
||||
// Join agent room
|
||||
socket.join(agentId)
|
||||
|
||||
// Heartbeat
|
||||
socket.on('heartbeat', async (data) => {
|
||||
await db.update(agents)
|
||||
.set({
|
||||
lastHeartbeat: new Date(),
|
||||
status: data.status,
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
})
|
||||
|
||||
// Task updates
|
||||
socket.on('task_update', async (data) => {
|
||||
await db.update(tasks)
|
||||
.set({ state: data.state })
|
||||
.where(eq(tasks.id, data.taskId))
|
||||
|
||||
// Notify frontend
|
||||
io.emit('task:status_changed', {
|
||||
taskId: data.taskId,
|
||||
newState: data.state,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Agent disconnected: ${agentId}`)
|
||||
})
|
||||
})
|
||||
|
||||
// Send task assignment to specific agent
|
||||
return {
|
||||
assignTask: (agentId: string, task: any) => {
|
||||
agentNamespace.to(agentId).emit('task_assigned', task)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT for Agents
|
||||
|
||||
```typescript
|
||||
// Generate agent token
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export function generateAgentToken(agentId: string) {
|
||||
return jwt.sign(
|
||||
{
|
||||
agentId,
|
||||
type: 'agent',
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{
|
||||
expiresIn: '7d',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Verify middleware
|
||||
export function verifyAgentToken(req: Request, res: Response, next: NextFunction) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' })
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
|
||||
req.agentId = decoded.agentId
|
||||
next()
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### mTLS (Optional)
|
||||
|
||||
Para seguridad adicional, usar mTLS entre agentes y backend:
|
||||
|
||||
```yaml
|
||||
# Agent pod with client cert
|
||||
volumes:
|
||||
- name: agent-certs
|
||||
secret:
|
||||
secretName: agent-client-certs
|
||||
|
||||
volumeMounts:
|
||||
- name: agent-certs
|
||||
mountPath: /etc/certs
|
||||
readOnly: true
|
||||
|
||||
env:
|
||||
- name: MCP_CLIENT_CERT
|
||||
value: /etc/certs/client.crt
|
||||
- name: MCP_CLIENT_KEY
|
||||
value: /etc/certs/client.key
|
||||
```
|
||||
|
||||
## Retry & Error Handling
|
||||
|
||||
```typescript
|
||||
// agent/mcp-client-with-retry.ts
|
||||
class MCPClientWithRetry extends MCPClient {
|
||||
async callToolWithRetry(
|
||||
toolName: string,
|
||||
args: any,
|
||||
maxRetries = 3
|
||||
) {
|
||||
let lastError
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await this.callTool(toolName, args)
|
||||
} catch (error: any) {
|
||||
lastError = error
|
||||
console.error(`Attempt ${i + 1} failed:`, error.message)
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
await sleep(Math.pow(2, i) * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
```typescript
|
||||
// agent/circuit-breaker.ts
|
||||
class CircuitBreaker {
|
||||
private failures = 0
|
||||
private lastFailureTime = 0
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed'
|
||||
|
||||
private readonly threshold = 5
|
||||
private readonly timeout = 60000 // 1 minute
|
||||
|
||||
async call<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailureTime > this.timeout) {
|
||||
this.state = 'half-open'
|
||||
} else {
|
||||
throw new Error('Circuit breaker is open')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
|
||||
if (this.state === 'half-open') {
|
||||
this.state = 'closed'
|
||||
this.failures = 0
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this.failures++
|
||||
this.lastFailureTime = Date.now()
|
||||
|
||||
if (this.failures >= this.threshold) {
|
||||
this.state = 'open'
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const breaker = new CircuitBreaker()
|
||||
|
||||
const task = await breaker.call(() =>
|
||||
mcp.callTool('get_next_task', { agentId })
|
||||
)
|
||||
```
|
||||
|
||||
## Monitoring Communication
|
||||
|
||||
```typescript
|
||||
// backend: middleware/mcp-metrics.ts
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const metrics = {
|
||||
totalCalls: 0,
|
||||
successCalls: 0,
|
||||
failedCalls: 0,
|
||||
callDurations: [] as number[],
|
||||
}
|
||||
|
||||
export function mcpMetricsMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const start = Date.now()
|
||||
metrics.totalCalls++
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start
|
||||
metrics.callDurations.push(duration)
|
||||
|
||||
if (res.statusCode < 400) {
|
||||
metrics.successCalls++
|
||||
} else {
|
||||
metrics.failedCalls++
|
||||
}
|
||||
|
||||
logger.debug('MCP call metrics', {
|
||||
method: req.body?.method,
|
||||
agentId: req.headers['x-agent-id'],
|
||||
duration,
|
||||
status: res.statusCode,
|
||||
})
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
// Endpoint para ver métricas
|
||||
router.get('/metrics', (req, res) => {
|
||||
res.json({
|
||||
total: metrics.totalCalls,
|
||||
success: metrics.successCalls,
|
||||
failed: metrics.failedCalls,
|
||||
avgDuration:
|
||||
metrics.callDurations.reduce((a, b) => a + b, 0) /
|
||||
metrics.callDurations.length,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing MCP Communication
|
||||
|
||||
```typescript
|
||||
// test/mcp-client.test.ts
|
||||
import { MCPClient } from '../agent/mcp-client'
|
||||
|
||||
describe('MCP Client', () => {
|
||||
let client: MCPClient
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MCPClient('http://localhost:3100', 'test-agent')
|
||||
})
|
||||
|
||||
it('should list available tools', async () => {
|
||||
const tools = await client.listTools()
|
||||
expect(tools).toContainEqual(
|
||||
expect.objectContaining({ name: 'get_next_task' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should call tool successfully', async () => {
|
||||
const result = await client.callTool('heartbeat', {
|
||||
status: 'idle',
|
||||
})
|
||||
expect(result.content[0].text).toContain('success')
|
||||
})
|
||||
|
||||
it('should handle errors', async () => {
|
||||
await expect(
|
||||
client.callTool('invalid_tool', {})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
```
|
||||
452
docs/05-agents/mcp-tools.md
Normal file
452
docs/05-agents/mcp-tools.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# MCP Tools - Herramientas Disponibles para Agentes
|
||||
|
||||
Esta documentación detalla todas las herramientas MCP que los agentes Claude Code pueden usar para interactuar con el sistema AiWorker.
|
||||
|
||||
## get_next_task
|
||||
|
||||
Obtiene la siguiente tarea disponible de la cola y la asigna al agente.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "uuid-of-agent",
|
||||
"capabilities": ["javascript", "react", "python"] // opcional
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "task-uuid",
|
||||
"title": "Implement user authentication",
|
||||
"description": "Create a JWT-based authentication system...",
|
||||
"priority": "high",
|
||||
"project": {
|
||||
"id": "project-uuid",
|
||||
"name": "My App",
|
||||
"giteaRepoUrl": "http://gitea/owner/my-app",
|
||||
"dockerImage": "myapp:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo de uso**:
|
||||
```typescript
|
||||
// En Claude Code, el agente puede hacer:
|
||||
const task = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID,
|
||||
capabilities: ['javascript', 'typescript', 'react']
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## update_task_status
|
||||
|
||||
Actualiza el estado de una tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"status": "in_progress" | "needs_input" | "ready_to_test" | "completed",
|
||||
"metadata": {
|
||||
"durationMinutes": 45,
|
||||
"linesChanged": 250
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Estados válidos**:
|
||||
- `in_progress`: Agente trabajando activamente
|
||||
- `needs_input`: Agente necesita información del usuario
|
||||
- `ready_to_test`: Tarea completada, lista para testing
|
||||
- `completed`: Tarea completamente finalizada
|
||||
|
||||
---
|
||||
|
||||
## ask_user_question
|
||||
|
||||
Solicita información al usuario cuando el agente necesita clarificación.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"question": "Which authentication library should I use: Passport.js or NextAuth?",
|
||||
"context": "The task requires implementing OAuth authentication. I found two popular options..."
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Question sent to user",
|
||||
"questionId": "question-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
1. Cambia el estado de la tarea a `needs_input`
|
||||
2. Notifica al frontend vía WebSocket
|
||||
3. Usuario responde desde el dashboard
|
||||
4. Agente puede hacer polling con `check_question_response`
|
||||
|
||||
---
|
||||
|
||||
## check_question_response
|
||||
|
||||
Verifica si el usuario ha respondido una pregunta.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output (sin respuesta)**:
|
||||
```json
|
||||
{
|
||||
"hasResponse": false,
|
||||
"message": "No response yet"
|
||||
}
|
||||
```
|
||||
|
||||
**Output (con respuesta)**:
|
||||
```json
|
||||
{
|
||||
"hasResponse": true,
|
||||
"response": "Use NextAuth, it integrates better with our Next.js stack",
|
||||
"question": "Which authentication library should I use..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## create_branch
|
||||
|
||||
Crea una nueva rama en Gitea para la tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"branchName": "feature/user-auth" // opcional, se genera automático
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"branchName": "task-abc123-implement-user-authentication",
|
||||
"repoUrl": "http://gitea/owner/my-app"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Si no se especifica `branchName`, se genera como: `task-{shortId}-{title-slugified}`
|
||||
- Se crea desde la rama default del proyecto (main/develop)
|
||||
- Se actualiza el campo `branchName` en la tarea
|
||||
|
||||
---
|
||||
|
||||
## create_pull_request
|
||||
|
||||
Crea un Pull Request en Gitea con los cambios de la tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"title": "Implement JWT-based authentication",
|
||||
"description": "## Changes\n- Added JWT middleware\n- Created auth routes\n- Added tests\n\n## Test Plan\n- [ ] Test login flow\n- [ ] Test token refresh"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"prUrl": "http://gitea/owner/my-app/pulls/42",
|
||||
"prNumber": 42
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Crea PR desde la rama de la tarea hacia la rama default
|
||||
- Actualiza campos `prNumber` y `prUrl` en la tarea
|
||||
- Emite evento WebSocket `task:pr_created`
|
||||
|
||||
---
|
||||
|
||||
## trigger_preview_deploy
|
||||
|
||||
Despliega la tarea en un preview environment aislado en Kubernetes.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"previewUrl": "https://task-abc123.preview.aiworker.dev",
|
||||
"namespace": "preview-task-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
1. Crea namespace en K8s: `preview-task-{shortId}`
|
||||
2. Deploya la aplicación con la imagen del proyecto
|
||||
3. Crea ingress con URL única
|
||||
4. Actualiza tarea a estado `ready_to_test`
|
||||
5. Guarda `previewUrl` y `previewNamespace` en la tarea
|
||||
|
||||
---
|
||||
|
||||
## get_task_details
|
||||
|
||||
Obtiene detalles completos de una tarea incluyendo preguntas pendientes.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "task-uuid",
|
||||
"title": "Implement user authentication",
|
||||
"description": "...",
|
||||
"state": "needs_input",
|
||||
"branchName": "task-abc123-implement-user-authentication",
|
||||
"prUrl": "http://gitea/owner/my-app/pulls/42",
|
||||
"previewUrl": "https://task-abc123.preview.aiworker.dev",
|
||||
"questions": [
|
||||
{
|
||||
"id": "q-uuid",
|
||||
"question": "Which auth library?",
|
||||
"status": "pending",
|
||||
"askedAt": "2026-01-19T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
"name": "My App",
|
||||
"giteaRepoUrl": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## log_activity
|
||||
|
||||
Registra actividad del agente en los logs del sistema.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "agent-uuid",
|
||||
"level": "info" | "debug" | "warn" | "error",
|
||||
"message": "Starting task implementation",
|
||||
"metadata": {
|
||||
"taskId": "task-uuid",
|
||||
"operation": "code_generation",
|
||||
"filesModified": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Niveles de log**:
|
||||
- `debug`: Información detallada de debugging
|
||||
- `info`: Eventos normales (default)
|
||||
- `warn`: Situaciones que requieren atención
|
||||
- `error`: Errores que impidieron completar una operación
|
||||
|
||||
---
|
||||
|
||||
## heartbeat
|
||||
|
||||
Envía señal de vida para indicar que el agente está activo.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "agent-uuid",
|
||||
"status": "idle" | "busy" | "error"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Actualiza `lastHeartbeat` timestamp
|
||||
- Actualiza `status` del agente
|
||||
- Si no se recibe heartbeat por 5 minutos, el agente se marca como `offline`
|
||||
|
||||
---
|
||||
|
||||
## Flujo Típico de una Tarea
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
Agent->>MCP: get_next_task()
|
||||
MCP-->>Agent: task details
|
||||
Agent->>MCP: create_branch()
|
||||
Agent->>Agent: Work on task
|
||||
Agent->>?MCP: ask_user_question() (si necesita)
|
||||
Agent->>Agent: Wait for response
|
||||
Agent->>MCP: check_question_response()
|
||||
Agent->>Agent: Continue working
|
||||
Agent->>Git: commit & push
|
||||
Agent->>MCP: create_pull_request()
|
||||
Agent->>MCP: trigger_preview_deploy()
|
||||
Agent->>MCP: update_task_status("ready_to_test")
|
||||
```
|
||||
|
||||
## Ejemplo de Uso Completo
|
||||
|
||||
```typescript
|
||||
// Dentro del agente Claude Code
|
||||
async function processTask() {
|
||||
// 1. Get task
|
||||
const taskResult = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID
|
||||
})
|
||||
|
||||
const task = JSON.parse(taskResult.content[0].text).task
|
||||
|
||||
if (!task) {
|
||||
console.log('No tasks available')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Working on: ${task.title}`)
|
||||
|
||||
// 2. Create branch
|
||||
const branchResult = await mcp.callTool('create_branch', {
|
||||
taskId: task.id
|
||||
})
|
||||
|
||||
const { branchName } = JSON.parse(branchResult.content[0].text)
|
||||
|
||||
// 3. Clone and checkout
|
||||
await exec(`git clone ${task.project.giteaRepoUrl} /workspace/task-${task.id}`)
|
||||
await exec(`cd /workspace/task-${task.id} && git checkout ${branchName}`)
|
||||
|
||||
// 4. Do the work...
|
||||
// (Claude Code generates and commits code)
|
||||
|
||||
// 5. Need clarification?
|
||||
if (needsClarification) {
|
||||
await mcp.callTool('ask_user_question', {
|
||||
taskId: task.id,
|
||||
question: 'Should I add error handling for network failures?',
|
||||
context: 'The API calls can fail...'
|
||||
})
|
||||
|
||||
// Wait for response
|
||||
let response
|
||||
while (!response) {
|
||||
await sleep(5000)
|
||||
const checkResult = await mcp.callTool('check_question_response', {
|
||||
taskId: task.id
|
||||
})
|
||||
const check = JSON.parse(checkResult.content[0].text)
|
||||
if (check.hasResponse) {
|
||||
response = check.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create PR
|
||||
await mcp.callTool('create_pull_request', {
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
description: `## Summary\nImplemented ${task.title}\n\n## Changes\n- Feature A\n- Feature B`
|
||||
})
|
||||
|
||||
// 7. Deploy preview
|
||||
await mcp.callTool('trigger_preview_deploy', {
|
||||
taskId: task.id
|
||||
})
|
||||
|
||||
// 8. Mark as done
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId: task.id,
|
||||
status: 'ready_to_test'
|
||||
})
|
||||
|
||||
console.log('Task completed!')
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Todos los tools pueden retornar errores:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Error: Task not found"
|
||||
}],
|
||||
"isError": true
|
||||
}
|
||||
```
|
||||
|
||||
El agente debe manejar estos errores apropiadamente:
|
||||
|
||||
```typescript
|
||||
const result = await mcp.callTool('update_task_status', { ... })
|
||||
|
||||
if (result.isError) {
|
||||
console.error('Tool failed:', result.content[0].text)
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Para evitar abuse, los tools tienen rate limits:
|
||||
|
||||
- `get_next_task`: 1 por segundo
|
||||
- `ask_user_question`: 5 por minuto por tarea
|
||||
- `create_pr`: 1 por minuto
|
||||
- `trigger_preview_deploy`: 1 por minuto
|
||||
- Otros: 10 por segundo
|
||||
|
||||
Si se excede el rate limit, el tool retorna error 429.
|
||||
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
|
||||
}
|
||||
```
|
||||
531
docs/06-deployment/gitops.md
Normal file
531
docs/06-deployment/gitops.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# GitOps con ArgoCD
|
||||
|
||||
## ¿Qué es GitOps?
|
||||
|
||||
GitOps usa Git como fuente única de verdad para infraestructura y aplicaciones. Los cambios se hacen via commits, y herramientas como ArgoCD sincronizan automáticamente el estado deseado en Git con el estado real en Kubernetes.
|
||||
|
||||
## Instalación de ArgoCD
|
||||
|
||||
```bash
|
||||
# Create namespace
|
||||
kubectl create namespace argocd
|
||||
|
||||
# Install ArgoCD
|
||||
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
|
||||
|
||||
# Wait for pods
|
||||
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=argocd-server -n argocd --timeout=300s
|
||||
|
||||
# Get initial admin password
|
||||
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
|
||||
|
||||
# Port forward to access UI
|
||||
kubectl port-forward svc/argocd-server -n argocd 8080:443
|
||||
|
||||
# Access at: https://localhost:8080
|
||||
# Username: admin
|
||||
# Password: (from above command)
|
||||
```
|
||||
|
||||
## Estructura de Repositorio GitOps
|
||||
|
||||
```
|
||||
gitops-repo/
|
||||
├── projects/
|
||||
│ ├── backend/
|
||||
│ │ ├── base/
|
||||
│ │ │ ├── deployment.yaml
|
||||
│ │ │ ├── service.yaml
|
||||
│ │ │ └── kustomization.yaml
|
||||
│ │ ├── dev/
|
||||
│ │ │ ├── kustomization.yaml
|
||||
│ │ │ └── patches.yaml
|
||||
│ │ ├── staging/
|
||||
│ │ │ ├── kustomization.yaml
|
||||
│ │ │ └── patches.yaml
|
||||
│ │ └── production/
|
||||
│ │ ├── kustomization.yaml
|
||||
│ │ └── patches.yaml
|
||||
│ │
|
||||
│ └── my-project/
|
||||
│ ├── base/
|
||||
│ ├── dev/
|
||||
│ ├── staging/
|
||||
│ └── production/
|
||||
│
|
||||
└── argocd/
|
||||
├── applications/
|
||||
│ ├── backend-dev.yaml
|
||||
│ ├── backend-staging.yaml
|
||||
│ ├── backend-production.yaml
|
||||
│ └── my-project-production.yaml
|
||||
└── app-of-apps.yaml
|
||||
```
|
||||
|
||||
## Base Manifests con Kustomize
|
||||
|
||||
```yaml
|
||||
# projects/backend/base/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: aiworker/backend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
---
|
||||
# projects/backend/base/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
---
|
||||
# projects/backend/base/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
|
||||
commonLabels:
|
||||
app: backend
|
||||
managed-by: argocd
|
||||
```
|
||||
|
||||
## Environment Overlays
|
||||
|
||||
```yaml
|
||||
# projects/backend/production/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: control-plane
|
||||
|
||||
bases:
|
||||
- ../base
|
||||
|
||||
patchesStrategicMerge:
|
||||
- patches.yaml
|
||||
|
||||
images:
|
||||
- name: aiworker/backend
|
||||
newTag: v1.2.3 # This gets updated automatically
|
||||
|
||||
replicas:
|
||||
- name: backend
|
||||
count: 3
|
||||
|
||||
configMapGenerator:
|
||||
- name: backend-config
|
||||
literals:
|
||||
- NODE_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
---
|
||||
# projects/backend/production/patches.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 4Gi
|
||||
```
|
||||
|
||||
## ArgoCD Application
|
||||
|
||||
```yaml
|
||||
# argocd/applications/backend-production.yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: backend-production
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
|
||||
source:
|
||||
repoURL: https://git.aiworker.dev/aiworker/gitops
|
||||
targetRevision: HEAD
|
||||
path: projects/backend/production
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: control-plane
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
allowEmpty: false
|
||||
syncOptions:
|
||||
- CreateNamespace=false
|
||||
retry:
|
||||
limit: 5
|
||||
backoff:
|
||||
duration: 5s
|
||||
factor: 2
|
||||
maxDuration: 3m
|
||||
|
||||
revisionHistoryLimit: 10
|
||||
```
|
||||
|
||||
## App of Apps Pattern
|
||||
|
||||
```yaml
|
||||
# argocd/app-of-apps.yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: aiworker-apps
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
|
||||
source:
|
||||
repoURL: https://git.aiworker.dev/aiworker/gitops
|
||||
targetRevision: HEAD
|
||||
path: argocd/applications
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: argocd
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
```
|
||||
|
||||
## Actualización de Imagen desde Backend
|
||||
|
||||
```typescript
|
||||
// services/gitops/updater.ts
|
||||
import { Octokit } from '@octokit/rest'
|
||||
import yaml from 'js-yaml'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class GitOpsUpdater {
|
||||
private octokit: Octokit
|
||||
private repo: string
|
||||
private owner: string
|
||||
|
||||
constructor() {
|
||||
this.octokit = new Octokit({
|
||||
baseUrl: process.env.GITEA_URL,
|
||||
auth: process.env.GITEA_TOKEN,
|
||||
})
|
||||
this.repo = 'gitops'
|
||||
this.owner = 'aiworker'
|
||||
}
|
||||
|
||||
async updateImage(params: {
|
||||
project: string
|
||||
environment: string
|
||||
imageTag: string
|
||||
}) {
|
||||
const { project, environment, imageTag } = params
|
||||
const path = `projects/${project}/${environment}/kustomization.yaml`
|
||||
|
||||
logger.info(`Updating GitOps: ${project}/${environment} → ${imageTag}`)
|
||||
|
||||
try {
|
||||
// 1. Get current file
|
||||
const { data: fileData } = await this.octokit.repos.getContent({
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
path,
|
||||
})
|
||||
|
||||
if (Array.isArray(fileData) || fileData.type !== 'file') {
|
||||
throw new Error('Invalid file')
|
||||
}
|
||||
|
||||
// 2. Decode content
|
||||
const content = Buffer.from(fileData.content, 'base64').toString('utf-8')
|
||||
const kustomization = yaml.load(content) as any
|
||||
|
||||
// 3. Update image tag
|
||||
if (!kustomization.images) {
|
||||
kustomization.images = []
|
||||
}
|
||||
|
||||
const imageIndex = kustomization.images.findIndex(
|
||||
(img: any) => img.name === `aiworker/${project}`
|
||||
)
|
||||
|
||||
if (imageIndex >= 0) {
|
||||
kustomization.images[imageIndex].newTag = imageTag
|
||||
} else {
|
||||
kustomization.images.push({
|
||||
name: `aiworker/${project}`,
|
||||
newTag: imageTag,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Encode new content
|
||||
const newContent = yaml.dump(kustomization)
|
||||
const newContentBase64 = Buffer.from(newContent).toString('base64')
|
||||
|
||||
// 5. Commit changes
|
||||
await this.octokit.repos.createOrUpdateFileContents({
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
path,
|
||||
message: `Update ${project} ${environment} to ${imageTag}`,
|
||||
content: newContentBase64,
|
||||
sha: fileData.sha,
|
||||
})
|
||||
|
||||
logger.info(`GitOps updated: ${project}/${environment}`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update GitOps:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integración con CI/CD
|
||||
|
||||
```typescript
|
||||
// services/queue/deploy-worker.ts
|
||||
import { GitOpsUpdater } from '../gitops/updater'
|
||||
|
||||
const gitopsUpdater = new GitOpsUpdater()
|
||||
|
||||
export const deployWorker = new Worker('deploys', async (job) => {
|
||||
const { deploymentId, projectId, environment, commitHash } = job.data
|
||||
|
||||
// ... deployment logic ...
|
||||
|
||||
// Update GitOps repo
|
||||
await gitopsUpdater.updateImage({
|
||||
project: project.name,
|
||||
environment,
|
||||
imageTag: commitHash.slice(0, 7),
|
||||
})
|
||||
|
||||
// ArgoCD will automatically sync within 3 minutes
|
||||
// Or trigger manual sync:
|
||||
await triggerArgoCDSync(project.name, environment)
|
||||
|
||||
logger.info('GitOps updated, ArgoCD will sync')
|
||||
})
|
||||
```
|
||||
|
||||
## Trigger ArgoCD Sync
|
||||
|
||||
```typescript
|
||||
// services/gitops/argocd.ts
|
||||
export async function triggerArgoCDSync(project: string, environment: string) {
|
||||
const appName = `${project}-${environment}`
|
||||
const argoCDUrl = process.env.ARGOCD_URL || 'https://argocd.aiworker.dev'
|
||||
const token = process.env.ARGOCD_TOKEN
|
||||
|
||||
const response = await fetch(`${argoCDUrl}/api/v1/applications/${appName}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prune: false,
|
||||
dryRun: false,
|
||||
strategy: {
|
||||
hook: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ArgoCD sync failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info(`Triggered ArgoCD sync: ${appName}`)
|
||||
}
|
||||
```
|
||||
|
||||
## Health Status from ArgoCD
|
||||
|
||||
```typescript
|
||||
// services/gitops/argocd.ts
|
||||
export async function getApplicationStatus(appName: string) {
|
||||
const argoCDUrl = process.env.ARGOCD_URL
|
||||
const token = process.env.ARGOCD_TOKEN
|
||||
|
||||
const response = await fetch(`${argoCDUrl}/api/v1/applications/${appName}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const app = await response.json()
|
||||
|
||||
return {
|
||||
syncStatus: app.status.sync.status, // Synced, OutOfSync
|
||||
healthStatus: app.status.health.status, // Healthy, Progressing, Degraded
|
||||
lastSyncedAt: app.status.operationState?.finishedAt,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring Dashboard
|
||||
|
||||
```typescript
|
||||
// api/routes/gitops.ts
|
||||
router.get('/gitops/status', async (req, res) => {
|
||||
const apps = ['backend-production', 'backend-staging', 'backend-dev']
|
||||
|
||||
const statuses = await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
const status = await getApplicationStatus(app)
|
||||
return {
|
||||
name: app,
|
||||
...status,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ applications: statuses })
|
||||
})
|
||||
```
|
||||
|
||||
## Benefits of GitOps
|
||||
|
||||
### 1. Declarative
|
||||
Todo el estado deseado está en Git, versionado y auditable.
|
||||
|
||||
### 2. Auditabilidad
|
||||
Cada cambio tiene un commit con autor, timestamp y descripción.
|
||||
|
||||
### 3. Rollback Fácil
|
||||
```bash
|
||||
# Rollback to previous version
|
||||
git revert HEAD
|
||||
git push
|
||||
|
||||
# ArgoCD automatically syncs back
|
||||
```
|
||||
|
||||
### 4. Disaster Recovery
|
||||
Cluster destruido? Simplemente:
|
||||
```bash
|
||||
# Reinstall ArgoCD
|
||||
kubectl apply -f argocd-install.yaml
|
||||
|
||||
# Deploy app-of-apps
|
||||
kubectl apply -f app-of-apps.yaml
|
||||
|
||||
# Todo vuelve al estado en Git
|
||||
```
|
||||
|
||||
### 5. Multi-Cluster
|
||||
```yaml
|
||||
# Deploy same app to multiple clusters
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: backend-cluster-2
|
||||
spec:
|
||||
destination:
|
||||
server: https://cluster-2.example.com
|
||||
namespace: control-plane
|
||||
# ... same source
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Separate Repo
|
||||
Mantener GitOps separado del código de aplicación:
|
||||
- **App repo**: Código fuente
|
||||
- **GitOps repo**: Manifests de K8s
|
||||
|
||||
### 2. Environment Branches (Optional)
|
||||
```
|
||||
main → production
|
||||
staging → staging environment
|
||||
dev → dev environment
|
||||
```
|
||||
|
||||
### 3. Secrets Management
|
||||
No commitear secrets en Git. Usar:
|
||||
- **Sealed Secrets**
|
||||
- **External Secrets Operator**
|
||||
- **Vault**
|
||||
|
||||
### 4. Progressive Rollout
|
||||
```yaml
|
||||
# Use Argo Rollouts for canary/blue-green
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Rollout
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
strategy:
|
||||
canary:
|
||||
steps:
|
||||
- setWeight: 20
|
||||
- pause: {duration: 5m}
|
||||
- setWeight: 50
|
||||
- pause: {duration: 5m}
|
||||
- setWeight: 100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Ver estado de aplicación
|
||||
argocd app get backend-production
|
||||
|
||||
# Ver diferencias
|
||||
argocd app diff backend-production
|
||||
|
||||
# Sync manual
|
||||
argocd app sync backend-production
|
||||
|
||||
# Ver logs
|
||||
kubectl logs -n argocd deployment/argocd-application-controller
|
||||
|
||||
# Refresh (fetch latest from Git)
|
||||
argocd app refresh backend-production
|
||||
```
|
||||
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
|
||||
```
|
||||
660
docs/06-deployment/staging-production.md
Normal file
660
docs/06-deployment/staging-production.md
Normal file
@@ -0,0 +1,660 @@
|
||||
# Staging y Production Deployments
|
||||
|
||||
## Flujo de Promoción
|
||||
|
||||
```
|
||||
Tareas Aprobadas
|
||||
↓
|
||||
Merge a Staging
|
||||
↓
|
||||
Deploy Staging
|
||||
↓
|
||||
Tests Automáticos
|
||||
↓
|
||||
Aprobación Manual
|
||||
↓
|
||||
Merge a Production
|
||||
↓
|
||||
Deploy Production
|
||||
```
|
||||
|
||||
## Merge a Staging
|
||||
|
||||
### 1. Agrupar Tareas
|
||||
|
||||
```typescript
|
||||
// api/routes/task-groups.ts
|
||||
router.post('/task-groups', async (req, res) => {
|
||||
const { projectId, taskIds, notes } = req.body
|
||||
|
||||
// Validate all tasks are approved
|
||||
const tasks = await db.query.tasks.findMany({
|
||||
where: inArray(tasks.id, taskIds),
|
||||
})
|
||||
|
||||
const notApproved = tasks.filter((t) => t.state !== 'approved')
|
||||
|
||||
if (notApproved.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'All tasks must be approved',
|
||||
notApproved: notApproved.map((t) => t.id),
|
||||
})
|
||||
}
|
||||
|
||||
// Create task group
|
||||
const groupId = crypto.randomUUID()
|
||||
|
||||
await db.insert(taskGroups).values({
|
||||
id: groupId,
|
||||
projectId,
|
||||
taskIds: JSON.stringify(taskIds),
|
||||
status: 'pending',
|
||||
notes,
|
||||
createdBy: req.user?.id,
|
||||
})
|
||||
|
||||
// Enqueue merge job
|
||||
await enqueueMerge({
|
||||
taskGroupId: groupId,
|
||||
projectId,
|
||||
taskIds,
|
||||
targetBranch: 'staging',
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
taskGroupId: groupId,
|
||||
status: 'pending',
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Merge Worker
|
||||
|
||||
```typescript
|
||||
// services/queue/merge-worker.ts
|
||||
export const mergeWorker = new Worker('merges', async (job) => {
|
||||
const { taskGroupId, projectId, taskIds, targetBranch } = job.data
|
||||
|
||||
logger.info(`Merging tasks to ${targetBranch}:`, taskIds)
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.id, projectId),
|
||||
})
|
||||
|
||||
const tasks = await db.query.tasks.findMany({
|
||||
where: inArray(tasks.id, taskIds),
|
||||
})
|
||||
|
||||
job.updateProgress(10)
|
||||
|
||||
// 1. Clone repo
|
||||
const repoDir = `/tmp/merge-${taskGroupId}`
|
||||
await exec(`git clone ${project.giteaRepoUrl} ${repoDir}`)
|
||||
process.chdir(repoDir)
|
||||
|
||||
// 2. Checkout target branch
|
||||
await exec(`git checkout ${targetBranch}`)
|
||||
|
||||
job.updateProgress(20)
|
||||
|
||||
// 3. Merge each task's branch
|
||||
for (const task of tasks) {
|
||||
if (!task.branchName) {
|
||||
logger.warn(`Task ${task.id} has no branch, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await exec(`git fetch origin ${task.branchName}`)
|
||||
await exec(`git merge origin/${task.branchName} --no-ff -m "Merge task: ${task.title}"`)
|
||||
|
||||
logger.info(`Merged ${task.branchName}`)
|
||||
|
||||
job.updateProgress(20 + (40 / tasks.length))
|
||||
} catch (error) {
|
||||
logger.error(`Failed to merge ${task.branchName}:`, error)
|
||||
|
||||
// Create conflict resolution task
|
||||
await db.update(tasks)
|
||||
.set({ state: 'needs_input' })
|
||||
.where(eq(tasks.id, task.id))
|
||||
|
||||
throw new Error(`Merge conflict in ${task.branchName}`)
|
||||
}
|
||||
}
|
||||
|
||||
job.updateProgress(60)
|
||||
|
||||
// 4. Push to staging
|
||||
await exec(`git push origin ${targetBranch}`)
|
||||
|
||||
job.updateProgress(70)
|
||||
|
||||
// 5. Create staging PR (if using main as production)
|
||||
if (targetBranch === 'staging') {
|
||||
const pr = await giteaClient.createPullRequest(
|
||||
project.giteaOwner,
|
||||
project.giteaRepoName,
|
||||
{
|
||||
title: `Deploy to Production - ${new Date().toISOString().split('T')[0]}`,
|
||||
body: generateStagingPRDescription(tasks),
|
||||
head: 'staging',
|
||||
base: 'main',
|
||||
}
|
||||
)
|
||||
|
||||
await db.update(taskGroups)
|
||||
.set({
|
||||
stagingBranch: 'staging',
|
||||
stagingPrNumber: pr.number,
|
||||
stagingPrUrl: pr.html_url,
|
||||
})
|
||||
.where(eq(taskGroups.id, taskGroupId))
|
||||
}
|
||||
|
||||
job.updateProgress(80)
|
||||
|
||||
// 6. Update tasks
|
||||
for (const task of tasks) {
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'staging',
|
||||
deployedStagingAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, task.id))
|
||||
}
|
||||
|
||||
// 7. Update task group
|
||||
await db.update(taskGroups)
|
||||
.set({ status: 'staging' })
|
||||
.where(eq(taskGroups.id, taskGroupId))
|
||||
|
||||
job.updateProgress(90)
|
||||
|
||||
// 8. Trigger staging deployment
|
||||
await enqueueDeploy({
|
||||
deploymentId: crypto.randomUUID(),
|
||||
projectId,
|
||||
environment: 'staging',
|
||||
branch: 'staging',
|
||||
commitHash: await getLatestCommit(repoDir, 'staging'),
|
||||
})
|
||||
|
||||
job.updateProgress(100)
|
||||
|
||||
logger.info(`Merge completed: ${taskGroupId}`)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
function generateStagingPRDescription(tasks: Task[]) {
|
||||
return `
|
||||
## Tasks Included
|
||||
|
||||
${tasks.map((t) => `- [x] ${t.title} (#${t.id.slice(0, 8)})`).join('\n')}
|
||||
|
||||
## Changes
|
||||
|
||||
${tasks.map((t) => `### ${t.title}\n${t.description}\n`).join('\n')}
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
${tasks.map((t) => `- [ ] Test: ${t.title}`).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated by AiWorker
|
||||
`.trim()
|
||||
}
|
||||
```
|
||||
|
||||
## Staging Deployment
|
||||
|
||||
```yaml
|
||||
# projects/my-app/staging/kustomization.yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: my-app-staging
|
||||
|
||||
bases:
|
||||
- ../base
|
||||
|
||||
images:
|
||||
- name: aiworker/my-app
|
||||
newTag: staging-abc123
|
||||
|
||||
replicas:
|
||||
- name: my-app
|
||||
count: 2
|
||||
|
||||
configMapGenerator:
|
||||
- name: app-config
|
||||
literals:
|
||||
- NODE_ENV=staging
|
||||
- LOG_LEVEL=debug
|
||||
- SENTRY_ENVIRONMENT=staging
|
||||
|
||||
patchesStrategicMerge:
|
||||
- patches.yaml
|
||||
---
|
||||
# projects/my-app/staging/patches.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: staging-db-credentials
|
||||
key: url
|
||||
```
|
||||
|
||||
## Automated Tests en Staging
|
||||
|
||||
```typescript
|
||||
// services/testing/staging-tests.ts
|
||||
export async function runStagingTests(params: {
|
||||
projectId: string
|
||||
stagingUrl: string
|
||||
}) {
|
||||
const { projectId, stagingUrl } = params
|
||||
|
||||
logger.info(`Running staging tests for: ${stagingUrl}`)
|
||||
|
||||
const tests = [
|
||||
testHealthEndpoint,
|
||||
testAuthentication,
|
||||
testCriticalFeatures,
|
||||
testPerformance,
|
||||
]
|
||||
|
||||
const results = []
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
const result = await test(stagingUrl)
|
||||
results.push({ test: test.name, passed: result.passed, details: result })
|
||||
|
||||
if (!result.passed) {
|
||||
logger.error(`Test failed: ${test.name}`, result)
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({ test: test.name, passed: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
const allPassed = results.every((r) => r.passed)
|
||||
|
||||
// Store results
|
||||
await db.insert(testRuns).values({
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
environment: 'staging',
|
||||
results: JSON.stringify(results),
|
||||
passed: allPassed,
|
||||
runAt: new Date(),
|
||||
})
|
||||
|
||||
return { allPassed, results }
|
||||
}
|
||||
|
||||
async function testHealthEndpoint(baseUrl: string) {
|
||||
const response = await fetch(`${baseUrl}/health`)
|
||||
return {
|
||||
passed: response.ok,
|
||||
status: response.status,
|
||||
}
|
||||
}
|
||||
|
||||
async function testAuthentication(baseUrl: string) {
|
||||
// Test login
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'test123',
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
passed: loginResponse.ok,
|
||||
hasToken: !!(await loginResponse.json()).token,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### 1. Aprobación Manual
|
||||
|
||||
```typescript
|
||||
// api/routes/task-groups.ts
|
||||
router.post('/task-groups/:id/approve-production', async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const taskGroup = await db.query.taskGroups.findFirst({
|
||||
where: eq(taskGroups.id, id),
|
||||
})
|
||||
|
||||
if (!taskGroup || taskGroup.status !== 'staging') {
|
||||
return res.status(400).json({ error: 'Task group not ready for production' })
|
||||
}
|
||||
|
||||
// Run final checks
|
||||
const stagingTests = await getLatestTestResults(taskGroup.projectId, 'staging')
|
||||
|
||||
if (!stagingTests?.passed) {
|
||||
return res.status(400).json({ error: 'Staging tests not passing' })
|
||||
}
|
||||
|
||||
// Merge staging to main
|
||||
await enqueueMerge({
|
||||
taskGroupId: id,
|
||||
projectId: taskGroup.projectId,
|
||||
taskIds: JSON.parse(taskGroup.taskIds),
|
||||
targetBranch: 'main',
|
||||
})
|
||||
|
||||
// Update status
|
||||
await db.update(taskGroups)
|
||||
.set({ status: 'production' })
|
||||
.where(eq(taskGroups.id, id))
|
||||
|
||||
res.json({ success: true, status: 'deploying' })
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Production Deployment con Blue-Green
|
||||
|
||||
```typescript
|
||||
// services/deployment/blue-green.ts
|
||||
export async function blueGreenDeploy(params: {
|
||||
projectId: string
|
||||
namespace: string
|
||||
newVersion: string
|
||||
}) {
|
||||
const { projectId, namespace, newVersion } = params
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.id, projectId),
|
||||
})
|
||||
|
||||
logger.info(`Blue-green deployment: ${project.name} → ${newVersion}`)
|
||||
|
||||
// 1. Deploy "green" (new version) alongside "blue" (current)
|
||||
await k8sClient.createDeployment({
|
||||
namespace,
|
||||
name: `${project.name}-green`,
|
||||
image: `${project.dockerImage}:${newVersion}`,
|
||||
replicas: project.replicas,
|
||||
envVars: project.envVars,
|
||||
labels: {
|
||||
app: project.name,
|
||||
version: 'green',
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Wait for green to be ready
|
||||
await k8sClient.waitForDeployment(namespace, `${project.name}-green`, 300)
|
||||
|
||||
// 3. Run smoke tests on green
|
||||
const greenUrl = await k8sClient.getServiceUrl(namespace, `${project.name}-green`)
|
||||
const smokeTests = await runSmokeTests(greenUrl)
|
||||
|
||||
if (!smokeTests.passed) {
|
||||
logger.error('Smoke tests failed on green deployment')
|
||||
throw new Error('Smoke tests failed')
|
||||
}
|
||||
|
||||
// 4. Switch service to point to green
|
||||
await k8sClient.updateServiceSelector(namespace, project.name, {
|
||||
app: project.name,
|
||||
version: 'green',
|
||||
})
|
||||
|
||||
logger.info('Traffic switched to green')
|
||||
|
||||
// 5. Wait 5 minutes for monitoring
|
||||
await sleep(300000)
|
||||
|
||||
// 6. Check error rates
|
||||
const errorRate = await getErrorRate(project.name, 5)
|
||||
|
||||
if (errorRate > 0.01) {
|
||||
// >1% errors
|
||||
logger.error('High error rate detected, rolling back')
|
||||
|
||||
// Rollback: switch service back to blue
|
||||
await k8sClient.updateServiceSelector(namespace, project.name, {
|
||||
app: project.name,
|
||||
version: 'blue',
|
||||
})
|
||||
|
||||
throw new Error('Rollback due to high error rate')
|
||||
}
|
||||
|
||||
// 7. Delete blue (old version)
|
||||
await k8sClient.deleteDeployment(namespace, `${project.name}-blue`)
|
||||
|
||||
// 8. Rename green to blue for next deployment
|
||||
await k8sClient.patchDeployment(namespace, `${project.name}-green`, {
|
||||
metadata: {
|
||||
name: `${project.name}-blue`,
|
||||
labels: { version: 'blue' },
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Blue-green deployment completed successfully')
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Production Deployment con Canary
|
||||
|
||||
```yaml
|
||||
# Using Argo Rollouts
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Rollout
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: my-app-production
|
||||
spec:
|
||||
replicas: 10
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: aiworker/my-app:v1.2.3
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
|
||||
strategy:
|
||||
canary:
|
||||
steps:
|
||||
# 10% of traffic
|
||||
- setWeight: 10
|
||||
- pause: {duration: 5m}
|
||||
|
||||
# Check metrics
|
||||
- analysis:
|
||||
templates:
|
||||
- templateName: error-rate
|
||||
args:
|
||||
- name: service-name
|
||||
value: my-app
|
||||
|
||||
# 50% of traffic
|
||||
- setWeight: 50
|
||||
- pause: {duration: 10m}
|
||||
|
||||
# Full rollout
|
||||
- setWeight: 100
|
||||
---
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: AnalysisTemplate
|
||||
metadata:
|
||||
name: error-rate
|
||||
spec:
|
||||
args:
|
||||
- name: service-name
|
||||
metrics:
|
||||
- name: error-rate
|
||||
interval: 1m
|
||||
successCondition: result < 0.01 # <1% errors
|
||||
provider:
|
||||
prometheus:
|
||||
address: http://prometheus:9090
|
||||
query: |
|
||||
rate(http_requests_total{service="{{args.service-name}}",status=~"5.."}[5m])
|
||||
/
|
||||
rate(http_requests_total{service="{{args.service-name}}"}[5m])
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
```typescript
|
||||
// api/routes/deployments.ts
|
||||
router.post('/deployments/:id/rollback', async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const deployment = await db.query.deployments.findFirst({
|
||||
where: eq(deployments.id, id),
|
||||
})
|
||||
|
||||
if (!deployment || deployment.environment !== 'production') {
|
||||
return res.status(400).json({ error: 'Can only rollback production' })
|
||||
}
|
||||
|
||||
// Find previous successful deployment
|
||||
const previous = await db.query.deployments.findFirst({
|
||||
where: and(
|
||||
eq(deployments.projectId, deployment.projectId),
|
||||
eq(deployments.environment, 'production'),
|
||||
eq(deployments.status, 'completed'),
|
||||
lt(deployments.createdAt, deployment.createdAt)
|
||||
),
|
||||
orderBy: [desc(deployments.createdAt)],
|
||||
})
|
||||
|
||||
if (!previous) {
|
||||
return res.status(400).json({ error: 'No previous deployment found' })
|
||||
}
|
||||
|
||||
logger.warn(`Rolling back to ${previous.commitHash}`)
|
||||
|
||||
// Create rollback deployment
|
||||
const rollbackId = crypto.randomUUID()
|
||||
|
||||
await db.insert(deployments).values({
|
||||
id: rollbackId,
|
||||
projectId: deployment.projectId,
|
||||
environment: 'production',
|
||||
deploymentType: 'rollback',
|
||||
branch: previous.branch,
|
||||
commitHash: previous.commitHash,
|
||||
status: 'pending',
|
||||
triggeredBy: req.user?.id,
|
||||
})
|
||||
|
||||
// Enqueue immediate deployment
|
||||
await enqueueDeploy({
|
||||
deploymentId: rollbackId,
|
||||
projectId: deployment.projectId,
|
||||
environment: 'production',
|
||||
branch: previous.branch,
|
||||
commitHash: previous.commitHash,
|
||||
}, {
|
||||
priority: 1, // Highest priority
|
||||
})
|
||||
|
||||
res.json({
|
||||
rollbackId,
|
||||
rollingBackTo: previous.commitHash,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Monitoring Production
|
||||
|
||||
```typescript
|
||||
// services/monitoring/production-monitor.ts
|
||||
export async function monitorProduction() {
|
||||
const projects = await db.query.projects.findMany()
|
||||
|
||||
for (const project of projects) {
|
||||
const metrics = await getProductionMetrics(project.name)
|
||||
|
||||
// Check error rate
|
||||
if (metrics.errorRate > 0.05) {
|
||||
// >5%
|
||||
await alertTeam({
|
||||
severity: 'critical',
|
||||
message: `High error rate in ${project.name}: ${metrics.errorRate * 100}%`,
|
||||
})
|
||||
}
|
||||
|
||||
// Check response time
|
||||
if (metrics.p95ResponseTime > 1000) {
|
||||
// >1s
|
||||
await alertTeam({
|
||||
severity: 'warning',
|
||||
message: `Slow response time in ${project.name}: ${metrics.p95ResponseTime}ms`,
|
||||
})
|
||||
}
|
||||
|
||||
// Check pod health
|
||||
const pods = await k8sClient.listPods(`${project.k8sNamespace}-prod`)
|
||||
const unhealthy = pods.filter((p) => p.status.phase !== 'Running')
|
||||
|
||||
if (unhealthy.length > 0) {
|
||||
await alertTeam({
|
||||
severity: 'warning',
|
||||
message: `Unhealthy pods in ${project.name}: ${unhealthy.length}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run every minute
|
||||
setInterval(monitorProduction, 60000)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always test in staging first**
|
||||
2. **Automated tests must pass before production**
|
||||
3. **Use blue-green or canary for production**
|
||||
4. **Monitor error rates closely after deployment**
|
||||
5. **Have rollback plan ready**
|
||||
6. **Deploy during low-traffic hours**
|
||||
7. **Notify team before production deployment**
|
||||
8. **Keep previous version running for quick rollback**
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] All tasks tested in preview
|
||||
- [ ] All tasks approved
|
||||
- [ ] Merged to staging
|
||||
- [ ] Staging tests passing
|
||||
- [ ] Database migrations run (if any)
|
||||
- [ ] Team notified
|
||||
- [ ] Monitoring dashboards ready
|
||||
- [ ] Rollback plan documented
|
||||
- [ ] Deploy to production
|
||||
- [ ] Monitor for 30 minutes
|
||||
- [ ] Confirm success or rollback
|
||||
313
docs/CONTAINER-REGISTRY.md
Normal file
313
docs/CONTAINER-REGISTRY.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Gitea Container Registry - Guía de Uso
|
||||
|
||||
El Container Registry de Gitea está habilitado y listo para usar.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Credenciales
|
||||
|
||||
**Registry URL**: `git.fuq.tv`
|
||||
**Usuario**: `admin`
|
||||
**Token**: `7401126cfb56ab2aebba17755bdc968c20768c27`
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Uso con Docker
|
||||
|
||||
### Login
|
||||
|
||||
```bash
|
||||
docker login git.fuq.tv -u admin -p 7401126cfb56ab2aebba17755bdc968c20768c27
|
||||
|
||||
# O de forma segura
|
||||
echo "7401126cfb56ab2aebba17755bdc968c20768c27" | docker login git.fuq.tv -u admin --password-stdin
|
||||
```
|
||||
|
||||
### Formato de Imágenes
|
||||
|
||||
```
|
||||
git.fuq.tv/<owner>/<package-name>:<tag>
|
||||
```
|
||||
|
||||
Ejemplos:
|
||||
- `git.fuq.tv/admin/aiworker-backend:v1.0.0`
|
||||
- `git.fuq.tv/admin/aiworker-frontend:latest`
|
||||
- `git.fuq.tv/aiworker/my-app:v2.1.0`
|
||||
|
||||
### Build y Push
|
||||
|
||||
```bash
|
||||
# 1. Build imagen
|
||||
docker build -t git.fuq.tv/admin/aiworker-backend:v1.0.0 .
|
||||
|
||||
# 2. Push al registry
|
||||
docker push git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
|
||||
# 3. También tag como latest
|
||||
docker tag git.fuq.tv/admin/aiworker-backend:v1.0.0 git.fuq.tv/admin/aiworker-backend:latest
|
||||
docker push git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
### Pull
|
||||
|
||||
```bash
|
||||
docker pull git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ☸️ Uso en Kubernetes
|
||||
|
||||
### Opción 1: Usar ImagePullSecrets (Recomendado)
|
||||
|
||||
El secret ya está creado en los namespaces `control-plane` y `agents`:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: backend
|
||||
image: git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
```
|
||||
|
||||
### Opción 2: Service Account con ImagePullSecrets
|
||||
|
||||
```bash
|
||||
# Patch del service account default
|
||||
kubectl patch serviceaccount default -n control-plane \
|
||||
-p '{"imagePullSecrets": [{"name": "gitea-registry"}]}'
|
||||
|
||||
# Ahora todos los pods usarán automáticamente el secret
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: aiworker-backend
|
||||
namespace: control-plane
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
# No need to specify imagePullSecrets, uses SA default
|
||||
containers:
|
||||
- name: backend
|
||||
image: git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
```
|
||||
|
||||
### Crear Secret en Otros Namespaces
|
||||
|
||||
```bash
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--docker-server=git.fuq.tv \
|
||||
--docker-username=admin \
|
||||
--docker-password=7401126cfb56ab2aebba17755bdc968c20768c27 \
|
||||
-n <namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Ver Packages en Gitea UI
|
||||
|
||||
1. Ve a https://git.fuq.tv
|
||||
2. Login (admin / admin123)
|
||||
3. Click en tu perfil → **Packages**
|
||||
4. Verás todas las imágenes subidas
|
||||
|
||||
---
|
||||
|
||||
## 🚀 CI/CD con Gitea Actions
|
||||
|
||||
### Ejemplo .gitea/workflows/build.yml
|
||||
|
||||
```yaml
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.fuq.tv
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.fuq.tv/admin/aiworker-backend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.fuq.tv/admin/aiworker-backend:buildcache
|
||||
cache-to: type=registry,ref=git.fuq.tv/admin/aiworker-backend:buildcache,mode=max
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔨 Build Manual (sin Docker daemon)
|
||||
|
||||
Si no tienes Docker corriendo localmente, puedes usar **buildah** o **podman**:
|
||||
|
||||
```bash
|
||||
# Con buildah
|
||||
buildah bud -t git.fuq.tv/admin/myapp:v1.0.0 .
|
||||
buildah push git.fuq.tv/admin/myapp:v1.0.0
|
||||
|
||||
# Con podman
|
||||
podman build -t git.fuq.tv/admin/myapp:v1.0.0 .
|
||||
podman push git.fuq.tv/admin/myapp:v1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Ejemplo Completo: Backend de AiWorker
|
||||
|
||||
### Dockerfile
|
||||
```dockerfile
|
||||
FROM oven/bun:1.3.6-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Dependencies
|
||||
COPY package.json bun.lockb ./
|
||||
RUN bun install --production
|
||||
|
||||
# Source
|
||||
COPY src ./src
|
||||
COPY drizzle ./drizzle
|
||||
|
||||
# Run
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "src/index.ts"]
|
||||
```
|
||||
|
||||
### Build y Push
|
||||
```bash
|
||||
# Build
|
||||
docker build -t git.fuq.tv/admin/aiworker-backend:v1.0.0 .
|
||||
|
||||
# Push
|
||||
docker push git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
|
||||
# Tag latest
|
||||
docker tag git.fuq.tv/admin/aiworker-backend:v1.0.0 git.fuq.tv/admin/aiworker-backend:latest
|
||||
docker push git.fuq.tv/admin/aiworker-backend:latest
|
||||
```
|
||||
|
||||
### Deploy en K8s
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: control-plane
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: backend
|
||||
image: git.fuq.tv/admin/aiworker-backend:v1.0.0
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: mariadb.control-plane.svc.cluster.local
|
||||
- name: REDIS_HOST
|
||||
value: redis.control-plane.svc.cluster.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Actualizar Deployment con Nueva Imagen
|
||||
|
||||
```bash
|
||||
# Opción 1: Set image
|
||||
kubectl set image deployment/backend backend=git.fuq.tv/admin/aiworker-backend:v1.1.0 -n control-plane
|
||||
|
||||
# Opción 2: Rollout restart (usa :latest)
|
||||
kubectl rollout restart deployment/backend -n control-plane
|
||||
|
||||
# Ver progreso
|
||||
kubectl rollout status deployment/backend -n control-plane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗑️ Cleanup de Imágenes Viejas
|
||||
|
||||
Desde la UI de Gitea:
|
||||
1. Packages → Select package
|
||||
2. Versions → Delete old versions
|
||||
|
||||
O vía API:
|
||||
```bash
|
||||
curl -X DELETE "https://git.fuq.tv/api/v1/packages/admin/container/aiworker-backend/v1.0.0" \
|
||||
-H "Authorization: token 7401126cfb56ab2aebba17755bdc968c20768c27"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Ventajas del Registry en Gitea
|
||||
|
||||
✅ **Integrado** - Mismo sistema que Git
|
||||
✅ **Autenticación única** - Mismos usuarios
|
||||
✅ **Sin costos extra** - Ya está incluido
|
||||
✅ **Storage HA** - Longhorn con 3 réplicas
|
||||
✅ **TLS automático** - Cert-Manager
|
||||
✅ **Privado** - No público como Docker Hub
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Resumen
|
||||
|
||||
**Registry**: `git.fuq.tv`
|
||||
**Login**: `admin / 7401126cfb56ab2aebba17755bdc968c20768c27`
|
||||
**Formato**: `git.fuq.tv/<owner>/<image>:<tag>`
|
||||
**K8s Secret**: `gitea-registry` (en control-plane y agents)
|
||||
|
||||
**Próximos pasos:**
|
||||
1. Crear Dockerfile para backend
|
||||
2. Build imagen
|
||||
3. Push a `git.fuq.tv/admin/aiworker-backend:v1.0.0`
|
||||
4. Deploy en K8s
|
||||
86
docs/README.md
Normal file
86
docs/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# AiWorker - Documentación
|
||||
|
||||
Sistema de orquestación de agentes IA (Claude Code) para automatización del ciclo completo de desarrollo.
|
||||
|
||||
## Índice de Documentación
|
||||
|
||||
### 01. Arquitectura
|
||||
- [Overview General](./01-arquitectura/overview.md)
|
||||
- [Stack Tecnológico](./01-arquitectura/stack-tecnologico.md)
|
||||
- [Flujo de Datos](./01-arquitectura/flujo-de-datos.md)
|
||||
- [Modelo de Datos](./01-arquitectura/modelo-datos.md)
|
||||
|
||||
### 02. Backend
|
||||
- [Estructura del Proyecto](./02-backend/estructura.md)
|
||||
- [Database Schema (MySQL)](./02-backend/database-schema.md)
|
||||
- [MCP Server](./02-backend/mcp-server.md)
|
||||
- [Integración con Gitea](./02-backend/gitea-integration.md)
|
||||
- [Sistema de Colas](./02-backend/queue-system.md)
|
||||
- [API Endpoints](./02-backend/api-endpoints.md)
|
||||
|
||||
### 03. Frontend
|
||||
- [Estructura del Proyecto](./03-frontend/estructura.md)
|
||||
- [Componentes Principales](./03-frontend/componentes.md)
|
||||
- [Gestión de Estado](./03-frontend/estado.md)
|
||||
- [Kanban Board](./03-frontend/kanban.md)
|
||||
- [Consolas Web](./03-frontend/consolas-web.md)
|
||||
|
||||
### 04. Kubernetes
|
||||
- [Setup del Cluster](./04-kubernetes/cluster-setup.md)
|
||||
- [Estructura de Namespaces](./04-kubernetes/namespaces.md)
|
||||
- [Deployments](./04-kubernetes/deployments.md)
|
||||
- [Gitea en K8s](./04-kubernetes/gitea-deployment.md)
|
||||
- [Networking y Ingress](./04-kubernetes/networking.md)
|
||||
|
||||
### 05. Agentes Claude Code
|
||||
- [Pods de Agentes](./05-agents/claude-code-pods.md)
|
||||
- [Herramientas MCP](./05-agents/mcp-tools.md)
|
||||
- [Comunicación con Backend](./05-agents/comunicacion.md)
|
||||
- [Ciclo de Vida](./05-agents/ciclo-vida.md)
|
||||
|
||||
### 06. Deployment
|
||||
- [CI/CD Pipeline](./06-deployment/ci-cd.md)
|
||||
- [GitOps con ArgoCD](./06-deployment/gitops.md)
|
||||
- [Preview Environments](./06-deployment/preview-envs.md)
|
||||
- [Staging y Producción](./06-deployment/staging-production.md)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
cd backend && bun install
|
||||
cd ../frontend && bun install
|
||||
|
||||
# Iniciar servicios locales (Docker Compose)
|
||||
docker-compose up -d
|
||||
|
||||
# Iniciar backend
|
||||
cd backend && bun run dev
|
||||
|
||||
# Iniciar frontend
|
||||
cd frontend && bun run dev
|
||||
```
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
- **Frontend**: React 19.2 + TailwindCSS + Vite
|
||||
- **Backend**: Bun 1.3.6 + Express + TypeScript
|
||||
- **Database**: MySQL 8.0
|
||||
- **Cache/Queue**: Redis
|
||||
- **Git Server**: Gitea (auto-alojado)
|
||||
- **Orchestration**: Kubernetes
|
||||
- **CI/CD**: ArgoCD + GitHub Actions
|
||||
- **Agents**: Claude Code (Anthropic)
|
||||
|
||||
## Versiones
|
||||
|
||||
- React: 19.2
|
||||
- Bun: 1.3.6
|
||||
- Node: 20+ (para compatibilidad)
|
||||
- MySQL: 8.0
|
||||
- Kubernetes: 1.28+
|
||||
- Gitea: latest
|
||||
|
||||
## Contribución
|
||||
|
||||
Esta es una documentación viva que se actualiza según evoluciona el proyecto.
|
||||
120
k8s-cluster-info.md
Normal file
120
k8s-cluster-info.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# AiWorker Kubernetes HA Cluster
|
||||
**Location**: Houston, Texas (us-hou-1)
|
||||
**Created**: 2026-01-19
|
||||
**K3s Version**: v1.35.0+k3s1
|
||||
**Network**: 10.100.0.0/24 (k8s-cluster-network)
|
||||
|
||||
## Architecture
|
||||
```
|
||||
[Floating IP]
|
||||
|
|
||||
┌────────────┴────────────┐
|
||||
| |
|
||||
[LB-01 (HA)] [LB-02 (HA)]
|
||||
| |
|
||||
└────────────┬────────────┘
|
||||
|
|
||||
┌───────────────┼───────────────┐
|
||||
| | |
|
||||
[CP-01] [CP-02] [CP-03]
|
||||
etcd HA etcd HA etcd HA
|
||||
| | |
|
||||
─────┴───────────────┴───────────────┴─────
|
||||
| | |
|
||||
[Worker-01] [Worker-02] [Worker-03]
|
||||
```
|
||||
|
||||
## Control Plane Nodes (gp.starter: 4 vCPU, 8 GB RAM)
|
||||
| Hostname | Public IP | Private IP | Role |
|
||||
|-------------|-----------------|-------------|------------------|
|
||||
| k8s-cp-01 | 108.165.47.233 | 10.100.0.2 | control-plane,etcd |
|
||||
| k8s-cp-02 | 108.165.47.235 | 10.100.0.3 | control-plane,etcd |
|
||||
| k8s-cp-03 | 108.165.47.215 | 10.100.0.4 | control-plane,etcd |
|
||||
|
||||
## Worker Nodes (gp.small: 8 vCPU, 16 GB RAM)
|
||||
| Hostname | Public IP | Private IP | Role |
|
||||
|----------------|-----------------|-------------|--------|
|
||||
| k8s-worker-01 | 108.165.47.225 | 10.100.0.5 | worker |
|
||||
| k8s-worker-02 | 108.165.47.224 | 10.100.0.6 | worker |
|
||||
| k8s-worker-03 | 108.165.47.222 | 10.100.0.7 | worker |
|
||||
|
||||
## Load Balancers (gp.micro: 2 vCPU, 4 GB RAM)
|
||||
| Hostname | Public IP | Private IP | Purpose |
|
||||
|------------|--------------|-------------|-----------------|
|
||||
| k8s-lb-01 | (pending) | (pending) | HAProxy Primary |
|
||||
| k8s-lb-02 | (pending) | (pending) | HAProxy Backup |
|
||||
|
||||
## K3s Configuration
|
||||
```bash
|
||||
# Token
|
||||
K10e74a5aacfaf4e2e0a291c3b369db8588cf0b9c2590a4d66e04ab960e24fcb4db::server:bc53704a9707d3cd9188af9e558ab50c
|
||||
|
||||
# API Server (via LB - will be configured)
|
||||
https://<floating-ip>:6443
|
||||
|
||||
# Direct access (temporary)
|
||||
https://108.165.47.233:6443
|
||||
```
|
||||
|
||||
## Network Configuration
|
||||
- **Private Network**: 10.100.0.0/24 (eth1)
|
||||
- **Flannel Interface**: eth1
|
||||
- **All internal communication**: via private IPs
|
||||
- **External access**: via public IPs (with firewall)
|
||||
|
||||
## SSH Access
|
||||
```bash
|
||||
# Control Planes
|
||||
ssh root@108.165.47.233 # k8s-cp-01
|
||||
ssh root@108.165.47.235 # k8s-cp-02
|
||||
ssh root@108.165.47.215 # k8s-cp-03
|
||||
|
||||
# Workers
|
||||
ssh root@108.165.47.225 # k8s-worker-01
|
||||
ssh root@108.165.47.224 # k8s-worker-02
|
||||
ssh root@108.165.47.222 # k8s-worker-03
|
||||
```
|
||||
|
||||
## Kubeconfig
|
||||
```bash
|
||||
# Local kubeconfig
|
||||
~/.kube/aiworker-config
|
||||
|
||||
# Usage
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes
|
||||
|
||||
# Or
|
||||
kubectl --kubeconfig ~/.kube/aiworker-config get nodes
|
||||
```
|
||||
|
||||
## Cost Summary
|
||||
| Component | Plan | Qty | Unit Price | Total/Month |
|
||||
|-----------------|------------|-----|------------|-------------|
|
||||
| Control Planes | gp.starter | 3 | $15/mo | $45 |
|
||||
| Workers | gp.small | 3 | $29/mo | $87 |
|
||||
| Load Balancers | gp.micro | 2 | $8/mo | $16 |
|
||||
| **Total** | | 8 | | **$148/mo** |
|
||||
|
||||
## Cluster Health
|
||||
```bash
|
||||
# Check nodes
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Check system pods
|
||||
kubectl get pods -A
|
||||
|
||||
# Check etcd cluster
|
||||
kubectl get endpoints -n kube-system kube-apiserver
|
||||
|
||||
# Cluster info
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
1. ✅ Cluster deployed
|
||||
2. 🔄 Configure load balancers (HAProxy)
|
||||
3. ⏳ Setup floating IP for HA
|
||||
4. ⏳ Install Nginx Ingress Controller
|
||||
5. ⏳ Install Cert-Manager
|
||||
6. ⏳ Deploy AiWorker application
|
||||
39
k8s-cluster-ips.txt
Normal file
39
k8s-cluster-ips.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
# AiWorker Kubernetes Cluster - Houston (us-hou-1)
|
||||
# Created: 2026-01-19
|
||||
# K3s Version: v1.35.0+k3s1
|
||||
|
||||
## Control Plane Nodes (gp.starter: 4 vCPUs, 8 GB RAM)
|
||||
k8s-cp-01 108.165.47.233 10.100.0.2
|
||||
k8s-cp-02 108.165.47.235 10.100.0.3
|
||||
k8s-cp-03 108.165.47.215 10.100.0.4
|
||||
|
||||
## Worker Nodes (gp.small: 8 vCPUs, 16 GB RAM)
|
||||
k8s-worker-01 108.165.47.225 10.100.0.5
|
||||
k8s-worker-02 108.165.47.224 10.100.0.6
|
||||
k8s-worker-03 108.165.47.222 10.100.0.7
|
||||
|
||||
## K3s Configuration
|
||||
K3s Token: K1092431cccd0946506089ed05872a31ea68edf6668b2a9cd325fcab79afd1e62de::server:60a8b6a55b2207ce71dc3e66fd43757c
|
||||
Control Plane API: https://108.165.47.233:6443
|
||||
|
||||
## SSH Access
|
||||
ssh root@108.165.47.233 # k8s-cp-01
|
||||
ssh root@108.165.47.235 # k8s-cp-02
|
||||
ssh root@108.165.47.215 # k8s-cp-03
|
||||
ssh root@108.165.47.225 # k8s-worker-01
|
||||
ssh root@108.165.47.224 # k8s-worker-02
|
||||
ssh root@108.165.47.222 # k8s-worker-03
|
||||
|
||||
## Network
|
||||
Private Network: k8s-cluster-network (10.100.0.0/24)
|
||||
Network ID: 70
|
||||
|
||||
## Kubeconfig
|
||||
Local: ~/.kube/aiworker-config
|
||||
Context: aiworker
|
||||
Command: kubectl --kubeconfig ~/.kube/aiworker-config get nodes
|
||||
|
||||
## Resources & Cost
|
||||
Control Plane: gp.starter x 3 = $45/month
|
||||
Workers: gp.small x 3 = $87/month
|
||||
Total: ~$132/month
|
||||
221
scripts/install-k3s-cluster.sh
Executable file
221
scripts/install-k3s-cluster.sh
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/bin/bash
|
||||
# AiWorker K3s HA Cluster Installation Script
|
||||
# Location: Houston, Texas (us-hou-1)
|
||||
# K3s Version: v1.35.0+k3s1
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}🚀 AiWorker K3s HA Cluster Installation${NC}"
|
||||
echo "========================================"
|
||||
|
||||
# Configuration
|
||||
K3S_VERSION="v1.35.0+k3s1"
|
||||
CONTROL_PLANE_IPS=("108.165.47.233" "108.165.47.235" "108.165.47.215")
|
||||
CONTROL_PLANE_PRIVATE=("10.100.0.2" "10.100.0.3" "10.100.0.4")
|
||||
WORKER_IPS=("108.165.47.225" "108.165.47.224" "108.165.47.222")
|
||||
WORKER_PRIVATE=("10.100.0.5" "10.100.0.6" "10.100.0.7")
|
||||
|
||||
# Step 1: Install first control plane with cluster-init
|
||||
echo -e "\n${YELLOW}Step 1/5: Installing first control plane (HA mode)${NC}"
|
||||
ssh -o StrictHostKeyChecking=no root@${CONTROL_PLANE_IPS[0]} "curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=${K3S_VERSION} \
|
||||
INSTALL_K3S_EXEC='server \
|
||||
--cluster-init \
|
||||
--disable traefik \
|
||||
--disable servicelb \
|
||||
--node-name k8s-cp-01 \
|
||||
--node-ip ${CONTROL_PLANE_PRIVATE[0]} \
|
||||
--flannel-iface eth1 \
|
||||
--tls-san ${CONTROL_PLANE_IPS[0]} \
|
||||
--tls-san ${CONTROL_PLANE_IPS[1]} \
|
||||
--tls-san ${CONTROL_PLANE_IPS[2]} \
|
||||
--tls-san ${CONTROL_PLANE_PRIVATE[0]} \
|
||||
--tls-san ${CONTROL_PLANE_PRIVATE[1]} \
|
||||
--tls-san ${CONTROL_PLANE_PRIVATE[2]}' \
|
||||
sh -"
|
||||
|
||||
echo -e "${GREEN}✓ First control plane installed${NC}"
|
||||
|
||||
# Get K3s token
|
||||
echo -e "\n${YELLOW}Retrieving K3s token...${NC}"
|
||||
K3S_TOKEN=$(ssh root@${CONTROL_PLANE_IPS[0]} "cat /var/lib/rancher/k3s/server/node-token")
|
||||
echo -e "${GREEN}✓ Token retrieved${NC}"
|
||||
|
||||
# Download kubeconfig
|
||||
echo -e "\n${YELLOW}Downloading kubeconfig...${NC}"
|
||||
ssh root@${CONTROL_PLANE_IPS[0]} "cat /etc/rancher/k3s/k3s.yaml" | \
|
||||
sed "s/127.0.0.1/${CONTROL_PLANE_IPS[0]}/g" > ~/.kube/aiworker-config
|
||||
chmod 600 ~/.kube/aiworker-config
|
||||
echo -e "${GREEN}✓ Kubeconfig saved to ~/.kube/aiworker-config${NC}"
|
||||
|
||||
# Wait for first node to be ready
|
||||
echo -e "\n${YELLOW}Waiting for first node to be ready...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Step 2: Join additional control planes
|
||||
echo -e "\n${YELLOW}Step 2/5: Joining additional control planes${NC}"
|
||||
for i in 1 2; do
|
||||
echo " Installing k8s-cp-0$((i+1))..."
|
||||
ssh -o StrictHostKeyChecking=no root@${CONTROL_PLANE_IPS[$i]} "curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=${K3S_VERSION} \
|
||||
K3S_TOKEN='${K3S_TOKEN}' \
|
||||
INSTALL_K3S_EXEC='server \
|
||||
--server https://${CONTROL_PLANE_PRIVATE[0]}:6443 \
|
||||
--disable traefik \
|
||||
--disable servicelb \
|
||||
--node-name k8s-cp-0$((i+1)) \
|
||||
--node-ip ${CONTROL_PLANE_PRIVATE[$i]} \
|
||||
--flannel-iface eth1 \
|
||||
--tls-san ${CONTROL_PLANE_IPS[0]} \
|
||||
--tls-san ${CONTROL_PLANE_IPS[1]} \
|
||||
--tls-san ${CONTROL_PLANE_IPS[2]}' \
|
||||
sh -" &
|
||||
done
|
||||
|
||||
wait
|
||||
echo -e "${GREEN}✓ All control planes installed${NC}"
|
||||
|
||||
sleep 15
|
||||
|
||||
# Step 3: Join worker nodes
|
||||
echo -e "\n${YELLOW}Step 3/5: Joining worker nodes${NC}"
|
||||
for i in 0 1 2; do
|
||||
echo " Installing k8s-worker-0$((i+1))..."
|
||||
ssh -o StrictHostKeyChecking=no root@${WORKER_IPS[$i]} "curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=${K3S_VERSION} \
|
||||
K3S_TOKEN='${K3S_TOKEN}' \
|
||||
K3S_URL='https://${CONTROL_PLANE_PRIVATE[0]}:6443' \
|
||||
INSTALL_K3S_EXEC='--node-name k8s-worker-0$((i+1)) \
|
||||
--node-ip ${WORKER_PRIVATE[$i]} \
|
||||
--flannel-iface eth1' \
|
||||
sh -" &
|
||||
done
|
||||
|
||||
wait
|
||||
echo -e "${GREEN}✓ All workers joined${NC}"
|
||||
|
||||
sleep 15
|
||||
|
||||
# Step 4: Verify cluster
|
||||
echo -e "\n${YELLOW}Step 4/5: Verifying cluster${NC}"
|
||||
export KUBECONFIG=~/.kube/aiworker-config
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# Step 5: Install core components
|
||||
echo -e "\n${YELLOW}Step 5/5: Installing core components${NC}"
|
||||
|
||||
# Nginx Ingress
|
||||
echo " Installing Nginx Ingress Controller..."
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/baremetal/deploy.yaml
|
||||
kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=controller -n ingress-nginx --timeout=300s
|
||||
echo -e "${GREEN}✓ Nginx Ingress installed${NC}"
|
||||
|
||||
# Cert-Manager
|
||||
echo " Installing Cert-Manager..."
|
||||
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml
|
||||
kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=controller -n cert-manager --timeout=300s
|
||||
echo -e "${GREEN}✓ Cert-Manager installed${NC}"
|
||||
|
||||
# Create Let's Encrypt issuers
|
||||
cat <<EOL | kubectl apply -f -
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: hector+aiworker@teamsuqad.io
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-staging
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
email: hector+aiworker@teamsuqad.io
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-staging
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
EOL
|
||||
echo -e "${GREEN}✓ Let's Encrypt issuers created${NC}"
|
||||
|
||||
# Create namespaces
|
||||
cat <<EOL | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: control-plane
|
||||
labels:
|
||||
name: control-plane
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: agents
|
||||
labels:
|
||||
name: agents
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
labels:
|
||||
name: gitea
|
||||
environment: production
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: monitoring
|
||||
labels:
|
||||
name: monitoring
|
||||
environment: production
|
||||
EOL
|
||||
echo -e "${GREEN}✓ Project namespaces created${NC}"
|
||||
|
||||
# ArgoCD
|
||||
echo " Installing ArgoCD..."
|
||||
kubectl create namespace argocd
|
||||
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
|
||||
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=argocd-server -n argocd --timeout=300s
|
||||
echo -e "${GREEN}✓ ArgoCD installed${NC}"
|
||||
|
||||
# Get ArgoCD password
|
||||
ARGOCD_PASSWORD=$(kubectl get secret argocd-initial-admin-secret -n argocd -o jsonpath="{.data.password}" | base64 -d)
|
||||
|
||||
echo -e "\n${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✅ Cluster installation complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "📊 Cluster Status:"
|
||||
kubectl get nodes
|
||||
echo ""
|
||||
echo -e "🔐 Access Information:"
|
||||
echo -e " Kubeconfig: ~/.kube/aiworker-config"
|
||||
echo -e " ArgoCD: https://argocd.fuq.tv"
|
||||
echo -e " Username: admin"
|
||||
echo -e " Password: ${ARGOCD_PASSWORD}"
|
||||
echo ""
|
||||
echo -e "💡 Next steps:"
|
||||
echo -e " 1. Configure DNS: *.fuq.tv → 108.165.47.221, 108.165.47.203"
|
||||
echo -e " 2. Deploy applications via ArgoCD"
|
||||
echo -e " 3. Access ArgoCD at https://argocd.fuq.tv"
|
||||
129
scripts/setup-load-balancers.sh
Executable file
129
scripts/setup-load-balancers.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# Configure HAProxy Load Balancers for AiWorker K3s Cluster
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}🔧 Configuring Load Balancers${NC}"
|
||||
|
||||
LB_IPS=("108.165.47.221" "108.165.47.203")
|
||||
LB_NAMES=("k8s-lb-01" "k8s-lb-02")
|
||||
|
||||
# Get Nginx Ingress NodePort ports
|
||||
echo -e "\n${YELLOW}Getting Nginx Ingress NodePorts...${NC}"
|
||||
HTTP_PORT=$(kubectl --kubeconfig ~/.kube/aiworker-config get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')
|
||||
HTTPS_PORT=$(kubectl --kubeconfig ~/.kube/aiworker-config get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.spec.ports[?(@.port==443)].nodePort}')
|
||||
|
||||
echo " HTTP NodePort: ${HTTP_PORT}"
|
||||
echo " HTTPS NodePort: ${HTTPS_PORT}"
|
||||
|
||||
# Create HAProxy configuration
|
||||
cat > /tmp/haproxy.cfg <<EOF
|
||||
global
|
||||
log /dev/log local0
|
||||
log /dev/log local1 notice
|
||||
chroot /var/lib/haproxy
|
||||
stats socket /run/haproxy/admin.sock mode 660 level admin
|
||||
stats timeout 30s
|
||||
user haproxy
|
||||
group haproxy
|
||||
daemon
|
||||
maxconn 4000
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
option dontlognull
|
||||
timeout connect 5000
|
||||
timeout client 50000
|
||||
timeout server 50000
|
||||
|
||||
# Frontend HTTP (port 80)
|
||||
frontend http_frontend
|
||||
bind *:80
|
||||
mode http
|
||||
option httplog
|
||||
option forwardfor
|
||||
default_backend http_backend
|
||||
|
||||
# Backend HTTP - Workers NodePort ${HTTP_PORT}
|
||||
backend http_backend
|
||||
mode http
|
||||
balance roundrobin
|
||||
option httpchk GET /healthz
|
||||
http-check expect status 200
|
||||
server k8s-worker-01 10.100.0.5:${HTTP_PORT} check
|
||||
server k8s-worker-02 10.100.0.6:${HTTP_PORT} check
|
||||
server k8s-worker-03 10.100.0.7:${HTTP_PORT} check
|
||||
|
||||
# Frontend HTTPS (port 443)
|
||||
frontend https_frontend
|
||||
bind *:443
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend https_backend
|
||||
|
||||
# Backend HTTPS - Workers NodePort ${HTTPS_PORT} (TCP passthrough)
|
||||
backend https_backend
|
||||
mode tcp
|
||||
balance roundrobin
|
||||
option tcp-check
|
||||
server k8s-worker-01 10.100.0.5:${HTTPS_PORT} check
|
||||
server k8s-worker-02 10.100.0.6:${HTTPS_PORT} check
|
||||
server k8s-worker-03 10.100.0.7:${HTTPS_PORT} check
|
||||
|
||||
# Stats interface
|
||||
frontend stats
|
||||
bind *:8404
|
||||
mode http
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 10s
|
||||
stats auth admin:aiworker2026
|
||||
EOF
|
||||
|
||||
# Deploy to both load balancers
|
||||
for i in 0 1; do
|
||||
echo -e "\n${YELLOW}Configuring ${LB_NAMES[$i]}...${NC}"
|
||||
|
||||
# Install HAProxy if not installed
|
||||
ssh root@${LB_IPS[$i]} "which haproxy || (apt update && apt install -y haproxy)"
|
||||
|
||||
# Deploy configuration
|
||||
scp /tmp/haproxy.cfg root@${LB_IPS[$i]}:/etc/haproxy/haproxy.cfg
|
||||
|
||||
# Restart HAProxy
|
||||
ssh root@${LB_IPS[$i]} "systemctl restart haproxy && systemctl enable haproxy"
|
||||
|
||||
# Verify
|
||||
if ssh root@${LB_IPS[$i]} "systemctl is-active haproxy" | grep -q "active"; then
|
||||
echo -e "${GREEN}✓ ${LB_NAMES[$i]} configured and running${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ ${LB_NAMES[$i]} failed to start${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "\n${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✅ Load Balancers configured!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "📊 HAProxy Stats:"
|
||||
echo -e " LB-01: http://108.165.47.221:8404/stats"
|
||||
echo -e " LB-02: http://108.165.47.203:8404/stats"
|
||||
echo -e " Credentials: admin / aiworker2026"
|
||||
echo ""
|
||||
echo -e "🌐 DNS Configuration:"
|
||||
echo -e " *.fuq.tv A 108.165.47.221"
|
||||
echo -e " *.fuq.tv A 108.165.47.203"
|
||||
echo -e " *.r.fuq.tv A 108.165.47.221"
|
||||
echo -e " *.r.fuq.tv A 108.165.47.203"
|
||||
echo ""
|
||||
echo -e "🧪 Test access:"
|
||||
echo -e " curl https://test.fuq.tv"
|
||||
|
||||
rm /tmp/haproxy.cfg
|
||||
Reference in New Issue
Block a user