P2.2-P2.9, P3.2-P3.10, P4.1-P4.3: Complete Phases 2, 3, and 4

11 work units built in parallel and merged:

Agent handlers (Phase 2):
- P2.2 Deploy: pull images, stop/remove/run containers, update registry
- P2.3 Lifecycle: stop/start/restart with desired_state tracking
- P2.4 Status: list (registry), live check (runtime), get status (drift+events)
- P2.5 Sync: receive desired state, reconcile unmanaged containers
- P2.6 File transfer: push/pull scoped to /srv/<service>/, path validation
- P2.7 Adopt: match <service>-* containers, derive component names
- P2.8 Monitor: continuous watch loop, drift/flap alerting, event pruning
- P2.9 Snapshot: VACUUM INTO database backup command

CLI commands (Phase 3):
- P3.2 Login, P3.3 Deploy, P3.4 Stop/Start/Restart
- P3.5 List/Ps/Status, P3.6 Sync, P3.7 Adopt
- P3.8 Service show/edit/export, P3.9 Push/Pull, P3.10 Node list/add/remove

Deployment artifacts (Phase 4):
- Systemd units (agent service + backup timer)
- Example configs (CLI + agent)
- Install script (idempotent)

All packages: build, vet, lint (0 issues), test (all pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:21:18 -07:00
parent d7cc970133
commit 8f913ddf9b
33 changed files with 3593 additions and 62 deletions

View File

@@ -42,6 +42,8 @@ func main() {
},
})
root.AddCommand(snapshotCmd())
if err := root.Execute(); err != nil {
log.Fatal(err)
os.Exit(1)

48
cmd/mcp-agent/snapshot.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/mcp/internal/config"
"github.com/spf13/cobra"
_ "modernc.org/sqlite"
)
func snapshotCmd() *cobra.Command {
return &cobra.Command{
Use: "snapshot",
Short: "Create a database backup",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadAgentConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
if err := os.MkdirAll(backupDir, 0750); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
ts := time.Now().Format("20060102-150405")
backupPath := filepath.Join(backupDir, fmt.Sprintf("mcp-%s.db", ts))
db, err := sql.Open("sqlite", cfg.Database.Path)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() { _ = db.Close() }()
//nolint:gosec // backupPath is derived from config + timestamp, not user input; VACUUM INTO does not support placeholders
if _, err := db.Exec("VACUUM INTO '" + backupPath + "'"); err != nil {
return fmt.Errorf("vacuum into: %w", err)
}
fmt.Printf("snapshot: %s\n", backupPath)
return nil
},
}
}

View File

@@ -1,18 +1,78 @@
package main
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"github.com/spf13/cobra"
)
func adoptCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "adopt <service>",
Short: "Adopt all <service>-* containers into a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
nodeName, _ := cmd.Flags().GetString("node")
var addr string
if nodeName != "" {
for _, n := range cfg.Nodes {
if n.Name == nodeName {
addr = n.Address
break
}
}
if addr == "" {
return fmt.Errorf("node %q not found in config", nodeName)
}
} else {
if len(cfg.Nodes) == 0 {
return fmt.Errorf("no nodes configured")
}
nodeName = cfg.Nodes[0].Name
addr = cfg.Nodes[0].Address
}
client, conn, err := dialAgent(addr, cfg)
if err != nil {
return fmt.Errorf("dial %s: %w", nodeName, err)
}
defer func() { _ = conn.Close() }()
resp, err := client.AdoptContainers(context.Background(), &mcpv1.AdoptContainersRequest{
Service: args[0],
})
if err != nil {
return fmt.Errorf("adopt on %s: %w", nodeName, err)
}
results := resp.GetResults()
if len(results) == 0 {
fmt.Printf("no containers matching %q found on %s\n", args[0]+"-*", nodeName)
return nil
}
for _, r := range results {
if r.GetSuccess() {
fmt.Printf(" adopted %s -> %s\n", r.GetContainer(), r.GetComponent())
} else {
fmt.Printf(" failed %s -> %s: %s\n", r.GetContainer(), r.GetComponent(), r.GetError())
}
}
return nil
},
}
cmd.Flags().String("node", "", "target node (default: first node in config)")
return cmd
}

