项目文件夹

文件
Asim Aslam 5d7609027b Enhance CLI with color output and add teardown blog post (#2923)
* feat(cli): add color output to micro chat and micro api

micro chat:
- Startup banner matching micro run style: bold header, cyan
  provider/model, green dots for each discovered tool endpoint
- Cyan bold prompt (> ) instead of plain
- Yellow arrow (→) with dimmed tool name for tool calls
- Red "error:" prefix for errors
- Dimmed "(history cleared)" for reset

micro api:
- Startup banner matching micro run style: bold header, cyan
  address, colored HTTP methods (green GET, yellow POST)

Brings the CLI UX closer to what the generated terminal
screenshot depicts — color-coded, professional, readable.

* feat(cli): adopt consistent color output across all commands

Apply the same banner/output style across the remaining commands:

micro new:    bold header, cyan service name, green ✓, cyan URLs
micro build:  green ✓ checkmarks, cyan file paths
micro deploy: bold header, cyan target
micro mcp:    bold header, green dots per tool, dimmed count
micro flow:   bold header, cyan flow/topic/provider

All commands now follow the micro run/chat/api pattern:
bold header, cyan values, green status indicators, dimmed hints.

* docs: add "Tools as Services" blog post

Write blog/12 — connects the AI story back to Go Micro's original
design: services were always self-describing, named, and uniformly
callable. The path from API gateway to MCP to LLM tools is the
same pattern — read the registry, present services in a format
the consumer understands, route calls back.

Covers the access layer pattern (HTTP, web, CLI, MCP, chat),
why doc comments became functional in the AI era, and how the
framework primitives (registry, broker, store) could all become
tools using the same mechanism.

Add to blog index, link forward from blog/11.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 22:15:18 +01:00

494 行
13 KiB
Go

// 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/v5/cmd"
"go-micro.dev/v5/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()
}
// Check if target is a named target from config
if cfg != nil {
if dt, ok := cfg.Deploy[target]; ok {
target = dt.SSH
}
}
return deploySSH(c, target, cfg)
}
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 {
sb.WriteString(fmt.Sprintf(" %s -> %s\n", name, dt.SSH))
}
sb.WriteString("\nDeploy with: micro deploy <target>")
return fmt.Errorf("%s", sb.String())
}
func deploySSH(c *cli.Context, target string, cfg *config.Config) 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)
}
remotePath := c.String("path")
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: Check if the requested service exists before SSH checks
filterService := c.String("service")
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 fmt.Errorf("service '%s' not found in configuration", filterService)
}
}
// 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
var services []string
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
for _, svc := range sorted {
// If --service flag is provided, only include that service
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
} else {
// Single service project
services = []string{filepath.Base(absDir)}
// If --service flag was provided for a single-service project, validate it matches
if filterService != "" && filterService != services[0] {
return fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
}
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")
// Check if we already have binaries and don't need to rebuild
if !forceBuild {
if _, err := os.Stat(binDir); err == nil {
// Check if binaries are for linux
// For now, just rebuild to be safe
}
}
// 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)",
},
},
})
}