// Package deploy provides the micro deploy command for deploying services package deploy import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/urfave/cli/v2" "go-micro.dev/v6/cmd" "go-micro.dev/v6/cmd/micro/run/config" ) const ( defaultRemotePath = "/opt/micro" ) // Deploy deploys services to a target func Deploy(c *cli.Context) error { // Get target from args or flag target := c.Args().First() if target == "" { target = c.String("ssh") } // Load config to check for deploy targets dir := "." absDir, _ := filepath.Abs(dir) cfg, _ := config.Load(absDir) // If still no target, check config for named targets if target == "" && cfg != nil && len(cfg.Deploy) > 0 { // Show available targets return showDeployTargets(cfg) } if target == "" { return showDeployHelp() } target, remotePath := resolveDeployTarget(c, target, cfg) if c.Bool("dry-run") { return printDeployPlan(c, target, cfg, remotePath) } return deploySSH(c, target, cfg, remotePath) } func resolveDeployTarget(c *cli.Context, target string, cfg *config.Config) (string, string) { remotePath := c.String("path") if remotePath == "" { remotePath = defaultRemotePath } // Check if target is a named target from config if cfg != nil { if dt, ok := cfg.Deploy[target]; ok { target = dt.SSH if dt.Path != "" && !c.IsSet("path") { remotePath = dt.Path } } } return target, remotePath } func showDeployHelp() error { return fmt.Errorf(`no deployment target specified. To deploy, you need a server running micro. Quick setup: 1. On your server (Ubuntu/Debian): ssh user@your-server curl -fsSL https://go-micro.dev/install.sh | sh sudo micro init --server 2. Then deploy from here: micro deploy user@your-server Or add to micro.mu: deploy prod ssh user@your-server Run 'micro deploy --help' for more options`) } func showDeployTargets(cfg *config.Config) error { var sb strings.Builder sb.WriteString("Available deploy targets:\n\n") for name, dt := range cfg.Deploy { fmt.Fprintf(&sb, " %s -> %s\n", name, dt.SSH) } sb.WriteString("\nDeploy with: micro deploy ") return fmt.Errorf("%s", sb.String()) } func printDeployPlan(c *cli.Context, target string, cfg *config.Config, remotePath string) error { dir := c.Args().Get(1) if dir == "" { dir = "." } absDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("failed to get absolute path: %w", err) } if cfg == nil { cfg, _ = config.Load(absDir) } if remotePath == "" { remotePath = defaultRemotePath } services, err := deployServices(absDir, cfg, c.String("service")) if err != nil { return err } fmt.Println() fmt.Println(" \033[1mmicro deploy --dry-run\033[0m") fmt.Println() fmt.Printf(" Target \033[36m%s\033[0m\n", target) fmt.Printf(" Remote path %s\n", remotePath) fmt.Printf(" Services %s\n", strings.Join(services, ", ")) fmt.Println() fmt.Println(" Plan:") fmt.Println(" 1. Build linux/amd64 service binaries") fmt.Printf(" 2. Copy binaries to %s/bin/\n", remotePath) fmt.Println(" 3. Enable and restart micro@ systemd units") fmt.Println(" 4. Check service health") fmt.Println() fmt.Println(" No SSH, rsync, systemd, or remote deployment was performed.") return nil } func deployServices(absDir string, cfg *config.Config, filterService string) ([]string, error) { if filterService != "" && cfg != nil { found := false for _, svc := range cfg.Services { if svc.Name == filterService { found = true break } } if !found && len(cfg.Services) > 0 { return nil, fmt.Errorf("service '%s' not found in configuration", filterService) } } if cfg != nil && len(cfg.Services) > 0 { sorted, err := cfg.TopologicalSort() if err != nil { return nil, err } services := make([]string, 0, len(sorted)) for _, svc := range sorted { if filterService == "" || svc.Name == filterService { services = append(services, svc.Name) } } return services, nil } services := []string{filepath.Base(absDir)} if filterService != "" && filterService != services[0] { return nil, fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0]) } return services, nil } func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error { dir := c.Args().Get(1) if dir == "" { dir = "." } absDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("failed to get absolute path: %w", err) } // Load config if not passed if cfg == nil { cfg, _ = config.Load(absDir) } if remotePath == "" { remotePath = defaultRemotePath } fmt.Println() fmt.Println(" \033[1mmicro deploy\033[0m") fmt.Println() fmt.Printf(" Target \033[36m%s\033[0m\n\n", target) // Early validation: resolve services before SSH checks. services, err := deployServices(absDir, cfg, c.String("service")) if err != nil { return err } // Step 1: Check SSH connectivity fmt.Print(" Checking SSH connection... ") if err := checkSSH(target); err != nil { fmt.Println("\u2717") return err } fmt.Println("\u2713") // Step 2: Check server is initialized fmt.Print(" Checking server setup... ") if err := checkServerInit(target, remotePath); err != nil { fmt.Println("\u2717") return err } fmt.Println("\u2713") // Step 3: Build binaries fmt.Printf(" Building binaries... ") if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil { fmt.Println("\u2717") return err } fmt.Printf("\u2713 %s\n", strings.Join(services, ", ")) // Step 4: Copy binaries fmt.Printf(" Copying binaries... ") if err := copyBinaries(target, filepath.Join(absDir, "bin"), remotePath); err != nil { fmt.Println("\u2717") return err } fmt.Printf("\u2713 %d services\n", len(services)) // Step 5: Setup and restart services via systemd fmt.Printf(" Updating systemd... ") if err := setupSystemdServices(target, remotePath, services); err != nil { fmt.Println("\u2717") return err } fmt.Printf("\u2713 %s\n", strings.Join(prefixServices(services), ", ")) // Step 6: Restart services fmt.Printf(" Restarting services... ") if err := restartServices(target, services); err != nil { fmt.Println("\u2717") return err } fmt.Println("\u2713") // Step 7: Check health fmt.Printf(" Checking health... ") time.Sleep(2 * time.Second) // Give services time to start healthy, unhealthy := checkServicesHealth(target, services) if len(unhealthy) > 0 { fmt.Printf("\u26a0 %d/%d healthy\n", len(healthy), len(services)) } else { fmt.Println("\u2713 all healthy") } fmt.Println() fmt.Printf("\u2713 Deployed to %s\n", target) fmt.Println() fmt.Printf(" Status: micro status --remote %s\n", target) fmt.Printf(" Logs: micro logs --remote %s\n", target) if len(unhealthy) > 0 { fmt.Println() fmt.Printf("\u26a0 Some services may have issues: %s\n", strings.Join(unhealthy, ", ")) fmt.Printf(" Check logs: micro logs %s --remote %s\n", unhealthy[0], target) } return nil } func prefixServices(services []string) []string { result := make([]string, len(services)) for i, s := range services { result[i] = "micro@" + s } return result } func checkSSH(host string) error { testCmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", host, "echo ok") output, err := testCmd.CombinedOutput() if err != nil { return fmt.Errorf(` \u2717 Cannot connect to %s SSH connection failed. Check that: \u2022 The server is reachable: ping %s \u2022 SSH is configured: ssh %s \u2022 Your key is added: ssh-add -l Common fixes: \u2022 Add SSH key: ssh-copy-id %s \u2022 Check hostname in ~/.ssh/config Error: %s`, host, host, host, host, strings.TrimSpace(string(output))) } return nil } func checkServerInit(host, remotePath string) error { checkCmd := fmt.Sprintf("test -f %s/.micro-initialized", remotePath) sshCmd := exec.Command("ssh", host, checkCmd) if err := sshCmd.Run(); err != nil { return fmt.Errorf(` \u2717 Server not initialized micro is not set up on %s. Run this on the server: ssh %s curl -fsSL https://go-micro.dev/install.sh | sh sudo micro init --server Or initialize remotely (requires sudo): micro init --server --remote %s`, host, host, host) } return nil } func buildBinaries(absDir string, cfg *config.Config, forceBuild bool, servicesToBuild []string) error { binDir := filepath.Join(absDir, "bin") // Always build for linux/amd64 targetOS := "linux" targetArch := "amd64" if err := os.MkdirAll(binDir, 0755); err != nil { return err } if cfg != nil && len(cfg.Services) > 0 { sorted, err := cfg.TopologicalSort() if err != nil { return err } // Create a map for quick lookup of services to build // This provides O(1) lookup time and makes the code more maintainable shouldBuild := make(map[string]bool) for _, svcName := range servicesToBuild { shouldBuild[svcName] = true } for _, svc := range sorted { // Only build services in the servicesToBuild list if !shouldBuild[svc.Name] { continue } svcDir := filepath.Join(absDir, svc.Path) outPath := filepath.Join(binDir, svc.Name) buildCmd := exec.Command("go", "build", "-o", outPath, ".") buildCmd.Dir = svcDir buildCmd.Env = append(os.Environ(), "GOOS="+targetOS, "GOARCH="+targetArch, "CGO_ENABLED=0", ) if output, err := buildCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to build %s:\n%s", svc.Name, string(output)) } } } else { name := filepath.Base(absDir) outPath := filepath.Join(binDir, name) buildCmd := exec.Command("go", "build", "-o", outPath, ".") buildCmd.Dir = absDir buildCmd.Env = append(os.Environ(), "GOOS="+targetOS, "GOARCH="+targetArch, "CGO_ENABLED=0", ) if output, err := buildCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to build:\n%s", string(output)) } } return nil } func copyBinaries(target, binDir, remotePath string) error { // Ensure remote bin directory exists mkdirCmd := exec.Command("ssh", target, fmt.Sprintf("mkdir -p %s/bin", remotePath)) if err := mkdirCmd.Run(); err != nil { return fmt.Errorf("failed to create remote directory: %w", err) } // Use rsync for efficient copy // --omit-dir-times avoids permission errors on directory timestamps rsyncArgs := []string{ "-avz", "--delete", "--omit-dir-times", binDir + "/", fmt.Sprintf("%s:%s/bin/", target, remotePath), } rsyncCmd := exec.Command("rsync", rsyncArgs...) output, err := rsyncCmd.CombinedOutput() if err != nil { outputStr := string(output) // Fall back to scp if rsync not available if strings.Contains(outputStr, "command not found") { scpCmd := exec.Command("scp", "-r", binDir+"/", fmt.Sprintf("%s:%s/bin/", target, remotePath)) if scpOutput, scpErr := scpCmd.CombinedOutput(); scpErr != nil { return fmt.Errorf("copy failed: %s", string(scpOutput)) } return nil } // rsync exit code 23 means some files failed to transfer, but if we see our files listed, it's ok // rsync exit code 24 means some files vanished during transfer (harmless) exitErr, ok := err.(*exec.ExitError) if ok && (exitErr.ExitCode() == 23 || exitErr.ExitCode() == 24) { // Check if it's just permission warnings on metadata, not actual file transfer failures if !strings.Contains(outputStr, "Permission denied (13)") || strings.Contains(outputStr, "failed to set times") || strings.Contains(outputStr, "chgrp") { // These are acceptable warnings return nil } } return fmt.Errorf("copy failed: %s", outputStr) } return nil } func setupSystemdServices(target, remotePath string, services []string) error { for _, svc := range services { // Enable the service using the template enableCmd := fmt.Sprintf("sudo systemctl enable micro@%s 2>/dev/null || true", svc) sshCmd := exec.Command("ssh", target, enableCmd) _ = sshCmd.Run() // Ignore errors, service might already be enabled } // Reload systemd reloadCmd := exec.Command("ssh", target, "sudo systemctl daemon-reload") if err := reloadCmd.Run(); err != nil { return fmt.Errorf("failed to reload systemd: %w", err) } return nil } func restartServices(target string, services []string) error { for _, svc := range services { restartCmd := fmt.Sprintf("sudo systemctl restart micro@%s", svc) sshCmd := exec.Command("ssh", target, restartCmd) if output, err := sshCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to restart %s: %s", svc, string(output)) } } return nil } func checkServicesHealth(target string, services []string) (healthy, unhealthy []string) { for _, svc := range services { checkCmd := fmt.Sprintf("systemctl is-active micro@%s", svc) sshCmd := exec.Command("ssh", target, checkCmd) if err := sshCmd.Run(); err != nil { unhealthy = append(unhealthy, svc) } else { healthy = append(healthy, svc) } } return } // Ensure we're not on Windows for deploy func checkPlatform() error { if runtime.GOOS == "windows" { return fmt.Errorf("micro deploy requires SSH and rsync, which work best on Linux/macOS.\nConsider using WSL on Windows") } return nil } func init() { cmd.Register(&cli.Command{ Name: "deploy", Usage: "Deploy services to a remote server", Description: `Deploy copies binaries to a remote server and manages them with systemd. Before deploying, initialize the server: ssh user@server 'curl -fsSL https://go-micro.dev/install.sh | sh && sudo micro init --server' Then deploy: micro deploy user@server Deploy a specific service (multi-service projects): micro deploy user@server --service users With a micro.mu config, you can define named targets: deploy prod ssh user@prod.example.com deploy staging ssh user@staging.example.com Then: micro deploy prod The deploy process: 1. Builds binaries for linux/amd64 2. Copies to /opt/micro/bin/ via rsync 3. Enables and restarts systemd services 4. Verifies services are healthy`, Action: func(c *cli.Context) error { if err := checkPlatform(); err != nil { return err } return Deploy(c) }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "ssh", Usage: "Deploy target as user@host (can also be positional arg)", }, &cli.StringFlag{ Name: "path", Usage: "Remote path (default: /opt/micro)", Value: "/opt/micro", }, &cli.BoolFlag{ Name: "build", Usage: "Force rebuild of binaries", }, &cli.StringFlag{ Name: "service", Usage: "Deploy only a specific service (for multi-service projects)", }, &cli.BoolFlag{ Name: "dry-run", Usage: "Print the deployment plan without building, connecting, copying, or restarting services", }, }, }) }