chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
deploy_server
|
||||
operations.db
|
||||
operations.db-shm
|
||||
operations.db-wal
|
||||
*.log
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
name: deploy_server
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "Servidor de deploy continuo para apps del registry. Recibe webhooks de Gitea, gestiona targets de deploy en operations.db y orquesta deploys a VPS remotos via SSH. Soporta tres estrategias: systemd, systemd-remote y docker-compose."
|
||||
tags: [service, deploy, ci, cd, webhook, gitea, ssh, vps, docker-compose, systemd]
|
||||
uses_functions:
|
||||
- ssh_check_go_infra
|
||||
- ssh_exec_go_infra
|
||||
- ssh_upload_go_infra
|
||||
- rsync_deploy_bash_infra
|
||||
- systemd_generate_unit_go_infra
|
||||
- systemd_install_go_infra
|
||||
- systemd_restart_go_infra
|
||||
- systemd_status_go_infra
|
||||
- vps_setup_app_go_infra
|
||||
- health_check_http_go_infra
|
||||
- ssh_config_read_go_infra
|
||||
- ssh_config_find_go_infra
|
||||
- docker_compose_remote_deploy_bash_infra
|
||||
uses_types:
|
||||
- ssh_conn_go_infra
|
||||
- ssh_config_entry_go_infra
|
||||
- DeployConfig_go_infra
|
||||
framework: "net/http"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/deploy_server"
|
||||
---
|
||||
|
||||
## Estrategias de deploy
|
||||
|
||||
Tres estrategias disponibles segun el tipo de app:
|
||||
|
||||
| Estrategia | Flujo | Para que |
|
||||
|---|---|---|
|
||||
| `systemd` (default) | Build local + rsync + systemctl restart | Apps Go compiladas localmente y subidas al VPS |
|
||||
| `systemd-remote` | SSH: git pull + build remoto + systemctl restart | Apps Go cuyo source vive en el VPS (build in-situ) |
|
||||
| `docker-compose` | SSH: git pull + docker compose pull + up -d | Stacks Docker Compose en el VPS |
|
||||
|
||||
### systemd (build local + rsync)
|
||||
|
||||
```
|
||||
SSH check → build local (build_cmd en source_dir) → rsync al VPS
|
||||
→ chmod +x binario → systemctl restart → health check
|
||||
```
|
||||
|
||||
### systemd-remote (build remoto)
|
||||
|
||||
```
|
||||
SSH check → git pull origin <branch> en remote_dir
|
||||
→ build remoto (build_cmd via SSH) → systemctl restart → health check
|
||||
```
|
||||
|
||||
### docker-compose
|
||||
|
||||
```
|
||||
SSH check → git pull origin <branch> en remote_dir
|
||||
→ docker compose [-f extra...] pull → docker compose up -d → health check
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
# Servidor (escucha webhooks y expone API)
|
||||
./deploy_server serve --port 9090
|
||||
|
||||
# CLI: gestionar targets de deploy
|
||||
./deploy_server target add --app my_app --host produccion --port 8080 --health /api/health \
|
||||
--build "CGO_ENABLED=0 GOOS=linux go build -o my_app ." --strategy systemd
|
||||
./deploy_server target list
|
||||
./deploy_server target remove my_app
|
||||
|
||||
# Target con strategy systemd-remote (build en el VPS)
|
||||
./deploy_server target add --app agents_and_robots --host localhost \
|
||||
--remote-dir /home/ubuntu/CodeProyects/agents_and_robots \
|
||||
--binary launcher --build "bash build.sh" \
|
||||
--strategy systemd-remote --branch master
|
||||
|
||||
# Target con strategy docker-compose
|
||||
./deploy_server target add --app element_matrix_chat --host localhost \
|
||||
--remote-dir /home/ubuntu/CodeProyects/element_matrix_chat \
|
||||
--strategy docker-compose --branch master \
|
||||
--compose-files "docker-compose.livekit.yml"
|
||||
|
||||
# CLI: deploy manual
|
||||
./deploy_server deploy my_app # deploy a todos los hosts del target
|
||||
./deploy_server deploy my_app --host produccion # deploy a un host específico
|
||||
|
||||
# CLI: setup inicial de una app en un VPS
|
||||
./deploy_server setup my_app --host produccion
|
||||
|
||||
# CLI: estado de servicios remotos
|
||||
./deploy_server status my_app # systemd: systemctl status / docker-compose: docker compose ps
|
||||
./deploy_server status --all
|
||||
```
|
||||
|
||||
### Flags de target add
|
||||
|
||||
| Flag | Default | Descripcion |
|
||||
|---|---|---|
|
||||
| `--app` | (requerido) | Nombre de la app (debe coincidir con el nombre del repo en Gitea para webhooks) |
|
||||
| `--host` | (requerido) | Alias SSH de ~/.ssh/config (o `localhost` si deploy_server corre en el mismo host) |
|
||||
| `--remote-dir` | /opt/apps/<app> | Directorio en el host remoto donde vive la app |
|
||||
| `--binary` | <app> | Nombre del binario (solo para strategies systemd/systemd-remote) |
|
||||
| `--build` | "" | Comando de build (local para systemd, remoto para systemd-remote) |
|
||||
| `--user` | "" | Usuario systemd del servicio |
|
||||
| `--port` | 0 | Puerto del servicio (para health checks) |
|
||||
| `--health` | "" | Path del health check (ej: /api/health) |
|
||||
| `--env` | {} | Variables de entorno como JSON |
|
||||
| `--strategy` | systemd | Estrategia: `systemd`, `systemd-remote`, `docker-compose` |
|
||||
| `--source-dir` | "" | Directorio local del source relativo al registry root (override de apps/<app>) |
|
||||
| `--branch` | main | Branch de git para strategies remotas (git pull origin <branch>) |
|
||||
| `--compose-files` | "" | Archivos compose adicionales separados por coma (ej: "docker-compose.livekit.yml") |
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /webhook/push — recibe push de Gitea, detecta app afectada, despliega
|
||||
GET /api/targets — lista todos los targets de deploy
|
||||
GET /api/targets/:app — detalle de un target
|
||||
POST /api/deploy/:app — trigger manual de deploy
|
||||
GET /api/status/:app — estado del servicio remoto (systemctl status o docker compose ps)
|
||||
GET /api/health — health check del propio deploy_server
|
||||
GET /api/logs — ultimos 20 deploy logs (todas las apps)
|
||||
GET /api/logs/:app — ultimos 20 deploy logs de una app
|
||||
```
|
||||
|
||||
## Webhook de Gitea
|
||||
|
||||
El endpoint `POST /webhook/push` recibe payloads de Gitea en formato JSON. El matching funciona en dos pasos:
|
||||
|
||||
1. **Por paths**: analiza los archivos modificados en los commits. Si algun archivo empieza con `apps/<nombre>/`, extrae `<nombre>` como app afectada.
|
||||
2. **Fallback por nombre de repo**: si no hay match por paths, compara `repository.name` del payload contra los targets registrados.
|
||||
|
||||
El fallback es el mecanismo principal para repos externos (como agents_and_robots o element_matrix_chat) que no viven dentro de un monorepo con estructura `apps/`.
|
||||
|
||||
### Seguridad del webhook
|
||||
|
||||
- Si `DEPLOY_WEBHOOK_SECRET` esta seteada, se valida el header `X-Gitea-Signature` (HMAC-SHA256).
|
||||
- Si no esta seteada, se acepta cualquier request (solo para desarrollo).
|
||||
- El secret debe coincidir exactamente entre la env var y la configuracion del webhook en Gitea.
|
||||
|
||||
### Configuracion en Gitea
|
||||
|
||||
Para crear un webhook en Gitea que apunte al deploy_server:
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_create_webhook.sh
|
||||
export GITEA_URL="https://tu-gitea.ejemplo.com"
|
||||
export GITEA_TOKEN="<token-api>"
|
||||
gitea_create_webhook "<owner>" "<repo>" "http://<ip>:9090/webhook/push" "<webhook_secret>"
|
||||
```
|
||||
|
||||
**Gitea en Docker**: si Gitea corre en un container Docker, `127.0.0.1` apunta al container, no al host. Usar la gateway de la red Docker del container de Gitea:
|
||||
|
||||
```bash
|
||||
# Encontrar la gateway
|
||||
docker inspect <gitea_container> --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}'
|
||||
# Resultado: 10.0.17.1 (ejemplo)
|
||||
# URL del webhook: http://10.0.17.1:9090/webhook/push
|
||||
```
|
||||
|
||||
**Gitea ALLOWED_HOST_LIST**: Gitea restringe por defecto las URLs de webhooks. Configurar en `app.ini`:
|
||||
|
||||
```ini
|
||||
[webhook]
|
||||
ALLOWED_HOST_LIST = *
|
||||
```
|
||||
|
||||
O listar las IPs/subredes permitidas. Reiniciar Gitea tras el cambio.
|
||||
|
||||
**Firewall (UFW)**: si el host tiene UFW activo, permitir tráfico desde redes Docker al puerto del deploy_server:
|
||||
|
||||
```bash
|
||||
sudo ufw allow from 10.0.0.0/8 to any port 9090 comment 'deploy_server from Docker'
|
||||
```
|
||||
|
||||
## Despliegue del propio deploy_server
|
||||
|
||||
deploy_server usa `github.com/mattn/go-sqlite3` (CGO). Para compilar:
|
||||
|
||||
```bash
|
||||
# Local (Linux nativo o WSL)
|
||||
CGO_ENABLED=1 go build -o deploy_server .
|
||||
|
||||
# Cross-compile para Linux desde otra plataforma
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o deploy_server_linux .
|
||||
```
|
||||
|
||||
### Instalacion en un VPS
|
||||
|
||||
```bash
|
||||
# 1. Subir binario
|
||||
scp deploy_server_linux <host>:/home/ubuntu/CodeProyects/deploy_server/deploy_server
|
||||
|
||||
# 2. Crear systemd unit
|
||||
sudo tee /etc/systemd/system/deploy_server.service << 'EOF'
|
||||
[Unit]
|
||||
Description=deploy_server CI/CD
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/home/ubuntu/CodeProyects/deploy_server/deploy_server serve --port 9090
|
||||
WorkingDirectory=/home/ubuntu/CodeProyects/deploy_server
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
Environment=DEPLOY_WEBHOOK_SECRET=<secret>
|
||||
Environment=PATH=/usr/local/go/bin:/usr/bin:/bin
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 3. Activar
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now deploy_server
|
||||
|
||||
# 4. Verificar
|
||||
curl -s http://127.0.0.1:9090/api/health
|
||||
```
|
||||
|
||||
### SSH a localhost
|
||||
|
||||
Cuando deploy_server y las apps estan en el mismo VPS, el deploy usa SSH a `localhost`. Requisitos:
|
||||
|
||||
```bash
|
||||
# Generar key si no existe
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
|
||||
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
|
||||
# Aceptar host key
|
||||
ssh-keyscan localhost >> ~/.ssh/known_hosts
|
||||
# Verificar
|
||||
ssh -o BatchMode=yes localhost true
|
||||
```
|
||||
|
||||
## Despliegue actual: organic-machine.com
|
||||
|
||||
deploy_server corre en el VPS organic-machine.com como servicio systemd (`deploy_server.service`), puerto 9090.
|
||||
|
||||
### Targets registrados
|
||||
|
||||
| App | Estrategia | Host | Remote dir | Branch | Build |
|
||||
|---|---|---|---|---|---|
|
||||
| agents_and_robots | systemd-remote | localhost | /home/ubuntu/CodeProyects/agents_and_robots | master | `bash build.sh` |
|
||||
| element_matrix_chat | docker-compose | localhost | /home/ubuntu/CodeProyects/element_matrix_chat | master | — |
|
||||
| registry_api | docker-compose | localhost | /opt/fn-registry-build/apps/registry_api | master | docker compose build+up |
|
||||
|
||||
### Webhooks activos
|
||||
|
||||
Ambos repos en Gitea (`egutierrez/agents_and_robots`, `egutierrez/element_matrix_chat`) tienen webhooks push apuntando a `http://10.0.17.1:9090/webhook/push` (gateway de la red Docker de Gitea).
|
||||
|
||||
### Tiempos de deploy medidos
|
||||
|
||||
| App | Trigger | Duracion |
|
||||
|---|---|---|
|
||||
| agents_and_robots | webhook | ~8.5s (git pull + tests + compile 4 binarios + restart) |
|
||||
| element_matrix_chat | webhook | ~2.7s (git pull + docker compose pull + up -d) |
|
||||
|
||||
### Servicios en el VPS
|
||||
|
||||
| Servicio | Tipo | Estado |
|
||||
|---|---|---|
|
||||
| deploy_server | systemd (deploy_server.service) | enabled, active, :9090 |
|
||||
| agents_and_robots | systemd (agents_and_robots.service) | enabled, active (launcher) |
|
||||
| registry_api | Docker (registry-api container) | running, :8420, HTTPS via Traefik en registry.organic-machine.com |
|
||||
|
||||
### Infraestructura relevante
|
||||
|
||||
- **Gitea**: corre en Docker (container `gitea-dgg044oo04woo4ggcsws4gk0`), red `10.0.17.0/24`, gateway `10.0.17.1`
|
||||
- **Coolify**: proxy principal en puertos 80/443/8080, gestiona servicios Docker
|
||||
- **UFW**: policy DROP, regla `allow from 10.0.0.0/8 to port 9090` para que Docker alcance deploy_server
|
||||
- **Webhook secret**: guardado en `pass agentes/deploy-webhook-secret`
|
||||
- **Gitea token**: guardado en `pass agentes/egutierrez-token`
|
||||
- **Registry API basicAuth**: guardado en `pass registry/basicauth-user` y `pass registry/basicauth-pass`
|
||||
- **Registry API token**: guardado en `pass registry/api-token`
|
||||
|
||||
## Registro en operations.db
|
||||
|
||||
Dos tablas:
|
||||
|
||||
- **deploy_targets** (PK: app + host): configuracion de cada target con strategy, branch, compose_files, etc.
|
||||
- **deploy_logs**: un registro por cada deploy con app, host, status (success/failure), trigger (manual/api/webhook), error, duration_ms, started_at.
|
||||
|
||||
## Notas
|
||||
|
||||
- Los hosts SSH se resuelven via `~/.ssh/config` — el campo `host` en el target es el alias SSH.
|
||||
- Para strategy `docker-compose`: usa `docker compose` (v2, sin guion). Verificar con `docker compose version`.
|
||||
- Para strategy `systemd-remote`: el `build_cmd` se ejecuta via SSH en el remote_dir, no localmente.
|
||||
- El webhook matching por `repository.name` permite que repos externos (no del monorepo) disparen deploys si el nombre del repo coincide con el nombre del target.
|
||||
- deploy_server no se auto-despliega. Actualizaciones: cross-compile local + scp + `sudo systemctl restart deploy_server`.
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func registryRoot() string {
|
||||
if r := os.Getenv("FN_REGISTRY_ROOT"); r != "" {
|
||||
return r
|
||||
}
|
||||
// Subir dos niveles desde apps/deploy_server/
|
||||
exe, _ := os.Executable()
|
||||
return filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
||||
}
|
||||
|
||||
func storeDir() string {
|
||||
// operations.db en el directorio de la app
|
||||
return "operations.db"
|
||||
}
|
||||
|
||||
func openStoreOrDie() *Store {
|
||||
s, err := OpenStore(storeDir())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error opening store: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --- target ---
|
||||
|
||||
func cmdTarget(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: deploy_server target add|list|remove")
|
||||
os.Exit(1)
|
||||
}
|
||||
switch args[0] {
|
||||
case "add":
|
||||
cmdTargetAdd(args[1:])
|
||||
case "list":
|
||||
cmdTargetList()
|
||||
case "remove":
|
||||
cmdTargetRemove(args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown target subcommand: %s\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdTargetAdd(args []string) {
|
||||
fs := flag.NewFlagSet("target add", flag.ExitOnError)
|
||||
app := fs.String("app", "", "app name (matches apps/ directory or repo name)")
|
||||
host := fs.String("host", "", "SSH alias from ~/.ssh/config")
|
||||
remoteDir := fs.String("remote-dir", "", "remote directory (default: /opt/apps/<app>)")
|
||||
binaryName := fs.String("binary", "", "binary name (default: app name for systemd strategies)")
|
||||
buildCmd := fs.String("build", "", "build command (local for systemd, remote for systemd-remote)")
|
||||
serviceUser := fs.String("user", "", "systemd service user")
|
||||
port := fs.Int("port", 0, "service port")
|
||||
healthPath := fs.String("health", "", "health check path (e.g. /api/health)")
|
||||
envJSON := fs.String("env", "{}", "env vars as JSON object")
|
||||
strategy := fs.String("strategy", "systemd", "deploy strategy: systemd, systemd-remote, docker-compose")
|
||||
sourceDir := fs.String("source-dir", "", "local source dir relative to registry root (overrides apps/<app>)")
|
||||
branch := fs.String("branch", "main", "git branch for remote deploys")
|
||||
composeFiles := fs.String("compose-files", "", "comma-separated extra compose files for docker-compose strategy")
|
||||
fs.Parse(args)
|
||||
|
||||
if *app == "" || *host == "" {
|
||||
fmt.Fprintln(os.Stderr, "required: --app and --host")
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *remoteDir == "" {
|
||||
*remoteDir = "/opt/apps/" + *app
|
||||
}
|
||||
if *binaryName == "" && *strategy != "docker-compose" {
|
||||
*binaryName = *app
|
||||
}
|
||||
|
||||
var env map[string]string
|
||||
json.Unmarshal([]byte(*envJSON), &env)
|
||||
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
t := DeployTarget{
|
||||
App: *app,
|
||||
Host: *host,
|
||||
RemoteDir: *remoteDir,
|
||||
BinaryName: *binaryName,
|
||||
BuildCmd: *buildCmd,
|
||||
ServiceUser: *serviceUser,
|
||||
Port: *port,
|
||||
HealthPath: *healthPath,
|
||||
Env: env,
|
||||
Strategy: *strategy,
|
||||
SourceDir: *sourceDir,
|
||||
Branch: *branch,
|
||||
ComposeFiles: *composeFiles,
|
||||
}
|
||||
|
||||
if err := s.AddTarget(t); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("target added: %s [%s] → %s:%s\n", *app, *strategy, *host, *remoteDir)
|
||||
}
|
||||
|
||||
func cmdTargetList() {
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
targets, err := s.ListTargets()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
fmt.Println("No deploy targets configured.")
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "APP\tSTRATEGY\tHOST\tREMOTE DIR\tPORT\tHEALTH")
|
||||
for _, t := range targets {
|
||||
health := t.HealthPath
|
||||
if health == "" {
|
||||
health = "-"
|
||||
}
|
||||
strategy := t.Strategy
|
||||
if strategy == "" {
|
||||
strategy = "systemd"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", t.App, strategy, t.Host, t.RemoteDir, t.Port, health)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func cmdTargetRemove(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: deploy_server target remove <app>")
|
||||
os.Exit(1)
|
||||
}
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
if err := s.RemoveTarget(args[0]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("target removed: %s\n", args[0])
|
||||
}
|
||||
|
||||
// --- deploy ---
|
||||
|
||||
func cmdDeploy(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: deploy_server deploy <app> [--host HOST]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("deploy", flag.ExitOnError)
|
||||
host := fs.String("host", "", "deploy to specific host only")
|
||||
fs.Parse(args[1:])
|
||||
|
||||
app := args[0]
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
targets, err := s.GetTargets(app)
|
||||
if err != nil || len(targets) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no deploy targets for %q\n", app)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
d := NewDeployer(s, registryRoot())
|
||||
|
||||
for _, t := range targets {
|
||||
if *host != "" && t.Host != *host {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("deploying %s → %s:%s\n", t.App, t.Host, t.RemoteDir)
|
||||
if err := d.Deploy(t, "manual"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" OK\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- setup ---
|
||||
|
||||
func cmdSetup(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: deploy_server setup <app> --host HOST")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("setup", flag.ExitOnError)
|
||||
host := fs.String("host", "", "SSH host alias (required)")
|
||||
fs.Parse(args[1:])
|
||||
|
||||
if *host == "" {
|
||||
fmt.Fprintln(os.Stderr, "required: --host")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := args[0]
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
targets, err := s.GetTargets(app)
|
||||
if err != nil || len(targets) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no deploy target for %q — add one first with 'target add'\n", app)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
d := NewDeployer(s, registryRoot())
|
||||
|
||||
for _, t := range targets {
|
||||
if t.Host != *host {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("setting up %s on %s...\n", t.App, t.Host)
|
||||
if err := d.Setup(t); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "no target for %s on host %s\n", app, *host)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// --- status ---
|
||||
|
||||
func cmdStatus(args []string) {
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
all := fs.Bool("all", false, "show all targets")
|
||||
fs.Parse(args)
|
||||
|
||||
s := openStoreOrDie()
|
||||
defer s.Close()
|
||||
|
||||
d := NewDeployer(s, registryRoot())
|
||||
|
||||
var targets []DeployTarget
|
||||
if *all {
|
||||
targets, _ = s.ListTargets()
|
||||
} else if fs.NArg() > 0 {
|
||||
targets, _ = s.GetTargets(fs.Arg(0))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "usage: deploy_server status <app> | --all")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, t := range targets {
|
||||
fmt.Printf("=== %s @ %s ===\n", t.App, t.Host)
|
||||
out, err := d.Status(t)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " error: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(out)
|
||||
}
|
||||
|
||||
// Últimos 3 deploys
|
||||
logs, _ := s.RecentLogs(t.App, 3)
|
||||
if len(logs) > 0 {
|
||||
fmt.Println(" Recent deploys:")
|
||||
for _, l := range logs {
|
||||
errStr := ""
|
||||
if l.Error != "" {
|
||||
errStr = " — " + l.Error
|
||||
}
|
||||
fmt.Printf(" %s %s %s %dms%s\n", l.StartedAt.Format("2006-01-02 15:04"), l.Trigger, l.Status, l.Duration, errStr)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Deployer ejecuta deploys usando funciones SSH del registry via CLI.
|
||||
type Deployer struct {
|
||||
store *Store
|
||||
registryRoot string
|
||||
}
|
||||
|
||||
func NewDeployer(store *Store, registryRoot string) *Deployer {
|
||||
return &Deployer{store: store, registryRoot: registryRoot}
|
||||
}
|
||||
|
||||
// Deploy ejecuta el pipeline de deploy para una app en un host específico.
|
||||
func (d *Deployer) Deploy(target DeployTarget, trigger string) error {
|
||||
start := time.Now()
|
||||
|
||||
// 1. Verificar SSH
|
||||
if err := d.sshCheck(target.Host); err != nil {
|
||||
d.logDeploy(target, trigger, start, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Dispatch por strategy
|
||||
var err error
|
||||
switch target.Strategy {
|
||||
case "docker-compose":
|
||||
err = d.deployDockerCompose(target)
|
||||
case "systemd-remote":
|
||||
err = d.deploySystemdRemote(target)
|
||||
default: // "systemd"
|
||||
err = d.deploySystemd(target)
|
||||
}
|
||||
if err != nil {
|
||||
d.logDeploy(target, trigger, start, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Health check (si configurado, común a todas las strategies)
|
||||
if target.HealthPath != "" && target.Port > 0 {
|
||||
url := fmt.Sprintf("http://%s:%d%s", target.Host, target.Port, target.HealthPath)
|
||||
if err := d.healthCheck(url); err != nil {
|
||||
d.logDeploy(target, trigger, start, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.logDeploy(target, trigger, start, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deploySystemd ejecuta el flujo original: build local → rsync → systemctl restart.
|
||||
func (d *Deployer) deploySystemd(target DeployTarget) error {
|
||||
if target.BuildCmd != "" {
|
||||
appDir := d.appDir(target)
|
||||
if err := d.buildLocal(appDir, target.BuildCmd); err != nil {
|
||||
return fmt.Errorf("build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
appDir := d.appDir(target)
|
||||
if err := d.rsyncDeploy(appDir, target.Host, target.RemoteDir); err != nil {
|
||||
return fmt.Errorf("rsync: %w", err)
|
||||
}
|
||||
|
||||
if target.BinaryName != "" {
|
||||
chmodCmd := fmt.Sprintf("chmod +x %s/%s", target.RemoteDir, target.BinaryName)
|
||||
if err := d.sshExec(target.Host, chmodCmd); err != nil {
|
||||
return fmt.Errorf("chmod: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.sshExec(target.Host, fmt.Sprintf("sudo systemctl restart %s", target.App)); err != nil {
|
||||
return fmt.Errorf("systemctl restart: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deploySystemdRemote ejecuta: git pull → build remoto → systemctl restart.
|
||||
// Para apps cuyo source vive en el host remoto (no se usa rsync).
|
||||
func (d *Deployer) deploySystemdRemote(target DeployTarget) error {
|
||||
branch := target.Branch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
// git pull
|
||||
fmt.Printf(" git pull origin %s in %s\n", branch, target.RemoteDir)
|
||||
pullCmd := fmt.Sprintf("cd '%s' && git pull origin '%s'", target.RemoteDir, branch)
|
||||
if err := d.sshExec(target.Host, pullCmd); err != nil {
|
||||
return fmt.Errorf("git pull: %w", err)
|
||||
}
|
||||
|
||||
// Build remoto
|
||||
if target.BuildCmd != "" {
|
||||
fmt.Printf(" remote build: %s\n", target.BuildCmd)
|
||||
buildCmd := fmt.Sprintf("cd '%s' && %s", target.RemoteDir, target.BuildCmd)
|
||||
if err := d.sshExec(target.Host, buildCmd); err != nil {
|
||||
return fmt.Errorf("remote build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restart systemd
|
||||
fmt.Printf(" systemctl restart %s\n", target.App)
|
||||
if err := d.sshExec(target.Host, fmt.Sprintf("sudo systemctl restart %s", target.App)); err != nil {
|
||||
return fmt.Errorf("systemctl restart: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deployDockerCompose ejecuta: git pull → docker compose pull → docker compose up -d.
|
||||
func (d *Deployer) deployDockerCompose(target DeployTarget) error {
|
||||
branch := target.Branch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
// git pull
|
||||
fmt.Printf(" git pull origin %s in %s\n", branch, target.RemoteDir)
|
||||
pullCmd := fmt.Sprintf("cd '%s' && git pull origin '%s'", target.RemoteDir, branch)
|
||||
if err := d.sshExec(target.Host, pullCmd); err != nil {
|
||||
return fmt.Errorf("git pull: %w", err)
|
||||
}
|
||||
|
||||
// Build compose args
|
||||
composeArgs := "-f docker-compose.yml"
|
||||
if target.ComposeFiles != "" {
|
||||
for _, f := range strings.Split(target.ComposeFiles, ",") {
|
||||
f = strings.TrimSpace(f)
|
||||
if f != "" {
|
||||
composeArgs += " -f " + f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docker compose pull
|
||||
fmt.Printf(" docker compose %s pull\n", composeArgs)
|
||||
pullImgCmd := fmt.Sprintf("cd '%s' && docker compose %s pull", target.RemoteDir, composeArgs)
|
||||
if err := d.sshExec(target.Host, pullImgCmd); err != nil {
|
||||
return fmt.Errorf("docker compose pull: %w", err)
|
||||
}
|
||||
|
||||
// docker compose up -d
|
||||
fmt.Printf(" docker compose %s up -d\n", composeArgs)
|
||||
upCmd := fmt.Sprintf("cd '%s' && docker compose %s up -d", target.RemoteDir, composeArgs)
|
||||
if err := d.sshExec(target.Host, upCmd); err != nil {
|
||||
return fmt.Errorf("docker compose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setup ejecuta el setup inicial de una app en un VPS.
|
||||
func (d *Deployer) Setup(target DeployTarget) error {
|
||||
start := time.Now()
|
||||
|
||||
// 1. Verificar SSH
|
||||
if err := d.sshCheck(target.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch target.Strategy {
|
||||
case "docker-compose":
|
||||
return d.setupDockerCompose(target, start)
|
||||
case "systemd-remote":
|
||||
return d.setupSystemdRemote(target, start)
|
||||
default:
|
||||
return d.setupSystemd(target, start)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Deployer) setupSystemd(target DeployTarget, start time.Time) error {
|
||||
// Crear directorios y usuario
|
||||
mkdirCmd := fmt.Sprintf("sudo mkdir -p %s/data %s/logs", target.RemoteDir, target.RemoteDir)
|
||||
if err := d.sshExec(target.Host, mkdirCmd); err != nil {
|
||||
return fmt.Errorf("setup mkdir: %w", err)
|
||||
}
|
||||
|
||||
if target.ServiceUser != "" {
|
||||
userCmd := fmt.Sprintf("id %s >/dev/null 2>&1 || sudo useradd -r -s /usr/sbin/nologin -d %s %s",
|
||||
target.ServiceUser, target.RemoteDir, target.ServiceUser)
|
||||
if err := d.sshExec(target.Host, userCmd); err != nil {
|
||||
return fmt.Errorf("setup user: %w", err)
|
||||
}
|
||||
chownCmd := fmt.Sprintf("sudo chown -R %s:%s %s", target.ServiceUser, target.ServiceUser, target.RemoteDir)
|
||||
if err := d.sshExec(target.Host, chownCmd); err != nil {
|
||||
return fmt.Errorf("setup chown: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build + rsync
|
||||
if target.BuildCmd != "" {
|
||||
appDir := d.appDir(target)
|
||||
if err := d.buildLocal(appDir, target.BuildCmd); err != nil {
|
||||
return fmt.Errorf("setup build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
appDir := d.appDir(target)
|
||||
if err := d.rsyncDeploy(appDir, target.Host, target.RemoteDir); err != nil {
|
||||
return fmt.Errorf("setup rsync: %w", err)
|
||||
}
|
||||
|
||||
// Generar e instalar systemd unit
|
||||
if err := d.installUnit(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Health check
|
||||
if target.HealthPath != "" && target.Port > 0 {
|
||||
url := fmt.Sprintf("http://%s:%d%s", target.Host, target.Port, target.HealthPath)
|
||||
if err := d.healthCheck(url); err != nil {
|
||||
return fmt.Errorf("setup health check: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("setup complete: %s on %s (%s)\n", target.App, target.Host, time.Since(start).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Deployer) setupSystemdRemote(target DeployTarget, start time.Time) error {
|
||||
// Verificar que el directorio remoto existe y es un repo git
|
||||
checkCmd := fmt.Sprintf("test -d '%s/.git'", target.RemoteDir)
|
||||
if err := d.sshExec(target.Host, checkCmd); err != nil {
|
||||
return fmt.Errorf("remote dir %s is not a git repo: %w", target.RemoteDir, err)
|
||||
}
|
||||
|
||||
// Build inicial si hay comando
|
||||
if target.BuildCmd != "" {
|
||||
fmt.Printf(" initial remote build: %s\n", target.BuildCmd)
|
||||
buildCmd := fmt.Sprintf("cd '%s' && %s", target.RemoteDir, target.BuildCmd)
|
||||
if err := d.sshExec(target.Host, buildCmd); err != nil {
|
||||
return fmt.Errorf("setup remote build: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Instalar systemd unit
|
||||
if err := d.installUnit(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("setup complete: %s on %s (%s)\n", target.App, target.Host, time.Since(start).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Deployer) setupDockerCompose(target DeployTarget, start time.Time) error {
|
||||
// Verificar que el directorio remoto existe con docker-compose.yml
|
||||
checkCmd := fmt.Sprintf("test -f '%s/docker-compose.yml'", target.RemoteDir)
|
||||
if err := d.sshExec(target.Host, checkCmd); err != nil {
|
||||
return fmt.Errorf("remote dir %s has no docker-compose.yml: %w", target.RemoteDir, err)
|
||||
}
|
||||
|
||||
// Verificar docker compose disponible
|
||||
if err := d.sshExec(target.Host, "docker compose version"); err != nil {
|
||||
return fmt.Errorf("docker compose not available on %s: %w", target.Host, err)
|
||||
}
|
||||
|
||||
fmt.Printf("setup complete (docker-compose): %s on %s (%s)\n", target.App, target.Host, time.Since(start).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Deployer) installUnit(target DeployTarget) error {
|
||||
unitContent := d.generateUnit(target)
|
||||
tmpPath := fmt.Sprintf("/tmp/%s.service", target.App)
|
||||
destPath := fmt.Sprintf("/etc/systemd/system/%s.service", target.App)
|
||||
|
||||
writeCmd := fmt.Sprintf("cat > %s << 'UNIT_EOF'\n%sUNIT_EOF", tmpPath, unitContent)
|
||||
if err := d.sshExec(target.Host, writeCmd); err != nil {
|
||||
return fmt.Errorf("setup write unit: %w", err)
|
||||
}
|
||||
|
||||
installCmd := fmt.Sprintf("sudo mv %s %s && sudo systemctl daemon-reload && sudo systemctl enable %s && sudo systemctl start %s",
|
||||
tmpPath, destPath, target.App, target.App)
|
||||
if err := d.sshExec(target.Host, installCmd); err != nil {
|
||||
return fmt.Errorf("setup systemd install: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status consulta el estado de un servicio remoto.
|
||||
func (d *Deployer) Status(target DeployTarget) (string, error) {
|
||||
var cmd string
|
||||
switch target.Strategy {
|
||||
case "docker-compose":
|
||||
cmd = fmt.Sprintf("cd '%s' && docker compose ps 2>&1", target.RemoteDir)
|
||||
default:
|
||||
cmd = fmt.Sprintf("systemctl status %s --no-pager 2>&1 || true", target.App)
|
||||
}
|
||||
return d.sshExecOutput(target.Host, cmd)
|
||||
}
|
||||
|
||||
// --- helpers internos ---
|
||||
|
||||
func (d *Deployer) appDir(target DeployTarget) string {
|
||||
if target.SourceDir != "" {
|
||||
return filepath.Join(d.registryRoot, target.SourceDir)
|
||||
}
|
||||
return filepath.Join(d.registryRoot, "apps", target.App)
|
||||
}
|
||||
|
||||
func (d *Deployer) sshCheck(host string) error {
|
||||
cmd := exec.Command("ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host, "true")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ssh check %s: %s %s", host, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Deployer) sshExec(host, command string) error {
|
||||
cmd := exec.Command("ssh", "-o", "BatchMode=yes", host, command)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (d *Deployer) sshExecOutput(host, command string) (string, error) {
|
||||
out, err := exec.Command("ssh", "-o", "BatchMode=yes", host, command).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (d *Deployer) buildLocal(appDir, buildCmd string) error {
|
||||
fmt.Printf(" build: %s\n", buildCmd)
|
||||
cmd := exec.Command("bash", "-c", buildCmd)
|
||||
cmd.Dir = appDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (d *Deployer) rsyncDeploy(localDir, host, remoteDir string) error {
|
||||
fmt.Printf(" rsync: %s → %s:%s\n", localDir, host, remoteDir)
|
||||
args := []string{
|
||||
"-avz", "--delete",
|
||||
"--exclude=.git",
|
||||
"--exclude=operations.db*",
|
||||
"--exclude=*.exe",
|
||||
"--exclude=node_modules",
|
||||
"--exclude=.venv",
|
||||
"--exclude=__pycache__",
|
||||
"--exclude=build/",
|
||||
"--exclude=*.db-shm",
|
||||
"--exclude=*.db-wal",
|
||||
"--exclude=registry.db",
|
||||
"-e", "ssh",
|
||||
localDir + "/",
|
||||
fmt.Sprintf("%s:%s/", host, remoteDir),
|
||||
}
|
||||
cmd := exec.Command("rsync", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (d *Deployer) healthCheck(url string) error {
|
||||
fmt.Printf(" health check: %s\n", url)
|
||||
// Usa curl con retry — disponible en cualquier sistema
|
||||
cmd := exec.Command("curl", "-sf", "--retry", "10", "--retry-delay", "2", "--retry-connrefused", url)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("health check %s failed: %s", url, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Deployer) generateUnit(target DeployTarget) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Unit]\n")
|
||||
b.WriteString(fmt.Sprintf("Description=%s\n", target.App))
|
||||
b.WriteString("After=network.target\n\n")
|
||||
b.WriteString("[Service]\n")
|
||||
b.WriteString("Type=simple\n")
|
||||
b.WriteString(fmt.Sprintf("ExecStart=%s/%s\n", target.RemoteDir, target.BinaryName))
|
||||
b.WriteString(fmt.Sprintf("WorkingDirectory=%s\n", target.RemoteDir))
|
||||
if target.ServiceUser != "" {
|
||||
b.WriteString(fmt.Sprintf("User=%s\n", target.ServiceUser))
|
||||
}
|
||||
for k, v := range target.Env {
|
||||
b.WriteString(fmt.Sprintf("Environment=%s=%s\n", k, v))
|
||||
}
|
||||
b.WriteString("Restart=on-failure\n")
|
||||
b.WriteString("RestartSec=5\n\n")
|
||||
b.WriteString("[Install]\n")
|
||||
b.WriteString("WantedBy=multi-user.target\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (d *Deployer) logDeploy(target DeployTarget, trigger string, start time.Time, deployErr error) {
|
||||
status := "success"
|
||||
errMsg := ""
|
||||
if deployErr != nil {
|
||||
status = "failure"
|
||||
errMsg = deployErr.Error()
|
||||
}
|
||||
d.store.LogDeploy(DeployLog{
|
||||
App: target.App,
|
||||
Host: target.Host,
|
||||
Status: status,
|
||||
Trigger: trigger,
|
||||
Error: errMsg,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
StartedAt: start,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module deploy_server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.24
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
cmdServe(os.Args[2:])
|
||||
case "target":
|
||||
cmdTarget(os.Args[2:])
|
||||
case "deploy":
|
||||
cmdDeploy(os.Args[2:])
|
||||
case "setup":
|
||||
cmdSetup(os.Args[2:])
|
||||
case "status":
|
||||
cmdStatus(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`deploy_server — CI/CD server for fn_registry apps
|
||||
|
||||
Usage:
|
||||
deploy_server serve [--port PORT] Start webhook server (default: 9090)
|
||||
deploy_server target add|list|remove Manage deploy targets
|
||||
deploy_server deploy <app> [--host HOST] Deploy an app
|
||||
deploy_server setup <app> --host HOST First-time setup on VPS
|
||||
deploy_server status <app>|--all Check remote service status`)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cmdServe(args []string) {
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := fs.Int("port", 9090, "listen port")
|
||||
fs.Parse(args)
|
||||
|
||||
s := openStoreOrDie()
|
||||
// No defer close — server runs forever
|
||||
|
||||
d := NewDeployer(s, registryRoot())
|
||||
srv := &Server{store: s, deployer: d}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /webhook/push", srv.handleWebhook)
|
||||
mux.HandleFunc("GET /api/targets", srv.handleListTargets)
|
||||
mux.HandleFunc("GET /api/targets/{app}", srv.handleGetTarget)
|
||||
mux.HandleFunc("POST /api/deploy/{app}", srv.handleDeploy)
|
||||
mux.HandleFunc("GET /api/status/{app}", srv.handleStatus)
|
||||
mux.HandleFunc("GET /api/health", srv.handleHealth)
|
||||
mux.HandleFunc("GET /api/logs", srv.handleLogs)
|
||||
mux.HandleFunc("GET /api/logs/{app}", srv.handleLogs)
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
fmt.Printf("deploy_server listening on %s\n", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store *Store
|
||||
deployer *Deployer
|
||||
}
|
||||
|
||||
// giteaPushPayload es el subset del payload de Gitea que nos interesa.
|
||||
type giteaPushPayload struct {
|
||||
Ref string `json:"ref"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
} `json:"repository"`
|
||||
Commits []struct {
|
||||
Modified []string `json:"modified"`
|
||||
Added []string `json:"added"`
|
||||
Removed []string `json:"removed"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validar signature si hay secret configurado
|
||||
secret := os.Getenv("DEPLOY_WEBHOOK_SECRET")
|
||||
if secret != "" {
|
||||
sig := r.Header.Get("X-Gitea-Signature")
|
||||
if !verifySignature(body, sig, secret) {
|
||||
http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var payload giteaPushPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
http.Error(w, "parse payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Detectar qué apps fueron afectadas por los cambios
|
||||
apps := s.detectAffectedApps(payload)
|
||||
if len(apps) == 0 {
|
||||
// Intentar match por nombre de repo
|
||||
targets, _ := s.store.GetTargets(payload.Repository.Name)
|
||||
if len(targets) > 0 {
|
||||
apps = append(apps, payload.Repository.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "no matching targets"})
|
||||
return
|
||||
}
|
||||
|
||||
// Deploy cada app afectada
|
||||
results := make(map[string]string)
|
||||
for _, app := range apps {
|
||||
targets, _ := s.store.GetTargets(app)
|
||||
for _, t := range targets {
|
||||
if err := s.deployer.Deploy(t, "webhook"); err != nil {
|
||||
results[app+"@"+t.Host] = "failed: " + err.Error()
|
||||
} else {
|
||||
results[app+"@"+t.Host] = "deployed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
func (s *Server) detectAffectedApps(payload giteaPushPayload) []string {
|
||||
seen := make(map[string]bool)
|
||||
var apps []string
|
||||
|
||||
for _, commit := range payload.Commits {
|
||||
allFiles := append(append(commit.Modified, commit.Added...), commit.Removed...)
|
||||
for _, f := range allFiles {
|
||||
// Detectar apps/ paths
|
||||
if strings.HasPrefix(f, "apps/") {
|
||||
parts := strings.SplitN(f, "/", 3)
|
||||
if len(parts) >= 2 && !seen[parts[1]] {
|
||||
seen[parts[1]] = true
|
||||
apps = append(apps, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
func (s *Server) handleListTargets(w http.ResponseWriter, r *http.Request) {
|
||||
targets, err := s.store.ListTargets()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(targets)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTarget(w http.ResponseWriter, r *http.Request) {
|
||||
app := r.PathValue("app")
|
||||
targets, err := s.store.GetTargets(app)
|
||||
if err != nil || len(targets) == 0 {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(targets)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
app := r.PathValue("app")
|
||||
targets, err := s.store.GetTargets(app)
|
||||
if err != nil || len(targets) == 0 {
|
||||
http.Error(w, "no targets for "+app, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
results := make(map[string]string)
|
||||
for _, t := range targets {
|
||||
if err := s.deployer.Deploy(t, "api"); err != nil {
|
||||
results[t.Host] = "failed: " + err.Error()
|
||||
} else {
|
||||
results[t.Host] = "deployed"
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
app := r.PathValue("app")
|
||||
targets, _ := s.store.GetTargets(app)
|
||||
|
||||
type statusResult struct {
|
||||
Host string `json:"host"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
var results []statusResult
|
||||
for _, t := range targets {
|
||||
out, _ := s.deployer.Status(t)
|
||||
results = append(results, statusResult{Host: t.Host, Status: out})
|
||||
}
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, `{"status":"ok"}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
app := r.PathValue("app")
|
||||
logs, err := s.store.RecentLogs(app, 20)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
func verifySignature(body []byte, signature, secret string) bool {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// registryRootFromExe intenta derivar la raíz del registry desde el ejecutable.
|
||||
func registryRootFromExe() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
// apps/deploy_server/deploy_server → raíz es ../../
|
||||
return filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DeployTarget represents a deploy configuration stored in operations.db.
|
||||
type DeployTarget struct {
|
||||
App string `json:"app"`
|
||||
Host string `json:"host"` // SSH alias from ~/.ssh/config
|
||||
RemoteDir string `json:"remote_dir"`
|
||||
BinaryName string `json:"binary_name"`
|
||||
BuildCmd string `json:"build_cmd"`
|
||||
ServiceUser string `json:"service_user"`
|
||||
Port int `json:"port"`
|
||||
HealthPath string `json:"health_path"`
|
||||
Env map[string]string `json:"env"`
|
||||
Strategy string `json:"strategy"` // "systemd" (default), "systemd-remote", "docker-compose"
|
||||
SourceDir string `json:"source_dir"` // override for appDir(); empty = use default apps/<app>
|
||||
Branch string `json:"branch"` // git branch for remote deploys; default "main"
|
||||
ComposeFiles string `json:"compose_files"` // comma-separated extra compose files
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DeployLog records a deploy execution.
|
||||
type DeployLog struct {
|
||||
ID int64 `json:"id"`
|
||||
App string `json:"app"`
|
||||
Host string `json:"host"`
|
||||
Status string `json:"status"` // "success", "failure"
|
||||
Trigger string `json:"trigger"` // "manual", "webhook"
|
||||
Error string `json:"error"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// Store manages the deploy_server's SQLite database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS deploy_targets (
|
||||
app TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
remote_dir TEXT NOT NULL DEFAULT '',
|
||||
binary_name TEXT NOT NULL DEFAULT '',
|
||||
build_cmd TEXT NOT NULL DEFAULT '',
|
||||
service_user TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
health_path TEXT NOT NULL DEFAULT '',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
strategy TEXT NOT NULL DEFAULT 'systemd',
|
||||
source_dir TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
compose_files TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (app, host)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deploy_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Idempotent column additions for existing databases
|
||||
for _, col := range []string{
|
||||
"ALTER TABLE deploy_targets ADD COLUMN strategy TEXT NOT NULL DEFAULT 'systemd'",
|
||||
"ALTER TABLE deploy_targets ADD COLUMN source_dir TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE deploy_targets ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'",
|
||||
"ALTER TABLE deploy_targets ADD COLUMN compose_files TEXT NOT NULL DEFAULT ''",
|
||||
} {
|
||||
s.db.Exec(col) // ignore "duplicate column" errors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddTarget(t DeployTarget) error {
|
||||
envJSON, _ := json.Marshal(t.Env)
|
||||
if t.Strategy == "" {
|
||||
t.Strategy = "systemd"
|
||||
}
|
||||
if t.Branch == "" {
|
||||
t.Branch = "main"
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO deploy_targets (app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.App, t.Host, t.RemoteDir, t.BinaryName, t.BuildCmd, t.ServiceUser, t.Port, t.HealthPath,
|
||||
string(envJSON), t.Strategy, t.SourceDir, t.Branch, t.ComposeFiles,
|
||||
time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveTarget(app string) error {
|
||||
res, err := s.db.Exec("DELETE FROM deploy_targets WHERE app = ?", app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("target %q not found", app)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const selectTargetCols = "app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at"
|
||||
|
||||
func (s *Store) GetTargets(app string) ([]DeployTarget, error) {
|
||||
rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets WHERE app = ?", app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTargets(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListTargets() ([]DeployTarget, error) {
|
||||
rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets ORDER BY app, host")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTargets(rows)
|
||||
}
|
||||
|
||||
func scanTargets(rows *sql.Rows) ([]DeployTarget, error) {
|
||||
var targets []DeployTarget
|
||||
for rows.Next() {
|
||||
var t DeployTarget
|
||||
var envStr, createdStr string
|
||||
if err := rows.Scan(&t.App, &t.Host, &t.RemoteDir, &t.BinaryName, &t.BuildCmd, &t.ServiceUser, &t.Port, &t.HealthPath, &envStr, &t.Strategy, &t.SourceDir, &t.Branch, &t.ComposeFiles, &createdStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(envStr), &t.Env)
|
||||
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
targets = append(targets, t)
|
||||
}
|
||||
return targets, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) LogDeploy(l DeployLog) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO deploy_logs (app, host, status, trigger, error, duration_ms, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.App, l.Host, l.Status, l.Trigger, l.Error, l.Duration, l.StartedAt.UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecentLogs(app string, limit int) ([]DeployLog, error) {
|
||||
query := "SELECT id, app, host, status, trigger, error, duration_ms, started_at FROM deploy_logs"
|
||||
var args []any
|
||||
if app != "" {
|
||||
query += " WHERE app = ?"
|
||||
args = append(args, app)
|
||||
}
|
||||
query += " ORDER BY started_at DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []DeployLog
|
||||
for rows.Next() {
|
||||
var l DeployLog
|
||||
var startedStr string
|
||||
if err := rows.Scan(&l.ID, &l.App, &l.Host, &l.Status, &l.Trigger, &l.Error, &l.Duration, &startedStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.StartedAt, _ = time.Parse(time.RFC3339, startedStr)
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user