From 041400b907869945eaa0057be68ca9fa3ab3676b Mon Sep 17 00:00:00 2001 From: fn-registry agent Date: Tue, 28 Apr 2026 22:12:20 +0200 Subject: [PATCH] chore: sync from fn-registry agent --- .gitignore | 5 + app.md | 292 +++++++++++++++++++++++++++++++++++++ commands.go | 285 ++++++++++++++++++++++++++++++++++++ deployer.go | 409 ++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 43 ++++++ server.go | 223 ++++++++++++++++++++++++++++ store.go | 208 ++++++++++++++++++++++++++ 9 files changed, 1472 insertions(+) create mode 100644 .gitignore create mode 100644 app.md create mode 100644 commands.go create mode 100644 deployer.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 server.go create mode 100644 store.go 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() +}