View File

@@ -1,9 +1,16 @@
package main
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
)
func deployCmd() *cobra.Command {
@@ -12,9 +19,113 @@ func deployCmd() *cobra.Command {
Short: "Deploy service from service definition",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
serviceName, component := parseServiceArg(args[0])
def, err := loadServiceDef(cmd, cfg, serviceName)
if err != nil {
return err
}
spec := servicedef.ToProto(def)
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.Deploy(context.Background(), &mcpv1.DeployRequest{
Service: spec,
Component: component,
})
if err != nil {
return fmt.Errorf("deploy: %w", err)
}
printComponentResults(resp.GetResults())
return nil
},
}
cmd.Flags().StringP("file", "f", "", "service definition file")
return cmd
}
// parseServiceArg splits a "service/component" argument into its parts.
func parseServiceArg(arg string) (service, component string) {
parts := strings.SplitN(arg, "/", 2)
service = parts[0]
if len(parts) == 2 {
component = parts[1]
}
return service, component
}
// loadServiceDef attempts to load a service definition from the -f flag,
// the configured services directory, or by falling back to the agent registry.
func loadServiceDef(cmd *cobra.Command, cfg *config.CLIConfig, serviceName string) (*servicedef.ServiceDef, error) {
// Check -f flag first.
filePath, _ := cmd.Flags().GetString("file")
if filePath != "" {
def, err := servicedef.Load(filePath)
if err != nil {
return nil, fmt.Errorf("load service def from %q: %w", filePath, err)
}
return def, nil
}
// Try services directory.
dirPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
def, err := servicedef.Load(dirPath)
if err == nil {
return def, nil
}
// Fall back to agent registry: query each node for the service.
for _, node := range cfg.Nodes {
client, conn, dialErr := dialAgent(node.Address, cfg)
if dialErr != nil {
continue
}
resp, listErr := client.ListServices(context.Background(), &mcpv1.ListServicesRequest{})
_ = conn.Close()
if listErr != nil {
continue
}
for _, svc := range resp.GetServices() {
if svc.GetName() == serviceName {
return servicedef.FromProto(serviceSpecFromInfo(svc), node.Name), nil
}
}
}
return nil, fmt.Errorf("service definition %q not found in %q or agent registry", serviceName, cfg.Services.Dir)
}
// serviceSpecFromInfo converts a ServiceInfo to a ServiceSpec for use with
// servicedef.FromProto. This is needed because the agent registry returns
// ServiceInfo, not ServiceSpec.
func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
Name: info.GetName(),
Active: info.GetActive(),
}
for _, c := range info.GetComponents() {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
Name: c.GetName(),
Image: c.GetImage(),
})
}
return spec
}

View File

@@ -14,13 +14,6 @@ import (
"google.golang.org/grpc/metadata"
)
// Ensure dial helpers are referenced to satisfy linters until CLI commands
// are implemented. This will be removed when the first command uses dialAgent.
var (
_ = dialAgent
_ = loadBearerToken
)
// dialAgent connects to an agent at the given address and returns a gRPC
// client. The connection uses TLS and attaches the bearer token to every RPC.
func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClient, *grpc.ClientConn, error) {

31
cmd/mcp/helpers.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"fmt"
"os"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
)
// findNodeAddress looks up a node by name in the CLI config and returns
// its address.
func findNodeAddress(cfg *config.CLIConfig, nodeName string) (string, error) {
for _, n := range cfg.Nodes {
if n.Name == nodeName {
return n.Address, nil
}
}
return "", fmt.Errorf("node %q not found in config", nodeName)
}
// printComponentResults prints the result of each component operation.
func printComponentResults(results []*mcpv1.ComponentResult) {
for _, r := range results {
if r.GetSuccess() {
fmt.Printf(" %s: ok\n", r.GetName())
} else {
fmt.Fprintf(os.Stderr, " %s: error: %s\n", r.GetName(), r.GetError())
}
}
}

View File

