From da59d60c2d349559376061eec1ba67a9524924ef Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 2 Apr 2026 15:43:51 -0700 Subject: [PATCH] Add master integration to CLI deploy and undeploy - CLIConfig gains optional [master] section with address field - dialMaster() creates McpMasterServiceClient (same TLS/token pattern) - deploy: routes through master when [master] configured, --direct flag bypasses master for v1-style agent deployment - undeploy: same master/direct routing pattern - Master responses show per-step results (deploy, dns, edge) Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mcp/deploy.go | 52 ++++++++++++++++++++++++++++++++++++++++-- cmd/mcp/dial.go | 37 ++++++++++++++++++++++++++++++ cmd/mcp/undeploy.go | 33 ++++++++++++++++++++++++++- internal/config/cli.go | 18 +++++++++++---- 4 files changed, 132 insertions(+), 8 deletions(-) diff --git a/cmd/mcp/deploy.go b/cmd/mcp/deploy.go index 0f6fbd4..e2f5058 100644 --- a/cmd/mcp/deploy.go +++ b/cmd/mcp/deploy.go @@ -15,6 +15,8 @@ import ( ) func deployCmd() *cobra.Command { + var direct bool + cmd := &cobra.Command{ Use: "deploy [/]", Short: "Deploy service from service definition", @@ -40,6 +42,12 @@ func deployCmd() *cobra.Command { spec := servicedef.ToProto(def) + // Route through master if configured and not in direct mode. + if cfg.Master != nil && cfg.Master.Address != "" && !direct { + return deployViaMaster(cfg, spec) + } + + // Direct mode: deploy to agent. address, err := findNodeAddress(cfg, def.Node) if err != nil { return err @@ -64,9 +72,48 @@ func deployCmd() *cobra.Command { }, } cmd.Flags().StringP("file", "f", "", "service definition file") + cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, deploy directly to agent (v1 mode)") return cmd } +func deployViaMaster(cfg *config.CLIConfig, spec *mcpv1.ServiceSpec) error { + client, conn, err := dialMaster(cfg.Master.Address, cfg) + if err != nil { + return fmt.Errorf("dial master: %w", err) + } + defer func() { _ = conn.Close() }() + + resp, err := client.Deploy(context.Background(), &mcpv1.MasterDeployRequest{ + Service: spec, + }) + if err != nil { + return fmt.Errorf("master deploy: %w", err) + } + + fmt.Printf(" %s: placed on %s\n", spec.GetName(), resp.GetNode()) + if r := resp.GetDeployResult(); r != nil { + printStepResult("deploy", r) + } + if r := resp.GetDnsResult(); r != nil { + printStepResult("dns", r) + } + if r := resp.GetEdgeRouteResult(); r != nil { + printStepResult("edge", r) + } + if !resp.GetSuccess() { + return fmt.Errorf("deploy failed: %s", resp.GetError()) + } + return nil +} + +func printStepResult(name string, r *mcpv1.StepResult) { + if r.GetSuccess() { + fmt.Printf(" %s: ok\n", name) + } else { + fmt.Printf(" %s: FAILED — %s\n", name, r.GetError()) + } +} + // parseServiceArg splits a "service/component" argument into its parts. func parseServiceArg(arg string) (service, component string) { parts := strings.SplitN(arg, "/", 2) @@ -125,8 +172,9 @@ func loadServiceDef(cmd *cobra.Command, cfg *config.CLIConfig, serviceName strin // ServiceInfo, not ServiceSpec. func serviceSpecFromInfo(info *mcpv1.ServiceInfo) *mcpv1.ServiceSpec { spec := &mcpv1.ServiceSpec{ - Name: info.GetName(), - Active: info.GetActive(), + Name: info.GetName(), + Active: info.GetActive(), + Comment: info.GetComment(), } for _, c := range info.GetComponents() { spec.Components = append(spec.Components, &mcpv1.ComponentSpec{ diff --git a/cmd/mcp/dial.go b/cmd/mcp/dial.go index cb4db46..8f23818 100644 --- a/cmd/mcp/dial.go +++ b/cmd/mcp/dial.go @@ -52,6 +52,43 @@ func dialAgent(address string, cfg *config.CLIConfig) (mcpv1.McpAgentServiceClie return mcpv1.NewMcpAgentServiceClient(conn), conn, nil } +// dialMaster connects to the master at the given address and returns a gRPC +// client for the McpMasterService. +func dialMaster(address string, cfg *config.CLIConfig) (mcpv1.McpMasterServiceClient, *grpc.ClientConn, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + if cfg.MCIAS.CACert != "" { + caCert, err := os.ReadFile(cfg.MCIAS.CACert) //nolint:gosec // trusted config path + if err != nil { + return nil, nil, fmt.Errorf("read CA cert %q: %w", cfg.MCIAS.CACert, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, nil, fmt.Errorf("invalid CA cert %q", cfg.MCIAS.CACert) + } + tlsConfig.RootCAs = pool + } + + token, err := loadBearerToken(cfg) + if err != nil { + return nil, nil, fmt.Errorf("load token: %w", err) + } + + conn, err := grpc.NewClient( + address, + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithUnaryInterceptor(tokenInterceptor(token)), + grpc.WithStreamInterceptor(streamTokenInterceptor(token)), + ) + if err != nil { + return nil, nil, fmt.Errorf("dial master %q: %w", address, err) + } + + return mcpv1.NewMcpMasterServiceClient(conn), conn, nil +} + // tokenInterceptor returns a gRPC client interceptor that attaches the // bearer token to outgoing RPC metadata. func tokenInterceptor(token string) grpc.UnaryClientInterceptor { diff --git a/cmd/mcp/undeploy.go b/cmd/mcp/undeploy.go index 4badcfe..55c66dc 100644 --- a/cmd/mcp/undeploy.go +++ b/cmd/mcp/undeploy.go @@ -13,7 +13,9 @@ import ( ) func undeployCmd() *cobra.Command { - return &cobra.Command{ + var direct bool + + cmd := &cobra.Command{ Use: "undeploy ", Short: "Fully undeploy a service: remove routes, DNS, certs, and containers", Args: cobra.ExactArgs(1), @@ -38,6 +40,11 @@ func undeployCmd() *cobra.Command { return fmt.Errorf("write service def: %w", err) } + // Route through master if configured and not in direct mode. + if cfg.Master != nil && cfg.Master.Address != "" && !direct { + return undeployViaMaster(cfg, serviceName) + } + address, err := findNodeAddress(cfg, def.Node) if err != nil { return err @@ -60,4 +67,28 @@ func undeployCmd() *cobra.Command { return nil }, } + cmd.Flags().BoolVar(&direct, "direct", false, "bypass master, undeploy directly via agent") + return cmd +} + +func undeployViaMaster(cfg *config.CLIConfig, serviceName string) error { + client, conn, err := dialMaster(cfg.Master.Address, cfg) + if err != nil { + return fmt.Errorf("dial master: %w", err) + } + defer func() { _ = conn.Close() }() + + resp, err := client.Undeploy(context.Background(), &mcpv1.MasterUndeployRequest{ + ServiceName: serviceName, + }) + if err != nil { + return fmt.Errorf("master undeploy: %w", err) + } + + if resp.GetSuccess() { + fmt.Printf(" %s: undeployed\n", serviceName) + } else { + return fmt.Errorf("undeploy failed: %s", resp.GetError()) + } + return nil } diff --git a/internal/config/cli.go b/internal/config/cli.go index 722de95..021a2af 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -10,11 +10,19 @@ import ( // CLIConfig is the configuration for the mcp CLI binary. type CLIConfig struct { - Services ServicesConfig `toml:"services"` - Build BuildConfig `toml:"build"` - MCIAS MCIASConfig `toml:"mcias"` - Auth AuthConfig `toml:"auth"` - Nodes []NodeConfig `toml:"nodes"` + Services ServicesConfig `toml:"services"` + Build BuildConfig `toml:"build"` + MCIAS MCIASConfig `toml:"mcias"` + Auth AuthConfig `toml:"auth"` + Nodes []NodeConfig `toml:"nodes"` + Master *CLIMasterConfig `toml:"master,omitempty"` +} + +// CLIMasterConfig holds the optional master connection settings. +// When configured, deploy/undeploy/status go through the master +// instead of directly to agents. +type CLIMasterConfig struct { + Address string `toml:"address"` // master gRPC address (e.g. "100.95.252.120:9555") } // BuildConfig holds settings for building container images.