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