commit 041400b907869945eaa0057be68ca9fa3ab3676b Author: fn-registry agent Date: Tue Apr 28 22:12:20 2026 +0200 chore: sync from fn-registry agent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e468878 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +deploy_server +operations.db +operations.db-shm +operations.db-wal +*.log diff --git a/app.md b/app.md new file mode 100644 index 0000000..c0eeabc --- /dev/null +++ b/app.md @@ -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 en remote_dir +→ build remoto (build_cmd via SSH) → systemctl restart → health check +``` + +### docker-compose + +``` +SSH check → git pull origin 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/ | Directorio en el host remoto donde vive la app | +| `--binary` | | 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/) | +| `--branch` | main | Branch de git para strategies remotas (git pull origin ) | +| `--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//`, extrae `` 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="" +gitea_create_webhook "" "" "http://:9090/webhook/push" "" +``` + +**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 --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 :/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= +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`. diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..29324f5 --- /dev/null +++ b/commands.go @@ -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/)") + 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/)") + 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 ") + 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 [--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 --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 | --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() + } +} diff --git a/deployer.go b/deployer.go new file mode 100644 index 0000000..0e0be1f --- /dev/null +++ b/deployer.go @@ -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, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..896d57f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module deploy_server + +go 1.25.0 + +require github.com/mattn/go-sqlite3 v1.14.24 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9dcdc9b --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b201fd7 --- /dev/null +++ b/main.go @@ -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 [--host HOST] Deploy an app + deploy_server setup --host HOST First-time setup on VPS + deploy_server status |--all Check remote service status`) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..5335e97 --- /dev/null +++ b/server.go @@ -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))) +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..b125932 --- /dev/null +++ b/store.go @@ -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/ + 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() +}