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