@@ -1,9 +1,15 @@
package main
import (
"context"
"fmt"
"path/filepath"
"github.com/spf13/cobra"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
)
func stopCmd() *cobra.Command {
@@ -12,7 +18,45 @@ func stopCmd() *cobra.Command {
Short: "Stop all components, set active=false",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
serviceName := args[0]
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
def, err := servicedef.Load(defPath)
if err != nil {
return fmt.Errorf("load service def: %w", err)
}
active := false
def.Active = &active
if err := servicedef.Write(defPath, def); err != nil {
return fmt.Errorf("write service def: %w", err)
}
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.StopService(context.Background(), &mcpv1.StopServiceRequest{
Name: serviceName,
})
if err != nil {
return fmt.Errorf("stop service: %w", err)
}
printComponentResults(resp.GetResults())
return nil
},
}
}
@@ -23,7 +67,45 @@ func startCmd() *cobra.Command {
Short: "Start all components, set active=true",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
serviceName := args[0]
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
def, err := servicedef.Load(defPath)
if err != nil {
return fmt.Errorf("load service def: %w", err)
}
active := true
def.Active = &active
if err := servicedef.Write(defPath, def); err != nil {
return fmt.Errorf("write service def: %w", err)
}
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.StartService(context.Background(), &mcpv1.StartServiceRequest{
Name: serviceName,
})
if err != nil {
return fmt.Errorf("start service: %w", err)
}
printComponentResults(resp.GetResults())
return nil
},
}
}
@@ -34,7 +116,39 @@ func restartCmd() *cobra.Command {
Short: "Restart all components",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
serviceName := args[0]
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
def, err := servicedef.Load(defPath)
if err != nil {
return fmt.Errorf("load service def: %w", err)
}
address, err := findNodeAddress(cfg, def.Node)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.RestartService(context.Background(), &mcpv1.RestartServiceRequest{
Name: serviceName,
})
if err != nil {
return fmt.Errorf("restart service: %w", err)
}
printComponentResults(resp.GetResults())
return nil
},
}
}

View File

@@ -1,9 +1,15 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"git.wntrmute.dev/kyle/mcp/internal/auth"
"git.wntrmute.dev/kyle/mcp/internal/config"
)
func loginCmd() *cobra.Command {
@@ -11,7 +17,42 @@ func loginCmd() *cobra.Command {
Use: "login",
Short: "Authenticate to MCIAS, store token",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Username: ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fmt.Errorf("read username: %w", err)
}
return fmt.Errorf("read username: unexpected end of input")
}
username := strings.TrimSpace(scanner.Text())
fmt.Print("Password: ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fmt.Errorf("read password: %w", err)
}
return fmt.Errorf("read password: unexpected end of input")
}
password := strings.TrimSpace(scanner.Text())
token, err := auth.Login(cfg.MCIAS.ServerURL, cfg.MCIAS.CACert, username, password)
if err != nil {
return fmt.Errorf("login: %w", err)
}
if err := auth.SaveToken(cfg.Auth.TokenPath, token); err != nil {
return fmt.Errorf("save token: %w", err)
}
fmt.Println("Login successful.")
return nil
},
}
}

View File

