Files
2026-04-28 22:12:20 +02:00

410 lines
12 KiB
Go

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,
})
}