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