@@ -2,7 +2,12 @@ package main
import (
"fmt"
"os"
"text/tabwriter"
toml "github.com/pelletier/go-toml/v2"
"git.wntrmute.dev/kyle/mcp/internal/config"
"github.com/spf13/cobra"
)
@@ -15,29 +20,112 @@ func nodeCmd() *cobra.Command {
list := &cobra.Command{
Use: "list",
Short: "List registered nodes",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runNodeList,
}
add := &cobra.Command{
Use: "add <name> <address>",
Short: "Register a node",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runNodeAdd,
}
remove := &cobra.Command{
Use: "remove <name>",
Short: "Deregister a node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runNodeRemove,
}
cmd.AddCommand(list, add, remove)
return cmd
}
func runNodeList(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "NAME\tADDRESS")
for _, n := range cfg.Nodes {
_, _ = fmt.Fprintf(w, "%s\t%s\n", n.Name, n.Address)
}
return w.Flush()
}
func runNodeAdd(_ *cobra.Command, args []string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
name := args[0]
address := args[1]
for _, n := range cfg.Nodes {
if n.Name == name {
return fmt.Errorf("node %q already exists", name)
}
}
cfg.Nodes = append(cfg.Nodes, config.NodeConfig{
Name: name,
Address: address,
})
if err := writeConfig(cfgPath, cfg); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Printf("Added node %s (%s)\n", name, address)
return nil
}
func runNodeRemove(_ *cobra.Command, args []string) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
name := args[0]
var found bool
nodes := make([]config.NodeConfig, 0, len(cfg.Nodes))
for _, n := range cfg.Nodes {
if n.Name == name {
found = true
continue
}
nodes = append(nodes, n)
}
if !found {
return fmt.Errorf("node %q not found", name)
}
cfg.Nodes = nodes
if err := writeConfig(cfgPath, cfg); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Printf("Removed node %s\n", name)
return nil
}
// writeConfig serializes the CLIConfig to TOML and writes it back to the
// config file.
func writeConfig(path string, cfg *config.CLIConfig) error {
data, err := toml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config %q: %w", path, err)
}
return nil
}

View File

