410 lines
12 KiB
Go
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,
|
|
})
|
|
}
|