1
0
mirror of https://github.com/go-micro/go-micro.git synced 2026-06-03 18:44:36 +02:00
Files
go-micro/cmd/micro/cli/remote/remote.go
Asim Aslam a5bef7af29 Add systemd-based deployment support (#2836)
* Add systemd-based deployment support

- micro init --server: Initialize server to receive deployments
  - Creates /opt/micro/{bin,data,config} directories
  - Generates systemd template unit (micro@.service)
  - Creates 'micro' system user

- micro deploy: Deploy services via SSH + systemd
  - Builds linux/amd64 binaries automatically
  - Copies via rsync/scp to server
  - Manages services via systemctl
  - Helpful error messages for common issues

- micro status --remote: Check remote service status
- micro logs --remote: Stream remote logs via journalctl
- micro stop --remote: Stop services on remote server

- Config: Added 'deploy' blocks to micro.mu for named targets

The deployment model:
- systemd is the process supervisor (battle-tested)
- SSH is the transport (standard, secure)
- No custom daemons or platforms needed

Co-authored-by: Shelley <shelley@exe.dev>

* Add deployment documentation

- docs/deployment.md: Comprehensive guide for server deployment
- README.md: Updated deployment section with full workflow

Co-authored-by: Shelley <shelley@exe.dev>

* Add deployment section to CLI documentation

Co-authored-by: Shelley <shelley@exe.dev>

* Fix systemd template escaping and rsync permission warnings

- Fix %i escaping in systemd template (was being interpreted by fmt.Sprintf)
- Handle rsync exit code 23/24 gracefully (metadata permission warnings)
- Add --omit-dir-times to rsync to avoid directory timestamp errors

Co-authored-by: Shelley <shelley@exe.dev>

* Add install script for micro CLI

Co-authored-by: Shelley <shelley@exe.dev>

* Fix non-constant format string in deploy error

Co-authored-by: Shelley <shelley@exe.dev>

---------

Co-authored-by: Shelley <shelley@exe.dev>
2026-01-27 13:27:59 +00:00

368 lines
8.2 KiB
Go

// Package remote provides remote server operations for micro
package remote
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"github.com/urfave/cli/v2"
"go-micro.dev/v5/cmd"
)
const defaultRemotePath = "/opt/micro"
// Status shows status of services (local or remote)
func Status(c *cli.Context) error {
remoteHost := c.String("remote")
if remoteHost != "" {
return remoteStatus(remoteHost)
}
return localStatus(c)
}
func localStatus(c *cli.Context) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
runDir := filepath.Join(homeDir, "micro", "run")
files, err := os.ReadDir(runDir)
if err != nil {
fmt.Println("No services running locally.")
fmt.Println("\nStart services with: micro run")
return nil
}
var hasServices bool
fmt.Printf("%-20s %-10s %-8s %s\n", "SERVICE", "STATUS", "PID", "DIRECTORY")
fmt.Println(strings.Repeat("-", 70))
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".pid") {
continue
}
hasServices = true
service := f.Name()[:len(f.Name())-4]
pidFilePath := filepath.Join(runDir, f.Name())
pidFile, err := os.Open(pidFilePath)
if err != nil {
continue
}
var pid int
var dir string
scanner := bufio.NewScanner(pidFile)
if scanner.Scan() {
fmt.Sscanf(scanner.Text(), "%d", &pid)
}
if scanner.Scan() {
dir = scanner.Text()
}
pidFile.Close()
status := "\u2717 stopped"
if pid > 0 {
proc, err := os.FindProcess(pid)
if err == nil {
if err := proc.Signal(syscall.Signal(0)); err == nil {
status = "\u25cf running"
}
}
}
fmt.Printf("%-20s %-10s %-8d %s\n", service, status, pid, dir)
}
if !hasServices {
fmt.Println("No services running locally.")
fmt.Println("\nStart services with: micro run")
}
return nil
}
func remoteStatus(host string) error {
// Get list of micro services via systemctl
listCmd := exec.Command("ssh", host, "systemctl list-units 'micro@*' --no-legend --no-pager 2>/dev/null || true")
output, err := listCmd.Output()
if err != nil {
return fmt.Errorf("failed to get status from %s: %w", host, err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
fmt.Printf("%s\n", host)
fmt.Println(strings.Repeat("\u2501", 50))
fmt.Println("\nNo services deployed.")
fmt.Println("\nDeploy with: micro deploy " + host)
return nil
}
fmt.Printf("%s\n", host)
fmt.Println(strings.Repeat("\u2501", 50))
fmt.Println()
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
unit := parts[0]
loadState := parts[1]
activeState := parts[2]
subState := parts[3]
// Extract service name from micro@servicename.service
serviceName := strings.TrimPrefix(unit, "micro@")
serviceName = strings.TrimSuffix(serviceName, ".service")
// Get more details
statusIcon := "\u25cf"
statusText := subState
if activeState != "active" || subState != "running" {
statusIcon = "\u2717"
}
_ = loadState // unused but parsed
fmt.Printf(" %-15s %s %s\n", serviceName, statusIcon, statusText)
}
fmt.Println()
return nil
}
// Logs shows logs for services (local or remote)
func Logs(c *cli.Context) error {
remoteHost := c.String("remote")
service := c.Args().First()
follow := c.Bool("follow") || c.Bool("f")
lines := c.Int("lines")
if remoteHost != "" {
return remoteLogs(remoteHost, service, follow, lines)
}
return localLogs(c, service, follow, lines)
}
func localLogs(c *cli.Context, service string, follow bool, lines int) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
logDir := filepath.Join(homeDir, "micro", "logs")
if service == "" {
// List available logs
files, err := os.ReadDir(logDir)
if err != nil {
fmt.Println("No logs available.")
return nil
}
fmt.Println("Available logs:")
for _, f := range files {
if strings.HasSuffix(f.Name(), ".log") {
name := strings.TrimSuffix(f.Name(), ".log")
fmt.Printf(" %s\n", name)
}
}
fmt.Println("\nView logs: micro logs <service>")
return nil
}
logPath := filepath.Join(logDir, service+".log")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
return fmt.Errorf("no logs for service '%s'", service)
}
if follow {
cmd := exec.Command("tail", "-f", logPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
if lines == 0 {
lines = 100
}
cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", lines), logPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func remoteLogs(host, service string, follow bool, lines int) error {
var journalCmd string
if service == "" {
// All micro services
journalCmd = "journalctl -u 'micro@*'"
} else {
journalCmd = fmt.Sprintf("journalctl -u 'micro@%s'", service)
}
if follow {
journalCmd += " -f"
} else {
if lines == 0 {
lines = 100
}
journalCmd += fmt.Sprintf(" -n %d", lines)
}
journalCmd += " --no-pager"
sshCmd := exec.Command("ssh", host, journalCmd)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
return sshCmd.Run()
}
// Stop stops a running service
func Stop(c *cli.Context) error {
if c.Args().Len() != 1 {
return fmt.Errorf("Usage: micro stop <service>")
}
service := c.Args().First()
remoteHost := c.String("remote")
if remoteHost != "" {
return remoteStop(remoteHost, service)
}
return localStop(service)
}
func localStop(service string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
runDir := filepath.Join(homeDir, "micro", "run")
pidFilePath := filepath.Join(runDir, service+".pid")
pidFile, err := os.Open(pidFilePath)
if err != nil {
return fmt.Errorf("service '%s' is not running", service)
}
var pid int
scanner := bufio.NewScanner(pidFile)
if scanner.Scan() {
fmt.Sscanf(scanner.Text(), "%d", &pid)
}
pidFile.Close()
if pid <= 0 {
_ = os.Remove(pidFilePath)
return fmt.Errorf("service '%s' is not running", service)
}
proc, err := os.FindProcess(pid)
if err != nil {
_ = os.Remove(pidFilePath)
return fmt.Errorf("could not find process for '%s'", service)
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
_ = os.Remove(pidFilePath)
return fmt.Errorf("failed to stop service '%s': %v", service, err)
}
_ = os.Remove(pidFilePath)
fmt.Printf("Stopped %s (pid %d)\n", service, pid)
return nil
}
func remoteStop(host, service string) error {
stopCmd := fmt.Sprintf("sudo systemctl stop micro@%s", service)
sshCmd := exec.Command("ssh", host, stopCmd)
if output, err := sshCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to stop %s: %s", service, string(output))
}
fmt.Printf("Stopped %s on %s\n", service, host)
return nil
}
func init() {
cmd.Register(&cli.Command{
Name: "status",
Usage: "Check status of running services",
Description: `Show status of running services.
Local status:
micro status
Remote status:
micro status --remote user@host`,
Action: Status,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "Check status on remote server",
},
},
})
cmd.Register(&cli.Command{
Name: "logs",
Usage: "Show logs for a service",
Description: `View service logs.
Local logs:
micro logs # list available logs
micro logs myservice # show logs for myservice
micro logs myservice -f # follow logs
Remote logs:
micro logs --remote user@host
micro logs myservice --remote user@host -f`,
Action: Logs,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "View logs on remote server",
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
&cli.IntFlag{
Name: "lines",
Aliases: []string{"n"},
Usage: "Number of lines to show (default: 100)",
Value: 100,
},
},
})
cmd.Register(&cli.Command{
Name: "stop",
Usage: "Stop a running service",
Description: `Stop a running service.
Local:
micro stop myservice
Remote:
micro stop myservice --remote user@host`,
Action: Stop,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "Stop service on remote server",
},
},
})
}