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:
@@ -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
48
cmd/mcp-agent/snapshot.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
31
cmd/mcp/helpers.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
106
cmd/mcp/node.go
106
cmd/mcp/node.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user