feat: funciones Go de deploy — systemd, VPS setup, deploy remoto

Nuevas funciones infra para deploy sin Docker: generación de units
systemd (pura), instalación/restart/status de servicios remotos via
SSH, setup inicial de VPS (crear dirs, usuario, permisos), y pipelines
de deploy completo (setup_vps_app, deploy_app_remote). Incluye tipo
DeployConfig con la configuración de deploy por app.
This commit is contained in:
2026-04-12 17:29:52 +02:00
parent 6f6bc714a9
commit a06946e410
16 changed files with 660 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DeployAppRemote orquesta el deploy continuo de una app a un VPS remoto.
// Pasos: verificar SSH → build local → rsync → restart systemd → health check.
func DeployAppRemote(conn SSHConn, cfg DeployConfig) error {
// 1. Verificar conectividad SSH
if err := SSHCheck(conn); err != nil {
return fmt.Errorf("deploy_app_remote: ssh check: %w", err)
}
// 2. Build local (si hay comando de build)
if cfg.BuildCmd != "" {
cmd := exec.Command("bash", "-c", cfg.BuildCmd)
cmd.Dir = cfg.LocalDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("deploy_app_remote: build failed: %s\n%s", err, strings.TrimSpace(string(out)))
}
}
// 3. Subir binario compilado
if err := uploadAppFiles(conn, cfg); err != nil {
return fmt.Errorf("deploy_app_remote: upload: %w", err)
}
// 4. Restart systemd service
if err := SystemdRestart(conn, cfg.AppName); err != nil {
return fmt.Errorf("deploy_app_remote: restart: %w", err)
}
// 5. Health check (si está configurado)
if cfg.HealthPath != "" && cfg.Port > 0 {
url := fmt.Sprintf("http://%s:%d%s", conn.Host, cfg.Port, cfg.HealthPath)
if err := HealthCheckHTTP(url, 30, 2000); err != nil {
return fmt.Errorf("deploy_app_remote: health check: %w", err)
}
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
---
name: deploy_app_remote
kind: pipeline
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DeployAppRemote(conn SSHConn, cfg DeployConfig) error"
description: "Orquesta el deploy continuo de una app a un VPS: verifica SSH, compila localmente, sube binario, reinicia systemd y hace health check."
tags: [deploy, vps, remote, ci, cd, pipeline, infra]
uses_functions: [ssh_check_go_infra, ssh_upload_go_infra, ssh_exec_go_infra, systemd_restart_go_infra, health_check_http_go_infra]
uses_types: [ssh_conn_go_infra, DeployConfig_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
params:
- name: conn
desc: "conexión SSH al VPS destino"
- name: cfg
desc: "configuración de deploy con nombre, rutas, build command, puerto y health path"
output: "nil si el deploy completo fue exitoso"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/deploy_app_remote.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "185.x.x.x", User: "root"}
cfg := DeployConfig{
AppName: "dag_engine",
LocalDir: "apps/dag_engine",
RemoteDir: "/opt/apps/dag_engine",
BinaryName: "dag_engine",
BuildCmd: "CGO_ENABLED=0 GOOS=linux go build -o dag_engine .",
Port: 8080,
HealthPath: "/api/health",
}
err := DeployAppRemote(conn, cfg)
```
## Notas
Pipeline de 5 pasos para deploy continuo (asume que el setup inicial ya se hizo con `setup_vps_app`). El build corre localmente en `LocalDir` con `bash -c BuildCmd`. Si `BuildCmd` está vacío, se salta el build y sube directamente el binario existente.
+14
View File
@@ -0,0 +1,14 @@
package infra
// DeployConfig parametriza un deploy de app a un VPS remoto.
type DeployConfig struct {
AppName string // nombre de la app (usado para systemd unit y logging)
LocalDir string // directorio local de la app (ej: apps/dag_engine)
RemoteDir string // directorio destino en el VPS (ej: /opt/apps/dag_engine)
BinaryName string // nombre del binario compilado (ej: dag_engine)
BuildCmd string // comando de build (ej: CGO_ENABLED=0 GOOS=linux go build -o dag_engine .)
ServiceUser string // usuario del sistema para el servicio (vacío = sin crear)
Port int // puerto del servicio (0 si no expone HTTP)
HealthPath string // path del health check (ej: /api/health, vacío = sin check)
Env map[string]string // variables de entorno para el servicio
}
+61
View File
@@ -0,0 +1,61 @@
package infra
import "fmt"
// SetupVPSApp orquesta el setup inicial de una app en un VPS remoto.
// Pasos: verificar SSH → crear dirs/usuario → rsync código → generar unit → instalar systemd → health check.
func SetupVPSApp(conn SSHConn, cfg DeployConfig) error {
// 1. Verificar conectividad SSH
if err := SSHCheck(conn); err != nil {
return fmt.Errorf("setup_vps_app: ssh check: %w", err)
}
// 2. Preparar directorios y usuario en el VPS
if err := VPSSetupApp(conn, cfg.AppName, cfg.RemoteDir, cfg.ServiceUser); err != nil {
return fmt.Errorf("setup_vps_app: vps setup: %w", err)
}
// 3. Subir binario o archivos de la app
if err := uploadAppFiles(conn, cfg); err != nil {
return fmt.Errorf("setup_vps_app: upload: %w", err)
}
// 4. Generar unit de systemd
execStart := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)
unit := SystemdGenerateUnit(cfg.AppName, execStart, cfg.RemoteDir, cfg.ServiceUser, cfg.Env)
// 5. Instalar y arrancar servicio
if err := SystemdInstall(conn, cfg.AppName, unit); err != nil {
return fmt.Errorf("setup_vps_app: systemd install: %w", err)
}
// 6. Health check (si hay endpoint configurado)
if cfg.HealthPath != "" && cfg.Port > 0 {
url := fmt.Sprintf("http://%s:%d%s", conn.Host, cfg.Port, cfg.HealthPath)
if err := HealthCheckHTTP(url, 30, 2000); err != nil {
return fmt.Errorf("setup_vps_app: health check: %w", err)
}
}
return nil
}
// uploadAppFiles sube el binario compilado al VPS via SCP.
func uploadAppFiles(conn SSHConn, cfg DeployConfig) error {
localBinary := fmt.Sprintf("%s/%s", cfg.LocalDir, cfg.BinaryName)
remoteBinary := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)
if err := SSHUpload(conn, localBinary, remoteBinary); err != nil {
return err
}
// Hacer ejecutable
_, stderr, code, err := SSHExec(conn, fmt.Sprintf("chmod +x %s", remoteBinary))
if err != nil {
return err
}
if code != 0 {
return fmt.Errorf("chmod: %s", stderr)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
---
name: setup_vps_app
kind: pipeline
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SetupVPSApp(conn SSHConn, cfg DeployConfig) error"
description: "Orquesta el setup inicial de una app en un VPS remoto: verifica SSH, crea dirs y usuario, sube binario, instala systemd unit y hace health check."
tags: [deploy, vps, setup, systemd, ssh, pipeline, infra]
uses_functions: [ssh_check_go_infra, vps_setup_app_go_infra, ssh_upload_go_infra, ssh_exec_go_infra, systemd_generate_unit_go_infra, systemd_install_go_infra, health_check_http_go_infra]
uses_types: [ssh_conn_go_infra, DeployConfig_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
params:
- name: conn
desc: "conexión SSH al VPS destino"
- name: cfg
desc: "configuración de deploy con nombre, rutas, build, puerto, env vars"
output: "nil si el setup completo fue exitoso"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/setup_vps_app.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "185.x.x.x", User: "root"}
cfg := DeployConfig{
AppName: "dag_engine",
LocalDir: "apps/dag_engine",
RemoteDir: "/opt/apps/dag_engine",
BinaryName: "dag_engine",
ServiceUser: "deploy",
Port: 8080,
HealthPath: "/api/health",
Env: map[string]string{"PORT": "8080"},
}
err := SetupVPSApp(conn, cfg)
```
## Notas
Pipeline de 6 pasos para primera instalación. Después del setup inicial, usar `deploy_app_remote` para deploys continuos (no regenera dirs ni systemd unit). El health check espera hasta 30 segundos con polling cada 2s.
+46
View File
@@ -0,0 +1,46 @@
package infra
import (
"fmt"
"sort"
"strings"
)
// SystemdGenerateUnit genera el texto de un archivo .service de systemd.
func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string {
var b strings.Builder
b.WriteString("[Unit]\n")
b.WriteString(fmt.Sprintf("Description=%s\n", name))
b.WriteString("After=network.target\n\n")
b.WriteString("[Service]\n")
b.WriteString("Type=simple\n")
b.WriteString(fmt.Sprintf("ExecStart=%s\n", execStart))
if workDir != "" {
b.WriteString(fmt.Sprintf("WorkingDirectory=%s\n", workDir))
}
if user != "" {
b.WriteString(fmt.Sprintf("User=%s\n", user))
}
// Environment vars en orden determinista
if len(env) > 0 {
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(fmt.Sprintf("Environment=%s=%s\n", k, env[k]))
}
}
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()
}
+50
View File
@@ -0,0 +1,50 @@
---
name: systemd_generate_unit
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string"
description: "Genera el texto de un archivo .service de systemd para una app. Incluye restart automático y env vars en orden determinista."
tags: [systemd, unit, service, generate, deploy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [fmt, sort, strings]
params:
- name: name
desc: "nombre del servicio (aparece en Description)"
- name: execStart
desc: "comando completo para arrancar la app (ruta absoluta al binario + args)"
- name: workDir
desc: "directorio de trabajo del servicio (vacío para omitir)"
- name: user
desc: "usuario del sistema bajo el que corre el servicio (vacío para omitir)"
- name: env
desc: "variables de entorno key=value para el servicio"
output: "texto completo del archivo .service listo para escribir a /etc/systemd/system/"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/systemd_generate_unit.go"
---
## Ejemplo
```go
unit := SystemdGenerateUnit(
"dag_engine",
"/opt/apps/dag_engine/dag_engine",
"/opt/apps/dag_engine",
"deploy",
map[string]string{"PORT": "8080", "DB_PATH": "/opt/apps/dag_engine/data/ops.db"},
)
fmt.Println(unit)
```
## Notas
Función pura sin I/O. Las env vars se ordenan alfabéticamente para output determinista. Genera un unit con Restart=on-failure y RestartSec=5.
+34
View File
@@ -0,0 +1,34 @@
package infra
import "fmt"
// SystemdInstall sube un unit file al host remoto, hace daemon-reload, enable y restart.
// Idempotente: si el unit ya existe, lo reemplaza.
func SystemdInstall(conn SSHConn, unitName, unitContent string) error {
// Escribir a archivo temporal y mover a /etc/systemd/system/
tmpPath := fmt.Sprintf("/tmp/%s.service", unitName)
destPath := fmt.Sprintf("/etc/systemd/system/%s.service", unitName)
writeCmd := fmt.Sprintf("cat > %s << 'UNIT_EOF'\n%sUNIT_EOF", tmpPath, unitContent)
_, stderr, code, err := SSHExec(conn, writeCmd)
if err != nil {
return fmt.Errorf("systemd_install: ssh write: %w", err)
}
if code != 0 {
return fmt.Errorf("systemd_install: write unit file: %s", stderr)
}
// Mover a systemd y aplicar
cmds := fmt.Sprintf("sudo mv %s %s && sudo systemctl daemon-reload && sudo systemctl enable %s && sudo systemctl restart %s",
tmpPath, destPath, unitName, unitName)
_, stderr, code, err = SSHExec(conn, cmds)
if err != nil {
return fmt.Errorf("systemd_install: ssh exec: %w", err)
}
if code != 0 {
return fmt.Errorf("systemd_install: %s", stderr)
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
---
name: systemd_install
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SystemdInstall(conn SSHConn, unitName, unitContent string) error"
description: "Sube un unit file al host remoto, hace daemon-reload, enable y restart. Idempotente: reemplaza si el unit ya existe."
tags: [systemd, install, deploy, service, remote]
uses_functions: [ssh_exec_go_infra]
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
params:
- name: conn
desc: "conexión SSH al host remoto"
- name: unitName
desc: "nombre del unit sin extensión (ej: dag_engine)"
- name: unitContent
desc: "contenido completo del archivo .service (generado por SystemdGenerateUnit)"
output: "nil si el unit se instaló y arrancó correctamente"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/systemd_install.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
unit := SystemdGenerateUnit("dag_engine", "/opt/apps/dag_engine/dag_engine", "/opt/apps/dag_engine", "deploy", nil)
err := SystemdInstall(conn, "dag_engine", unit)
```
## Notas
Escribe el unit a un archivo temporal en /tmp y lo mueve con sudo a /etc/systemd/system/. Requiere que el usuario SSH tenga permisos sudo sin password para systemctl y mv a /etc/systemd/system/.
+15
View File
@@ -0,0 +1,15 @@
package infra
import "fmt"
// SystemdRestart reinicia un servicio systemd en un host remoto.
func SystemdRestart(conn SSHConn, unitName string) error {
_, stderr, code, err := SSHExec(conn, fmt.Sprintf("sudo systemctl restart %s", unitName))
if err != nil {
return fmt.Errorf("systemd_restart: ssh exec: %w", err)
}
if code != 0 {
return fmt.Errorf("systemd_restart %s: %s", unitName, stderr)
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
---
name: systemd_restart
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SystemdRestart(conn SSHConn, unitName string) error"
description: "Reinicia un servicio systemd en un host remoto via SSH."
tags: [systemd, restart, service, remote]
uses_functions: [ssh_exec_go_infra]
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
params:
- name: conn
desc: "conexión SSH al host remoto"
- name: unitName
desc: "nombre del unit systemd a reiniciar (sin .service)"
output: "nil si el reinicio fue exitoso"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/systemd_restart.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
if err := SystemdRestart(conn, "dag_engine"); err != nil {
log.Fatal(err)
}
```
## Notas
Usa `sudo systemctl restart`. Requiere que el usuario SSH tenga permisos sudo. Si el servicio no existe, systemctl retorna error.
+57
View File
@@ -0,0 +1,57 @@
package infra
import (
"fmt"
"strings"
)
// SystemdServiceStatus resultado de consultar el estado de un servicio systemd.
type SystemdServiceStatus struct {
Unit string
Active string // "active", "inactive", "failed"
SubState string // "running", "dead", "failed"
MainPID string
Logs string
}
// SystemdStatus consulta el estado de un servicio systemd en un host remoto.
func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error) {
status := SystemdServiceStatus{Unit: unitName}
// Obtener propiedades del servicio
cmd := fmt.Sprintf("systemctl show %s --property=ActiveState,SubState,MainPID --no-pager", unitName)
stdout, stderr, code, err := SSHExec(conn, cmd)
if err != nil {
return status, fmt.Errorf("systemd_status: ssh exec: %w", err)
}
if code != 0 {
return status, fmt.Errorf("systemd_status: systemctl show: %s", stderr)
}
// Parsear propiedades key=value
for _, line := range strings.Split(stdout, "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
switch parts[0] {
case "ActiveState":
status.Active = parts[1]
case "SubState":
status.SubState = parts[1]
case "MainPID":
status.MainPID = parts[1]
}
}
// Obtener logs recientes
if logLines > 0 {
logCmd := fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || true", unitName, logLines)
logOut, _, _, logErr := SSHExec(conn, logCmd)
if logErr == nil {
status.Logs = strings.TrimSpace(logOut)
}
}
return status, nil
}
+45
View File
@@ -0,0 +1,45 @@
---
name: systemd_status
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error)"
description: "Consulta el estado de un servicio systemd en un host remoto. Retorna estado activo, sub-estado, PID y logs recientes."
tags: [systemd, status, monitor, service, remote]
uses_functions: [ssh_exec_go_infra]
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, strings]
params:
- name: conn
desc: "conexión SSH al host remoto"
- name: unitName
desc: "nombre del unit systemd a consultar (sin .service)"
- name: logLines
desc: "número de líneas de journalctl a incluir (0 para no incluir logs)"
output: "SystemdServiceStatus con Active, SubState, MainPID y Logs del servicio"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/systemd_status.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
s, err := SystemdStatus(conn, "dag_engine", 20)
if err != nil {
log.Fatal(err)
}
fmt.Printf("State: %s/%s PID: %s\n", s.Active, s.SubState, s.MainPID)
fmt.Println(s.Logs)
```
## Notas
Usa `systemctl show` para obtener propiedades sin formato humano. Los logs se obtienen con journalctl y son opcionales (logLines=0 los omite). Si journalctl falla (ej: permisos), los logs quedan vacíos sin error.
+42
View File
@@ -0,0 +1,42 @@
package infra
import "fmt"
// VPSSetupApp prepara un host remoto para recibir una app:
// crea directorios, usuario de servicio si no existe, y directorio de datos.
func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error {
// Crear directorio de la app y subdirectorios comunes
mkdirCmd := fmt.Sprintf("sudo mkdir -p %s/data %s/logs", remoteDir, remoteDir)
_, stderr, code, err := SSHExec(conn, mkdirCmd)
if err != nil {
return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
}
if code != 0 {
return fmt.Errorf("vps_setup_app: mkdir: %s", stderr)
}
// Crear usuario de servicio si se especificó y no existe
if serviceUser != "" {
userCmd := fmt.Sprintf("id %s >/dev/null 2>&1 || sudo useradd -r -s /usr/sbin/nologin -d %s %s",
serviceUser, remoteDir, serviceUser)
_, stderr, code, err = SSHExec(conn, userCmd)
if err != nil {
return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
}
if code != 0 {
return fmt.Errorf("vps_setup_app: create user: %s", stderr)
}
// Asignar ownership al usuario de servicio
chownCmd := fmt.Sprintf("sudo chown -R %s:%s %s", serviceUser, serviceUser, remoteDir)
_, stderr, code, err = SSHExec(conn, chownCmd)
if err != nil {
return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
}
if code != 0 {
return fmt.Errorf("vps_setup_app: chown: %s", stderr)
}
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
---
name: vps_setup_app
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error"
description: "Prepara un host remoto para recibir una app: crea directorios, usuario de servicio y asigna ownership."
tags: [vps, setup, deploy, remote, infra]
uses_functions: [ssh_exec_go_infra]
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
params:
- name: conn
desc: "conexión SSH al host remoto"
- name: appName
desc: "nombre de la app (para logging)"
- name: remoteDir
desc: "ruta absoluta donde vivirá la app en el remoto (ej: /opt/apps/dag_engine)"
- name: serviceUser
desc: "usuario del sistema para correr el servicio (vacío para omitir creación de usuario)"
output: "nil si el setup fue exitoso"
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/vps_setup_app.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
err := VPSSetupApp(conn, "dag_engine", "/opt/apps/dag_engine", "deploy")
```
## Notas
Idempotente: mkdir -p no falla si el directorio existe, useradd se salta si el usuario existe. Crea subdirectorios `data/` y `logs/` dentro del remoteDir. Requiere sudo.
+32
View File
@@ -0,0 +1,32 @@
---
name: DeployConfig
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: "type DeployConfig struct { AppName, LocalDir, RemoteDir, BinaryName, BuildCmd, ServiceUser string; Port int; HealthPath string; Env map[string]string }"
description: "Parametriza un deploy de app a un VPS remoto. Agrupa nombre, rutas, build, servicio, puerto, health check y env vars."
tags: [deploy, config, vps, remote, infra]
uses_types: []
file_path: "functions/infra/deploy_config.go"
---
## Ejemplo
```go
cfg := DeployConfig{
AppName: "dag_engine",
LocalDir: "apps/dag_engine",
RemoteDir: "/opt/apps/dag_engine",
BinaryName: "dag_engine",
BuildCmd: "CGO_ENABLED=0 GOOS=linux go build -o dag_engine .",
ServiceUser: "deploy",
Port: 8080,
HealthPath: "/api/health",
Env: map[string]string{"DB_PATH": "/opt/apps/dag_engine/data/ops.db"},
}
```
## Notas
Usado por los pipelines `setup_vps_app` y `deploy_app_remote`. El campo `BuildCmd` se ejecuta localmente con `bash -c` en el directorio `LocalDir`. Si `HealthPath` está vacío o `Port` es 0, se omite el health check.