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