Files
ci/internal/deployer/deployer.go
2026-06-18 21:19:47 +08:00

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