306 lines
8.3 KiB
Go
306 lines
8.3 KiB
Go
package deployer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// LineCallback is called for each line of deploy output.
|
|
type LineCallback func(line string)
|
|
|
|
// FileMapping maps a local file path to a remote server path.
|
|
type FileMapping struct {
|
|
Local string `json:"local"`
|
|
Remote string `json:"remote"`
|
|
}
|
|
|
|
// Config holds SSH connection parameters.
|
|
type Config struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
AuthMethod string // "key" or "password"
|
|
SSHKey string // private key content (PEM)
|
|
Password string
|
|
}
|
|
|
|
// DeployResult holds the outcome of a deployment.
|
|
type DeployResult struct {
|
|
Success bool
|
|
Output string
|
|
}
|
|
|
|
// Service handles SSH deployment operations.
|
|
type Service struct{}
|
|
|
|
// New creates a new deployer Service.
|
|
func New() *Service {
|
|
return &Service{}
|
|
}
|
|
|
|
// Deploy performs a full deployment: connect, pre-commands, upload files, post-commands, health check.
|
|
// Output is streamed via the onLine callback.
|
|
func (s *Service) Deploy(ctx context.Context, cfg *Config, deployDir string,
|
|
mappings []FileMapping, preCmd, postCmd, healthCheckURL string,
|
|
healthCheckTimeout int, onLine LineCallback) (*DeployResult, error) {
|
|
|
|
log := func(format string, args ...any) {
|
|
onLine(fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
log("=== Deploy started at %s ===", time.Now().Format(time.RFC3339))
|
|
log("Target: %s@%s:%d", cfg.Username, cfg.Host, cfg.Port)
|
|
log("Deploy directory: %s", deployDir)
|
|
log("Auth method: %s", cfg.AuthMethod)
|
|
|
|
// 1. Connect to SSH server
|
|
client, err := s.dial(cfg)
|
|
if err != nil {
|
|
log("ERROR: SSH connection failed: %v", err)
|
|
return &DeployResult{Success: false, Output: fmt.Sprintf("SSH connection failed: %v", err)}, err
|
|
}
|
|
defer client.Close()
|
|
log("SSH connection established")
|
|
|
|
// 2. Ensure remote deploy directory exists
|
|
if err := s.runCommand(client, fmt.Sprintf("mkdir -p %s", deployDir), onLine); err != nil {
|
|
log("ERROR: Failed to create deploy directory: %v", err)
|
|
return &DeployResult{Success: false, Output: fmt.Sprintf("mkdir failed: %v", err)}, err
|
|
}
|
|
log("Remote directory ensured: %s", deployDir)
|
|
|
|
// 3. Execute pre-deploy commands
|
|
if preCmd != "" {
|
|
log("=== Executing pre-deploy command ===")
|
|
log("$ %s", preCmd)
|
|
if err := s.runCommand(client, preCmd, onLine); err != nil {
|
|
log("ERROR: Pre-deploy command failed: %v", err)
|
|
return &DeployResult{Success: false, Output: fmt.Sprintf("pre-deploy failed: %v", err)}, err
|
|
}
|
|
log("Pre-deploy command completed")
|
|
}
|
|
|
|
// 4. Upload files
|
|
if len(mappings) > 0 {
|
|
log("=== Uploading %d file(s) ===", len(mappings))
|
|
}
|
|
for _, m := range mappings {
|
|
if err := s.uploadFile(client, m.Local, m.Remote, onLine); err != nil {
|
|
log("ERROR: Failed to upload %s: %v", m.Local, err)
|
|
return &DeployResult{Success: false, Output: fmt.Sprintf("upload %s failed: %v", m.Local, err)}, err
|
|
}
|
|
}
|
|
|
|
// 5. Execute post-deploy commands
|
|
if postCmd != "" {
|
|
log("=== Executing post-deploy command ===")
|
|
log("$ %s", postCmd)
|
|
if err := s.runCommand(client, postCmd, onLine); err != nil {
|
|
log("ERROR: Post-deploy command failed: %v", err)
|
|
return &DeployResult{Success: false, Output: fmt.Sprintf("post-deploy failed: %v", err)}, err
|
|
}
|
|
log("Post-deploy command completed")
|
|
}
|
|
|
|
// 6. Health check
|
|
if healthCheckURL != "" {
|
|
log("=== Health check ===")
|
|
log("Checking %s (timeout: %ds)", healthCheckURL, healthCheckTimeout)
|
|
|
|
timeout := time.Duration(healthCheckTimeout) * time.Second
|
|
if err := s.checkHealth(healthCheckURL, timeout); err != nil {
|
|
log("WARNING: Health check failed: %v", err)
|
|
// Health check failure is a warning, not a deploy failure
|
|
} else {
|
|
log("Health check passed")
|
|
}
|
|
}
|
|
|
|
log("=== Deploy completed successfully at %s ===", time.Now().Format(time.RFC3339))
|
|
return &DeployResult{Success: true, Output: "Deployment succeeded"}, nil
|
|
}
|
|
|
|
// dial establishes an SSH connection.
|
|
func (s *Service) dial(cfg *Config) (*ssh.Client, error) {
|
|
auth, err := s.makeAuth(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("make auth: %w", err)
|
|
}
|
|
|
|
sshCfg := &ssh.ClientConfig{
|
|
User: cfg.Username,
|
|
Auth: []ssh.AuthMethod{auth},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
|
return ssh.Dial("tcp", addr, sshCfg)
|
|
}
|
|
|
|
// makeAuth builds the ssh.AuthMethod from config.
|
|
func (s *Service) makeAuth(cfg *Config) (ssh.AuthMethod, error) {
|
|
switch cfg.AuthMethod {
|
|
case "password":
|
|
return ssh.Password(cfg.Password), nil
|
|
case "key":
|
|
// Try to parse the private key
|
|
key, err := ssh.ParsePrivateKey([]byte(cfg.SSHKey))
|
|
if err != nil {
|
|
// Try parsing with passphrase (empty passphrase)
|
|
key, err2 := ssh.ParsePrivateKeyWithPassphrase([]byte(cfg.SSHKey), []byte{})
|
|
if err2 != nil {
|
|
return nil, fmt.Errorf("parse private key: %w (original: %v)", err2, err)
|
|
}
|
|
return ssh.PublicKeys(key), nil
|
|
}
|
|
return ssh.PublicKeys(key), nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported auth method: %s", cfg.AuthMethod)
|
|
}
|
|
}
|
|
|
|
// runCommand executes a command on the remote host and streams output line by line.
|
|
func (s *Service) runCommand(client *ssh.Client, cmd string, onLine LineCallback) error {
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return fmt.Errorf("new session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
// Combine stdout and stderr
|
|
stdout, err := session.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := session.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := session.Start(cmd); err != nil {
|
|
return fmt.Errorf("start command: %w", err)
|
|
}
|
|
|
|
// Stream stdout lines
|
|
go streamLines(stdout, onLine)
|
|
// Stream stderr lines
|
|
go streamLines(stderr, onLine)
|
|
|
|
err = session.Wait()
|
|
if err != nil {
|
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
|
return fmt.Errorf("command exited with %d", exitErr.ExitStatus())
|
|
}
|
|
return fmt.Errorf("command error: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uploadFile copies a local file to a remote path via SSH session.
|
|
// Uses "cat > remotePath" which is universally available on Linux.
|
|
func (s *Service) uploadFile(client *ssh.Client, localPath, remotePath string, onLine LineCallback) error {
|
|
// Get file info
|
|
info, err := os.Stat(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("stat local file: %w", err)
|
|
}
|
|
|
|
fileSize := info.Size()
|
|
onLine(fmt.Sprintf("Uploading %s (%d bytes) -> %s", localPath, fileSize, remotePath))
|
|
|
|
// Open local file
|
|
f, err := os.Open(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open local file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return fmt.Errorf("new session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
// Pipe file content to cat > remotePath on the server
|
|
session.Stdin = f
|
|
|
|
// Capture stderr for error reporting
|
|
stderr, err := session.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
// Also create parent directories if needed
|
|
cmd := fmt.Sprintf("mkdir -p $(dirname %s) && cat > %s", remotePath, remotePath)
|
|
if err := session.Start(cmd); err != nil {
|
|
return fmt.Errorf("start upload: %w", err)
|
|
}
|
|
|
|
// Read stderr in background
|
|
errBuf := make([]byte, 4096)
|
|
n, _ := stderr.Read(errBuf)
|
|
|
|
err = session.Wait()
|
|
if err != nil {
|
|
if n > 0 {
|
|
return fmt.Errorf("upload failed: %s: %w", string(errBuf[:n]), err)
|
|
}
|
|
return fmt.Errorf("upload failed: %w", err)
|
|
}
|
|
|
|
onLine(fmt.Sprintf("Uploaded %s successfully", localPath))
|
|
return nil
|
|
}
|
|
|
|
// checkHealth performs an HTTP GET to the health check URL.
|
|
func (s *Service) checkHealth(url string, timeout time.Duration) error {
|
|
client := &http.Client{
|
|
Timeout: timeout,
|
|
}
|
|
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("health check request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("health check returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
// streamLines reads lines from a reader and calls onLine for each.
|
|
func streamLines(r io.Reader, onLine LineCallback) {
|
|
buf := make([]byte, 4096)
|
|
line := make([]byte, 0)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
for _, b := range buf[:n] {
|
|
if b == '\n' {
|
|
onLine(string(line))
|
|
line = line[:0]
|
|
} else if b != '\r' {
|
|
line = append(line, b)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
if len(line) > 0 {
|
|
onLine(string(line))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|