chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-04-28 22:12:20 +02:00
commit 041400b907
9 changed files with 1472 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
deploy_server
operations.db
operations.db-shm
operations.db-wal
*.log
+292
View File
@@ -0,0 +1,292 @@
---
name: deploy_server
lang: go
domain: infra
description: "Servidor de deploy continuo para apps del registry. Recibe webhooks de Gitea, gestiona targets de deploy en operations.db y orquesta deploys a VPS remotos via SSH. Soporta tres estrategias: systemd, systemd-remote y docker-compose."
tags: [service, deploy, ci, cd, webhook, gitea, ssh, vps, docker-compose, systemd]
uses_functions:
- ssh_check_go_infra
- ssh_exec_go_infra
- ssh_upload_go_infra
- rsync_deploy_bash_infra
- systemd_generate_unit_go_infra
- systemd_install_go_infra
- systemd_restart_go_infra
- systemd_status_go_infra
- vps_setup_app_go_infra
- health_check_http_go_infra
- ssh_config_read_go_infra
- ssh_config_find_go_infra
- docker_compose_remote_deploy_bash_infra
uses_types:
- ssh_conn_go_infra
- ssh_config_entry_go_infra
- DeployConfig_go_infra
framework: "net/http"
entry_point: "main.go"
dir_path: "apps/deploy_server"
---
## Estrategias de deploy
Tres estrategias disponibles segun el tipo de app:
| Estrategia | Flujo | Para que |
|---|---|---|
| `systemd` (default) | Build local + rsync + systemctl restart | Apps Go compiladas localmente y subidas al VPS |
| `systemd-remote` | SSH: git pull + build remoto + systemctl restart | Apps Go cuyo source vive en el VPS (build in-situ) |
| `docker-compose` | SSH: git pull + docker compose pull + up -d | Stacks Docker Compose en el VPS |
### systemd (build local + rsync)
```
SSH check → build local (build_cmd en source_dir) → rsync al VPS
→ chmod +x binario → systemctl restart → health check
```
### systemd-remote (build remoto)
```
SSH check → git pull origin <branch> en remote_dir
→ build remoto (build_cmd via SSH) → systemctl restart → health check
```
### docker-compose
```
SSH check → git pull origin <branch> en remote_dir
→ docker compose [-f extra...] pull → docker compose up -d → health check
```
## Uso
```bash
# Servidor (escucha webhooks y expone API)
./deploy_server serve --port 9090
# CLI: gestionar targets de deploy
./deploy_server target add --app my_app --host produccion --port 8080 --health /api/health \
--build "CGO_ENABLED=0 GOOS=linux go build -o my_app ." --strategy systemd
./deploy_server target list
./deploy_server target remove my_app
# Target con strategy systemd-remote (build en el VPS)
./deploy_server target add --app agents_and_robots --host localhost \
--remote-dir /home/ubuntu/CodeProyects/agents_and_robots \
--binary launcher --build "bash build.sh" \
--strategy systemd-remote --branch master
# Target con strategy docker-compose
./deploy_server target add --app element_matrix_chat --host localhost \
--remote-dir /home/ubuntu/CodeProyects/element_matrix_chat \
--strategy docker-compose --branch master \
--compose-files "docker-compose.livekit.yml"
# CLI: deploy manual
./deploy_server deploy my_app # deploy a todos los hosts del target
./deploy_server deploy my_app --host produccion # deploy a un host específico
# CLI: setup inicial de una app en un VPS
./deploy_server setup my_app --host produccion
# CLI: estado de servicios remotos
./deploy_server status my_app # systemd: systemctl status / docker-compose: docker compose ps
./deploy_server status --all
```
### Flags de target add
| Flag | Default | Descripcion |
|---|---|---|
| `--app` | (requerido) | Nombre de la app (debe coincidir con el nombre del repo en Gitea para webhooks) |
| `--host` | (requerido) | Alias SSH de ~/.ssh/config (o `localhost` si deploy_server corre en el mismo host) |
| `--remote-dir` | /opt/apps/<app> | Directorio en el host remoto donde vive la app |
| `--binary` | <app> | Nombre del binario (solo para strategies systemd/systemd-remote) |
| `--build` | "" | Comando de build (local para systemd, remoto para systemd-remote) |
| `--user` | "" | Usuario systemd del servicio |
| `--port` | 0 | Puerto del servicio (para health checks) |
| `--health` | "" | Path del health check (ej: /api/health) |
| `--env` | {} | Variables de entorno como JSON |
| `--strategy` | systemd | Estrategia: `systemd`, `systemd-remote`, `docker-compose` |
| `--source-dir` | "" | Directorio local del source relativo al registry root (override de apps/<app>) |
| `--branch` | main | Branch de git para strategies remotas (git pull origin <branch>) |
| `--compose-files` | "" | Archivos compose adicionales separados por coma (ej: "docker-compose.livekit.yml") |
## API
```
POST /webhook/push — recibe push de Gitea, detecta app afectada, despliega
GET /api/targets — lista todos los targets de deploy
GET /api/targets/:app — detalle de un target
POST /api/deploy/:app — trigger manual de deploy
GET /api/status/:app — estado del servicio remoto (systemctl status o docker compose ps)
GET /api/health — health check del propio deploy_server
GET /api/logs — ultimos 20 deploy logs (todas las apps)
GET /api/logs/:app — ultimos 20 deploy logs de una app
```
## Webhook de Gitea
El endpoint `POST /webhook/push` recibe payloads de Gitea en formato JSON. El matching funciona en dos pasos:
1. **Por paths**: analiza los archivos modificados en los commits. Si algun archivo empieza con `apps/<nombre>/`, extrae `<nombre>` como app afectada.
2. **Fallback por nombre de repo**: si no hay match por paths, compara `repository.name` del payload contra los targets registrados.
El fallback es el mecanismo principal para repos externos (como agents_and_robots o element_matrix_chat) que no viven dentro de un monorepo con estructura `apps/`.
### Seguridad del webhook
- Si `DEPLOY_WEBHOOK_SECRET` esta seteada, se valida el header `X-Gitea-Signature` (HMAC-SHA256).
- Si no esta seteada, se acepta cualquier request (solo para desarrollo).
- El secret debe coincidir exactamente entre la env var y la configuracion del webhook en Gitea.
### Configuracion en Gitea
Para crear un webhook en Gitea que apunte al deploy_server:
```bash
source bash/functions/infra/gitea_create_webhook.sh
export GITEA_URL="https://tu-gitea.ejemplo.com"
export GITEA_TOKEN="<token-api>"
gitea_create_webhook "<owner>" "<repo>" "http://<ip>:9090/webhook/push" "<webhook_secret>"
```
**Gitea en Docker**: si Gitea corre en un container Docker, `127.0.0.1` apunta al container, no al host. Usar la gateway de la red Docker del container de Gitea:
```bash
# Encontrar la gateway
docker inspect <gitea_container> --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}'
# Resultado: 10.0.17.1 (ejemplo)
# URL del webhook: http://10.0.17.1:9090/webhook/push
```
**Gitea ALLOWED_HOST_LIST**: Gitea restringe por defecto las URLs de webhooks. Configurar en `app.ini`:
```ini
[webhook]
ALLOWED_HOST_LIST = *
```
O listar las IPs/subredes permitidas. Reiniciar Gitea tras el cambio.
**Firewall (UFW)**: si el host tiene UFW activo, permitir tráfico desde redes Docker al puerto del deploy_server:
```bash
sudo ufw allow from 10.0.0.0/8 to any port 9090 comment 'deploy_server from Docker'
```
## Despliegue del propio deploy_server
deploy_server usa `github.com/mattn/go-sqlite3` (CGO). Para compilar:
```bash
# Local (Linux nativo o WSL)
CGO_ENABLED=1 go build -o deploy_server .
# Cross-compile para Linux desde otra plataforma
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o deploy_server_linux .
```
### Instalacion en un VPS
```bash
# 1. Subir binario
scp deploy_server_linux <host>:/home/ubuntu/CodeProyects/deploy_server/deploy_server
# 2. Crear systemd unit
sudo tee /etc/systemd/system/deploy_server.service << 'EOF'
[Unit]
Description=deploy_server CI/CD
After=network.target
[Service]
Type=simple
ExecStart=/home/ubuntu/CodeProyects/deploy_server/deploy_server serve --port 9090
WorkingDirectory=/home/ubuntu/CodeProyects/deploy_server
User=ubuntu
Group=ubuntu
Environment=DEPLOY_WEBHOOK_SECRET=<secret>
Environment=PATH=/usr/local/go/bin:/usr/bin:/bin
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# 3. Activar
sudo systemctl daemon-reload && sudo systemctl enable --now deploy_server
# 4. Verificar
curl -s http://127.0.0.1:9090/api/health
```
### SSH a localhost
Cuando deploy_server y las apps estan en el mismo VPS, el deploy usa SSH a `localhost`. Requisitos:
```bash
# Generar key si no existe
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
# Aceptar host key
ssh-keyscan localhost >> ~/.ssh/known_hosts
# Verificar
ssh -o BatchMode=yes localhost true
```
## Despliegue actual: organic-machine.com
deploy_server corre en el VPS organic-machine.com como servicio systemd (`deploy_server.service`), puerto 9090.
### Targets registrados
| App | Estrategia | Host | Remote dir | Branch | Build |
|---|---|---|---|---|---|
| agents_and_robots | systemd-remote | localhost | /home/ubuntu/CodeProyects/agents_and_robots | master | `bash build.sh` |
| element_matrix_chat | docker-compose | localhost | /home/ubuntu/CodeProyects/element_matrix_chat | master | — |
| registry_api | docker-compose | localhost | /opt/fn-registry-build/apps/registry_api | master | docker compose build+up |
### Webhooks activos
Ambos repos en Gitea (`egutierrez/agents_and_robots`, `egutierrez/element_matrix_chat`) tienen webhooks push apuntando a `http://10.0.17.1:9090/webhook/push` (gateway de la red Docker de Gitea).
### Tiempos de deploy medidos
| App | Trigger | Duracion |
|---|---|---|
| agents_and_robots | webhook | ~8.5s (git pull + tests + compile 4 binarios + restart) |
| element_matrix_chat | webhook | ~2.7s (git pull + docker compose pull + up -d) |
### Servicios en el VPS
| Servicio | Tipo | Estado |
|---|---|---|
| deploy_server | systemd (deploy_server.service) | enabled, active, :9090 |
| agents_and_robots | systemd (agents_and_robots.service) | enabled, active (launcher) |
| registry_api | Docker (registry-api container) | running, :8420, HTTPS via Traefik en registry.organic-machine.com |
### Infraestructura relevante
- **Gitea**: corre en Docker (container `gitea-dgg044oo04woo4ggcsws4gk0`), red `10.0.17.0/24`, gateway `10.0.17.1`
- **Coolify**: proxy principal en puertos 80/443/8080, gestiona servicios Docker
- **UFW**: policy DROP, regla `allow from 10.0.0.0/8 to port 9090` para que Docker alcance deploy_server
- **Webhook secret**: guardado en `pass agentes/deploy-webhook-secret`
- **Gitea token**: guardado en `pass agentes/egutierrez-token`
- **Registry API basicAuth**: guardado en `pass registry/basicauth-user` y `pass registry/basicauth-pass`
- **Registry API token**: guardado en `pass registry/api-token`
## Registro en operations.db
Dos tablas:
- **deploy_targets** (PK: app + host): configuracion de cada target con strategy, branch, compose_files, etc.
- **deploy_logs**: un registro por cada deploy con app, host, status (success/failure), trigger (manual/api/webhook), error, duration_ms, started_at.
## Notas
- Los hosts SSH se resuelven via `~/.ssh/config` — el campo `host` en el target es el alias SSH.
- Para strategy `docker-compose`: usa `docker compose` (v2, sin guion). Verificar con `docker compose version`.
- Para strategy `systemd-remote`: el `build_cmd` se ejecuta via SSH en el remote_dir, no localmente.
- El webhook matching por `repository.name` permite que repos externos (no del monorepo) disparen deploys si el nombre del repo coincide con el nombre del target.
- deploy_server no se auto-despliega. Actualizaciones: cross-compile local + scp + `sudo systemctl restart deploy_server`.
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"text/tabwriter"
)
func registryRoot() string {
if r := os.Getenv("FN_REGISTRY_ROOT"); r != "" {
return r
}
// Subir dos niveles desde apps/deploy_server/
exe, _ := os.Executable()
return filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}
func storeDir() string {
// operations.db en el directorio de la app
return "operations.db"
}
func openStoreOrDie() *Store {
s, err := OpenStore(storeDir())
if err != nil {
fmt.Fprintf(os.Stderr, "error opening store: %v\n", err)
os.Exit(1)
}
return s
}
// --- target ---
func cmdTarget(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: deploy_server target add|list|remove")
os.Exit(1)
}
switch args[0] {
case "add":
cmdTargetAdd(args[1:])
case "list":
cmdTargetList()
case "remove":
cmdTargetRemove(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown target subcommand: %s\n", args[0])
os.Exit(1)
}
}
func cmdTargetAdd(args []string) {
fs := flag.NewFlagSet("target add", flag.ExitOnError)
app := fs.String("app", "", "app name (matches apps/ directory or repo name)")
host := fs.String("host", "", "SSH alias from ~/.ssh/config")
remoteDir := fs.String("remote-dir", "", "remote directory (default: /opt/apps/<app>)")
binaryName := fs.String("binary", "", "binary name (default: app name for systemd strategies)")
buildCmd := fs.String("build", "", "build command (local for systemd, remote for systemd-remote)")
serviceUser := fs.String("user", "", "systemd service user")
port := fs.Int("port", 0, "service port")
healthPath := fs.String("health", "", "health check path (e.g. /api/health)")
envJSON := fs.String("env", "{}", "env vars as JSON object")
strategy := fs.String("strategy", "systemd", "deploy strategy: systemd, systemd-remote, docker-compose")
sourceDir := fs.String("source-dir", "", "local source dir relative to registry root (overrides apps/<app>)")
branch := fs.String("branch", "main", "git branch for remote deploys")
composeFiles := fs.String("compose-files", "", "comma-separated extra compose files for docker-compose strategy")
fs.Parse(args)
if *app == "" || *host == "" {
fmt.Fprintln(os.Stderr, "required: --app and --host")
fs.Usage()
os.Exit(1)
}
if *remoteDir == "" {
*remoteDir = "/opt/apps/" + *app
}
if *binaryName == "" && *strategy != "docker-compose" {
*binaryName = *app
}
var env map[string]string
json.Unmarshal([]byte(*envJSON), &env)
s := openStoreOrDie()
defer s.Close()
t := DeployTarget{
App: *app,
Host: *host,
RemoteDir: *remoteDir,
BinaryName: *binaryName,
BuildCmd: *buildCmd,
ServiceUser: *serviceUser,
Port: *port,
HealthPath: *healthPath,
Env: env,
Strategy: *strategy,
SourceDir: *sourceDir,
Branch: *branch,
ComposeFiles: *composeFiles,
}
if err := s.AddTarget(t); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("target added: %s [%s] → %s:%s\n", *app, *strategy, *host, *remoteDir)
}
func cmdTargetList() {
s := openStoreOrDie()
defer s.Close()
targets, err := s.ListTargets()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(targets) == 0 {
fmt.Println("No deploy targets configured.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "APP\tSTRATEGY\tHOST\tREMOTE DIR\tPORT\tHEALTH")
for _, t := range targets {
health := t.HealthPath
if health == "" {
health = "-"
}
strategy := t.Strategy
if strategy == "" {
strategy = "systemd"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", t.App, strategy, t.Host, t.RemoteDir, t.Port, health)
}
w.Flush()
}
func cmdTargetRemove(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: deploy_server target remove <app>")
os.Exit(1)
}
s := openStoreOrDie()
defer s.Close()
if err := s.RemoveTarget(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("target removed: %s\n", args[0])
}
// --- deploy ---
func cmdDeploy(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: deploy_server deploy <app> [--host HOST]")
os.Exit(1)
}
fs := flag.NewFlagSet("deploy", flag.ExitOnError)
host := fs.String("host", "", "deploy to specific host only")
fs.Parse(args[1:])
app := args[0]
s := openStoreOrDie()
defer s.Close()
targets, err := s.GetTargets(app)
if err != nil || len(targets) == 0 {
fmt.Fprintf(os.Stderr, "no deploy targets for %q\n", app)
os.Exit(1)
}
d := NewDeployer(s, registryRoot())
for _, t := range targets {
if *host != "" && t.Host != *host {
continue
}
fmt.Printf("deploying %s → %s:%s\n", t.App, t.Host, t.RemoteDir)
if err := d.Deploy(t, "manual"); err != nil {
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
} else {
fmt.Printf(" OK\n")
}
}
}
// --- setup ---
func cmdSetup(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: deploy_server setup <app> --host HOST")
os.Exit(1)
}
fs := flag.NewFlagSet("setup", flag.ExitOnError)
host := fs.String("host", "", "SSH host alias (required)")
fs.Parse(args[1:])
if *host == "" {
fmt.Fprintln(os.Stderr, "required: --host")
os.Exit(1)
}
app := args[0]
s := openStoreOrDie()
defer s.Close()
targets, err := s.GetTargets(app)
if err != nil || len(targets) == 0 {
fmt.Fprintf(os.Stderr, "no deploy target for %q — add one first with 'target add'\n", app)
os.Exit(1)
}
d := NewDeployer(s, registryRoot())
for _, t := range targets {
if t.Host != *host {
continue
}
fmt.Printf("setting up %s on %s...\n", t.App, t.Host)
if err := d.Setup(t); err != nil {
fmt.Fprintf(os.Stderr, "FAILED: %v\n", err)
os.Exit(1)
}
return
}
fmt.Fprintf(os.Stderr, "no target for %s on host %s\n", app, *host)
os.Exit(1)
}
// --- status ---
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
all := fs.Bool("all", false, "show all targets")
fs.Parse(args)
s := openStoreOrDie()
defer s.Close()
d := NewDeployer(s, registryRoot())
var targets []DeployTarget
if *all {
targets, _ = s.ListTargets()
} else if fs.NArg() > 0 {
targets, _ = s.GetTargets(fs.Arg(0))
} else {
fmt.Fprintln(os.Stderr, "usage: deploy_server status <app> | --all")
os.Exit(1)
}
for _, t := range targets {
fmt.Printf("=== %s @ %s ===\n", t.App, t.Host)
out, err := d.Status(t)
if err != nil {
fmt.Fprintf(os.Stderr, " error: %v\n", err)
} else {
fmt.Println(out)
}
// Últimos 3 deploys
logs, _ := s.RecentLogs(t.App, 3)
if len(logs) > 0 {
fmt.Println(" Recent deploys:")
for _, l := range logs {
errStr := ""
if l.Error != "" {
errStr = " — " + l.Error
}
fmt.Printf(" %s %s %s %dms%s\n", l.StartedAt.Format("2006-01-02 15:04"), l.Trigger, l.Status, l.Duration, errStr)
}
}
fmt.Println()
}
}
+409
View File
@@ -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,
})
}
+5
View File
@@ -0,0 +1,5 @@
module deploy_server
go 1.25.0
require github.com/mattn/go-sqlite3 v1.14.24
+2
View File
@@ -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=
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "serve":
cmdServe(os.Args[2:])
case "target":
cmdTarget(os.Args[2:])
case "deploy":
cmdDeploy(os.Args[2:])
case "setup":
cmdSetup(os.Args[2:])
case "status":
cmdStatus(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`deploy_server — CI/CD server for fn_registry apps
Usage:
deploy_server serve [--port PORT] Start webhook server (default: 9090)
deploy_server target add|list|remove Manage deploy targets
deploy_server deploy <app> [--host HOST] Deploy an app
deploy_server setup <app> --host HOST First-time setup on VPS
deploy_server status <app>|--all Check remote service status`)
}
+223
View File
@@ -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)))
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
// DeployTarget represents a deploy configuration stored in operations.db.
type DeployTarget struct {
App string `json:"app"`
Host string `json:"host"` // SSH alias from ~/.ssh/config
RemoteDir string `json:"remote_dir"`
BinaryName string `json:"binary_name"`
BuildCmd string `json:"build_cmd"`
ServiceUser string `json:"service_user"`
Port int `json:"port"`
HealthPath string `json:"health_path"`
Env map[string]string `json:"env"`
Strategy string `json:"strategy"` // "systemd" (default), "systemd-remote", "docker-compose"
SourceDir string `json:"source_dir"` // override for appDir(); empty = use default apps/<app>
Branch string `json:"branch"` // git branch for remote deploys; default "main"
ComposeFiles string `json:"compose_files"` // comma-separated extra compose files
CreatedAt time.Time `json:"created_at"`
}
// DeployLog records a deploy execution.
type DeployLog struct {
ID int64 `json:"id"`
App string `json:"app"`
Host string `json:"host"`
Status string `json:"status"` // "success", "failure"
Trigger string `json:"trigger"` // "manual", "webhook"
Error string `json:"error"`
Duration int64 `json:"duration_ms"`
StartedAt time.Time `json:"started_at"`
}
// Store manages the deploy_server's SQLite database.
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS deploy_targets (
app TEXT NOT NULL,
host TEXT NOT NULL,
remote_dir TEXT NOT NULL DEFAULT '',
binary_name TEXT NOT NULL DEFAULT '',
build_cmd TEXT NOT NULL DEFAULT '',
service_user TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 0,
health_path TEXT NOT NULL DEFAULT '',
env TEXT NOT NULL DEFAULT '{}',
strategy TEXT NOT NULL DEFAULT 'systemd',
source_dir TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT 'main',
compose_files TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (app, host)
);
CREATE TABLE IF NOT EXISTS deploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app TEXT NOT NULL,
host TEXT NOT NULL,
status TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'manual',
error TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL
);
`)
if err != nil {
return err
}
// Idempotent column additions for existing databases
for _, col := range []string{
"ALTER TABLE deploy_targets ADD COLUMN strategy TEXT NOT NULL DEFAULT 'systemd'",
"ALTER TABLE deploy_targets ADD COLUMN source_dir TEXT NOT NULL DEFAULT ''",
"ALTER TABLE deploy_targets ADD COLUMN branch TEXT NOT NULL DEFAULT 'main'",
"ALTER TABLE deploy_targets ADD COLUMN compose_files TEXT NOT NULL DEFAULT ''",
} {
s.db.Exec(col) // ignore "duplicate column" errors
}
return nil
}
func (s *Store) AddTarget(t DeployTarget) error {
envJSON, _ := json.Marshal(t.Env)
if t.Strategy == "" {
t.Strategy = "systemd"
}
if t.Branch == "" {
t.Branch = "main"
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO deploy_targets (app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.App, t.Host, t.RemoteDir, t.BinaryName, t.BuildCmd, t.ServiceUser, t.Port, t.HealthPath,
string(envJSON), t.Strategy, t.SourceDir, t.Branch, t.ComposeFiles,
time.Now().UTC().Format(time.RFC3339))
return err
}
func (s *Store) RemoveTarget(app string) error {
res, err := s.db.Exec("DELETE FROM deploy_targets WHERE app = ?", app)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("target %q not found", app)
}
return nil
}
const selectTargetCols = "app, host, remote_dir, binary_name, build_cmd, service_user, port, health_path, env, strategy, source_dir, branch, compose_files, created_at"
func (s *Store) GetTargets(app string) ([]DeployTarget, error) {
rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets WHERE app = ?", app)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTargets(rows)
}
func (s *Store) ListTargets() ([]DeployTarget, error) {
rows, err := s.db.Query("SELECT "+selectTargetCols+" FROM deploy_targets ORDER BY app, host")
if err != nil {
return nil, err
}
defer rows.Close()
return scanTargets(rows)
}
func scanTargets(rows *sql.Rows) ([]DeployTarget, error) {
var targets []DeployTarget
for rows.Next() {
var t DeployTarget
var envStr, createdStr string
if err := rows.Scan(&t.App, &t.Host, &t.RemoteDir, &t.BinaryName, &t.BuildCmd, &t.ServiceUser, &t.Port, &t.HealthPath, &envStr, &t.Strategy, &t.SourceDir, &t.Branch, &t.ComposeFiles, &createdStr); err != nil {
return nil, err
}
json.Unmarshal([]byte(envStr), &t.Env)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
targets = append(targets, t)
}
return targets, rows.Err()
}
func (s *Store) LogDeploy(l DeployLog) error {
_, err := s.db.Exec(`
INSERT INTO deploy_logs (app, host, status, trigger, error, duration_ms, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
l.App, l.Host, l.Status, l.Trigger, l.Error, l.Duration, l.StartedAt.UTC().Format(time.RFC3339))
return err
}
func (s *Store) RecentLogs(app string, limit int) ([]DeployLog, error) {
query := "SELECT id, app, host, status, trigger, error, duration_ms, started_at FROM deploy_logs"
var args []any
if app != "" {
query += " WHERE app = ?"
args = append(args, app)
}
query += " ORDER BY started_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []DeployLog
for rows.Next() {
var l DeployLog
var startedStr string
if err := rows.Scan(&l.ID, &l.App, &l.Host, &l.Status, &l.Trigger, &l.Error, &l.Duration, &startedStr); err != nil {
return nil, err
}
l.StartedAt, _ = time.Parse(time.RFC3339, startedStr)
logs = append(logs, l)
}
return logs, rows.Err()
}