@@ -1,9 +1,18 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"google.golang.org/grpc"
)
func serviceCmd() *cobra.Command {
@@ -16,30 +25,204 @@ func serviceCmd() *cobra.Command {
Use: "show <service>",
Short: "Print current spec from agent registry",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runServiceShow,
}
edit := &cobra.Command{
Use: "edit <service>",
Short: "Open service definition in $EDITOR",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runServiceEdit,
}
export := &cobra.Command{
Use: "export <service>",
Short: "Write agent registry spec to local service file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runServiceExport,
}
export.Flags().StringP("file", "f", "", "output file path")
cmd.AddCommand(show, edit, export)
return cmd
}
// findServiceNode checks the local service definition file first, then
// queries all agents to locate which node runs the named service.
func findServiceNode(cfg *config.CLIConfig, serviceName string) (string, string, error) {
defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
if def, err := servicedef.Load(defPath); err == nil {
for _, n := range cfg.Nodes {
if n.Name == def.Node {
return n.Name, n.Address, nil
}
}
return "", "", fmt.Errorf("node %q from service def not found in config", def.Node)
}
for _, n := range cfg.Nodes {
client, conn, err := dialAgent(n.Address, cfg)
if err != nil {
continue
}
resp, err := client.ListServices(context.Background(), &mcpv1.ListServicesRequest{})
_ = conn.Close()
if err != nil {
continue
}
for _, svc := range resp.GetServices() {
if svc.GetName() == serviceName {
return n.Name, n.Address, nil
}
}
}
return "", "", fmt.Errorf("service %q not found on any node", serviceName)
}
// fetchServiceInfo dials the agent at address, calls ListServices, and returns
// the matching ServiceInfo. The caller must close the returned connection.
func fetchServiceInfo(address string, cfg *config.CLIConfig, serviceName string) (*mcpv1.ServiceInfo, *grpc.ClientConn, error) {
client, conn, err := dialAgent(address, cfg)
if err != nil {
return nil, nil, fmt.Errorf("dial agent: %w", err)
}
resp, err := client.ListServices(context.Background(), &mcpv1.ListServicesRequest{})
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("list services: %w", err)
}
for _, svc := range resp.GetServices() {
if svc.GetName() == serviceName {
return svc, conn, nil
}
}
_ = conn.Close()
return nil, nil, fmt.Errorf("service %q not found on agent", serviceName)
}
// serviceInfoToSpec converts a ServiceInfo (runtime view) to a ServiceSpec
// (config view) for use with servicedef.FromProto.
func serviceInfoToSpec(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec {
spec := &mcpv1.ServiceSpec{
Name: info.GetName(),
Active: info.GetActive(),
}
for _, c := range info.GetComponents() {
spec.Components = append(spec.Components, &mcpv1.ComponentSpec{
Name: c.GetName(),
Image: c.GetImage(),
})
}
return spec
}
func runServiceShow(_ *cobra.Command, args []string) error {
serviceName := args[0]
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
_, address, err := findServiceNode(cfg, serviceName)
if err != nil {
return err
}
svc, conn, err := fetchServiceInfo(address, cfg, serviceName)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
def := servicedef.FromProto(serviceInfoToSpec(svc), "")
data, err := toml.Marshal(def)
if err != nil {
return fmt.Errorf("marshal service: %w", err)
}
fmt.Print(string(data))
return nil
}
func runServiceEdit(_ *cobra.Command, args []string) error {
serviceName := args[0]
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
filePath := filepath.Join(cfg.Services.Dir, serviceName+".toml")
// If local file does not exist, export from agent first.
if _, err := os.Stat(filePath); os.IsNotExist(err) {
nodeName, address, err := findServiceNode(cfg, serviceName)
if err != nil {
return fmt.Errorf("find service for export: %w", err)
}
svc, conn, err := fetchServiceInfo(address, cfg, serviceName)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
def := servicedef.FromProto(serviceInfoToSpec(svc), nodeName)
if err := servicedef.Write(filePath, def); err != nil {
return fmt.Errorf("write service def: %w", err)
}
_, _ = fmt.Fprintf(os.Stderr, "Exported %s from agent to %s\n", serviceName, filePath)
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = os.Getenv("VISUAL")
}
if editor == "" {
editor = "vi"
}
cmd := exec.Command(editor, filePath) //nolint:gosec // editor from trusted env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runServiceExport(cmd *cobra.Command, args []string) error {
serviceName := args[0]
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
nodeName, address, err := findServiceNode(cfg, serviceName)
if err != nil {
return err
}
svc, conn, err := fetchServiceInfo(address, cfg, serviceName)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
def := servicedef.FromProto(serviceInfoToSpec(svc), nodeName)
outPath, _ := cmd.Flags().GetString("file")
if outPath == "" {
outPath = filepath.Join(cfg.Services.Dir, serviceName+".toml")
}
if err := servicedef.Write(outPath, def); err != nil {
return fmt.Errorf("write service def: %w", err)
}
fmt.Printf("Exported %s to %s\n", serviceName, outPath)
return nil
}

View File

@@ -1,17 +1,80 @@
package main
import (
"context"
"fmt"
"os"
"text/tabwriter"
"time"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"github.com/spf13/cobra"
)
// newTable returns a tabwriter configured for CLI table output.
func newTable() *tabwriter.Writer {
return tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
}
// forEachNode loads the CLI config, then calls fn for every configured node.
// Dial errors are printed as warnings and the node is skipped. Connections
// are closed before moving to the next node, avoiding defer-in-loop leaks.
func forEachNode(fn func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error) error {
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
for _, node := range cfg.Nodes {
client, conn, err := dialAgent(node.Address, cfg)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: %v\n", node.Name, err)
continue
}
fnErr := fn(node, client)
_ = conn.Close()
if fnErr != nil {
return fnErr
}
}
return nil
}
func listCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List services from all agents (registry, no runtime query)",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
w := newTable()
_, _ = fmt.Fprintln(w, "SERVICE\tCOMPONENT\tDESIRED\tOBSERVED\tVERSION")
if err := forEachNode(func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error {
resp, err := client.ListServices(context.Background(), &mcpv1.ListServicesRequest{})
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: list services: %v\n", node.Name, err)
return nil
}
for _, svc := range resp.GetServices() {
for _, comp := range svc.GetComponents() {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
svc.GetName(),
comp.GetName(),
comp.GetDesiredState(),
comp.GetObservedState(),
comp.GetVersion(),
)
}
}
return nil
}); err != nil {
return err
}
return w.Flush()
},
}
}
@@ -21,7 +84,40 @@ func psCmd() *cobra.Command {
Use: "ps",
Short: "Live check: query runtime on all agents",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
w := newTable()
_, _ = fmt.Fprintln(w, "SERVICE\tCOMPONENT\tNODE\tSTATE\tVERSION\tUPTIME")
now := time.Now()
if err := forEachNode(func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error {
resp, err := client.LiveCheck(context.Background(), &mcpv1.LiveCheckRequest{})
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: live check: %v\n", node.Name, err)
return nil
}
for _, svc := range resp.GetServices() {
for _, comp := range svc.GetComponents() {
uptime := "-"
if comp.GetStarted() != nil {
d := now.Sub(comp.GetStarted().AsTime())
uptime = formatDuration(d)
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
svc.GetName(),
comp.GetName(),
node.Name,
comp.GetObservedState(),
comp.GetVersion(),
uptime,
)
}
}
return nil
}); err != nil {
return err
}
return w.Flush()
},
}
}
@@ -32,7 +128,110 @@ func statusCmd() *cobra.Command {
Short: "Full picture: live query + drift + recent events",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
var serviceName string
if len(args) > 0 {
serviceName = args[0]
}
var allServices []*mcpv1.ServiceInfo
var allDrift []*mcpv1.DriftInfo
var allEvents []*mcpv1.EventInfo
if err := forEachNode(func(node config.NodeConfig, client mcpv1.McpAgentServiceClient) error {
resp, err := client.GetServiceStatus(context.Background(), &mcpv1.GetServiceStatusRequest{
Name: serviceName,
})
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: %s: get service status: %v\n", node.Name, err)
return nil
}
allServices = append(allServices, resp.GetServices()...)
allDrift = append(allDrift, resp.GetDrift()...)
allEvents = append(allEvents, resp.GetRecentEvents()...)
return nil
}); err != nil {
return err
}
// Services table.
w := newTable()
_, _ = fmt.Fprintln(w, "SERVICE\tCOMPONENT\tDESIRED\tOBSERVED\tVERSION")
for _, svc := range allServices {
for _, comp := range svc.GetComponents() {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
svc.GetName(),
comp.GetName(),
comp.GetDesiredState(),
comp.GetObservedState(),
comp.GetVersion(),
)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush services table: %w", err)
}
// Drift section.
if len(allDrift) > 0 {
fmt.Println()
fmt.Println("DRIFT:")
dw := newTable()
_, _ = fmt.Fprintln(dw, "SERVICE\tCOMPONENT\tDESIRED\tOBSERVED")
for _, d := range allDrift {
_, _ = fmt.Fprintf(dw, "%s\t%s\t%s\t%s\n",
d.GetService(),
d.GetComponent(),
d.GetDesiredState(),
d.GetObservedState(),
)
}
if err := dw.Flush(); err != nil {
return fmt.Errorf("flush drift table: %w", err)
}
}
// Recent events section.
if len(allEvents) > 0 {
fmt.Println()
fmt.Println("RECENT EVENTS:")
ew := newTable()
_, _ = fmt.Fprintln(ew, "SERVICE\tCOMPONENT\tPREV\tNEW\tTIME")
for _, e := range allEvents {
ts := "-"
if e.GetTimestamp() != nil {
ts = e.GetTimestamp().AsTime().Format(time.RFC3339)
}
_, _ = fmt.Fprintf(ew, "%s\t%s\t%s\t%s\t%s\n",
e.GetService(),
e.GetComponent(),
e.GetPrevState(),
e.GetNewState(),
ts,
)
}
if err := ew.Flush(); err != nil {
return fmt.Errorf("flush events table: %w", err)
}
}
return nil
},
}
}
// formatDuration returns a human-readable duration string.
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%dd%dh", days, hours)
}

View File

@@ -1,8 +1,12 @@
package main
import (
"context"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"git.wntrmute.dev/kyle/mcp/internal/servicedef"
"github.com/spf13/cobra"
)
@@ -11,7 +15,69 @@ func syncCmd() *cobra.Command {
Use: "sync",
Short: "Push service definitions to agents (update desired state)",
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
defs, err := servicedef.LoadAll(cfg.Services.Dir)
if err != nil {
return fmt.Errorf("load service definitions: %w", err)
}
// Group definitions by node.
byNode := make(map[string][]*servicedef.ServiceDef)
for _, def := range defs {
byNode[def.Node] = append(byNode[def.Node], def)
}
// Build node name -> address lookup.
nodeAddr := make(map[string]string, len(cfg.Nodes))
for _, n := range cfg.Nodes {
nodeAddr[n.Name] = n.Address
}
for nodeName, nodeDefs := range byNode {
addr, ok := nodeAddr[nodeName]
if !ok {
fmt.Printf("warning: no node %q in config, skipping %d service(s)\n", nodeName, len(nodeDefs))
continue
}
client, conn, err := dialAgent(addr, cfg)
if err != nil {
return fmt.Errorf("dial %s: %w", nodeName, err)
}
defer func() { _ = conn.Close() }()
var specs []*mcpv1.ServiceSpec
for _, def := range nodeDefs {
specs = append(specs, servicedef.ToProto(def))
}
resp, err := client.SyncDesiredState(context.Background(), &mcpv1.SyncDesiredStateRequest{
Services: specs,
})
if err != nil {
return fmt.Errorf("sync %s: %w", nodeName, err)
}
fmt.Printf("node %s:\n", nodeName)
for _, r := range resp.GetResults() {
marker := " unchanged"
if r.GetChanged() {
marker = " changed"
}
summary := r.GetSummary()
if summary != "" {
fmt.Printf("%s %s: %s\n", marker, r.GetName(), summary)
} else {
fmt.Printf("%s %s\n", marker, r.GetName())
}
}
}
return nil
},
}
}

View File

@@ -1,8 +1,14 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/config"
"github.com/spf13/cobra"
)
@@ -11,9 +17,7 @@ func pushCmd() *cobra.Command {
Use: "push <local-file> <service> [path]",
Short: "Copy a local file into /srv/<service>/[path]",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runPush,
}
}
@@ -22,8 +26,116 @@ func pullCmd() *cobra.Command {
Use: "pull <service> <path> [local-file]",
Short: "Copy a file from /srv/<service>/<path> to local",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
},
RunE: runPull,
}
}
func runPush(_ *cobra.Command, args []string) error {
localFile := args[0]
serviceName := args[1]
remotePath := filepath.Base(localFile)
if len(args) == 3 {
remotePath = args[2]
}
f, err := os.Open(localFile) //nolint:gosec // user-specified path
if err != nil {
return fmt.Errorf("open local file %q: %w", localFile, err)
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return fmt.Errorf("stat local file %q: %w", localFile, err)
}
mode := uint32(info.Mode().Perm())
content, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("read local file %q: %w", localFile, err)
}
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
_, address, err := findServiceNode(cfg, serviceName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.PushFile(context.Background(), &mcpv1.PushFileRequest{
Service: serviceName,
Path: remotePath,
Content: content,
Mode: mode,
})
if err != nil {
return fmt.Errorf("push file: %w", err)
}
if !resp.GetSuccess() {
return fmt.Errorf("push file: %s", resp.GetError())
}
fmt.Printf("Pushed %s to %s:%s/%s\n", localFile, serviceName, "/srv/"+serviceName, remotePath)
return nil
}
func runPull(_ *cobra.Command, args []string) error {
serviceName := args[0]
remotePath := args[1]
localFile := filepath.Base(remotePath)
if len(args) == 3 {
localFile = args[2]
}
cfg, err := config.LoadCLIConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
_, address, err := findServiceNode(cfg, serviceName)
if err != nil {
return err
}
client, conn, err := dialAgent(address, cfg)
if err != nil {
return fmt.Errorf("dial agent: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := client.PullFile(context.Background(), &mcpv1.PullFileRequest{
Service: serviceName,
Path: remotePath,
})
if err != nil {
return fmt.Errorf("pull file: %w", err)
}
if resp.GetError() != "" {
return fmt.Errorf("pull file: %s", resp.GetError())
}
mode := os.FileMode(resp.GetMode())
if mode == 0 {
mode = 0o644
}
if err := os.WriteFile(localFile, resp.GetContent(), mode); err != nil {
return fmt.Errorf("write local file %q: %w", localFile, err)
}
fmt.Printf("Pulled %s:%s to %s (mode %04o)\n", serviceName, remotePath, localFile, mode)
return nil
}