From 09d0d197c328eee6e7b96ee575470a2ae6c24037 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 30 Mar 2026 17:26:05 -0700 Subject: [PATCH] Add component-level targeting to start, stop, and restart Allow start/stop/restart to target a single component via / syntax, matching deploy/logs/purge. When a component is specified, start/stop skip toggling the service-level active flag. Agent-side filtering returns NotFound for unknown components. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mcp/lifecycle.go | 49 ++++++++++++++++++++++--------------- gen/mcp/v1/mcp.pb.go | 39 ++++++++++++++++++++++++----- internal/agent/lifecycle.go | 47 +++++++++++++++++++++++++++++------ proto/mcp/v1/mcp.proto | 3 +++ 4 files changed, 104 insertions(+), 34 deletions(-) diff --git a/cmd/mcp/lifecycle.go b/cmd/mcp/lifecycle.go index ec7e1a7..c7ec845 100644 --- a/cmd/mcp/lifecycle.go +++ b/cmd/mcp/lifecycle.go @@ -14,8 +14,8 @@ import ( func stopCmd() *cobra.Command { return &cobra.Command{ - Use: "stop ", - Short: "Stop all components, set active=false", + Use: "stop [/]", + Short: "Stop components (or all), set active=false", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.LoadCLIConfig(cfgPath) @@ -23,7 +23,7 @@ func stopCmd() *cobra.Command { return fmt.Errorf("load config: %w", err) } - serviceName := args[0] + serviceName, component := parseServiceArg(args[0]) defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml") def, err := servicedef.Load(defPath) @@ -31,10 +31,13 @@ func stopCmd() *cobra.Command { 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) + // Only flip active=false when stopping the whole service. + if component == "" { + 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) @@ -49,7 +52,8 @@ func stopCmd() *cobra.Command { defer func() { _ = conn.Close() }() resp, err := client.StopService(context.Background(), &mcpv1.StopServiceRequest{ - Name: serviceName, + Name: serviceName, + Component: component, }) if err != nil { return fmt.Errorf("stop service: %w", err) @@ -63,8 +67,8 @@ func stopCmd() *cobra.Command { func startCmd() *cobra.Command { return &cobra.Command{ - Use: "start ", - Short: "Start all components, set active=true", + Use: "start [/]", + Short: "Start components (or all), set active=true", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.LoadCLIConfig(cfgPath) @@ -72,7 +76,7 @@ func startCmd() *cobra.Command { return fmt.Errorf("load config: %w", err) } - serviceName := args[0] + serviceName, component := parseServiceArg(args[0]) defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml") def, err := servicedef.Load(defPath) @@ -80,10 +84,13 @@ func startCmd() *cobra.Command { 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) + // Only flip active=true when starting the whole service. + if component == "" { + 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) @@ -98,7 +105,8 @@ func startCmd() *cobra.Command { defer func() { _ = conn.Close() }() resp, err := client.StartService(context.Background(), &mcpv1.StartServiceRequest{ - Name: serviceName, + Name: serviceName, + Component: component, }) if err != nil { return fmt.Errorf("start service: %w", err) @@ -112,8 +120,8 @@ func startCmd() *cobra.Command { func restartCmd() *cobra.Command { return &cobra.Command{ - Use: "restart ", - Short: "Restart all components", + Use: "restart [/]", + Short: "Restart components (or all)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.LoadCLIConfig(cfgPath) @@ -121,7 +129,7 @@ func restartCmd() *cobra.Command { return fmt.Errorf("load config: %w", err) } - serviceName := args[0] + serviceName, component := parseServiceArg(args[0]) defPath := filepath.Join(cfg.Services.Dir, serviceName+".toml") def, err := servicedef.Load(defPath) @@ -141,7 +149,8 @@ func restartCmd() *cobra.Command { defer func() { _ = conn.Close() }() resp, err := client.RestartService(context.Background(), &mcpv1.RestartServiceRequest{ - Name: serviceName, + Name: serviceName, + Component: component, }) if err != nil { return fmt.Errorf("restart service: %w", err) diff --git a/gen/mcp/v1/mcp.pb.go b/gen/mcp/v1/mcp.pb.go index a3fc700..4c78b67 100644 --- a/gen/mcp/v1/mcp.pb.go +++ b/gen/mcp/v1/mcp.pb.go @@ -426,6 +426,7 @@ func (x *ComponentResult) GetError() string { type StopServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Component string `protobuf:"bytes,2,opt,name=component,proto3" json:"component,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -467,6 +468,13 @@ func (x *StopServiceRequest) GetName() string { return "" } +func (x *StopServiceRequest) GetComponent() string { + if x != nil { + return x.Component + } + return "" +} + type StopServiceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Results []*ComponentResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` @@ -514,6 +522,7 @@ func (x *StopServiceResponse) GetResults() []*ComponentResult { type StartServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Component string `protobuf:"bytes,2,opt,name=component,proto3" json:"component,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -555,6 +564,13 @@ func (x *StartServiceRequest) GetName() string { return "" } +func (x *StartServiceRequest) GetComponent() string { + if x != nil { + return x.Component + } + return "" +} + type StartServiceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Results []*ComponentResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` @@ -602,6 +618,7 @@ func (x *StartServiceResponse) GetResults() []*ComponentResult { type RestartServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Component string `protobuf:"bytes,2,opt,name=component,proto3" json:"component,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -643,6 +660,13 @@ func (x *RestartServiceRequest) GetName() string { return "" } +func (x *RestartServiceRequest) GetComponent() string { + if x != nil { + return x.Component + } + return "" +} + type RestartServiceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Results []*ComponentResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` @@ -3060,17 +3084,20 @@ const file_proto_mcp_v1_mcp_proto_rawDesc = "" + "\x0fComponentResult\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"(\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"F\n" + "\x12StopServiceRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"H\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tcomponent\x18\x02 \x01(\tR\tcomponent\"H\n" + "\x13StopServiceResponse\x121\n" + - "\aresults\x18\x01 \x03(\v2\x17.mcp.v1.ComponentResultR\aresults\")\n" + + "\aresults\x18\x01 \x03(\v2\x17.mcp.v1.ComponentResultR\aresults\"G\n" + "\x13StartServiceRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"I\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tcomponent\x18\x02 \x01(\tR\tcomponent\"I\n" + "\x14StartServiceResponse\x121\n" + - "\aresults\x18\x01 \x03(\v2\x17.mcp.v1.ComponentResultR\aresults\"+\n" + + "\aresults\x18\x01 \x03(\v2\x17.mcp.v1.ComponentResultR\aresults\"I\n" + "\x15RestartServiceRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"K\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tcomponent\x18\x02 \x01(\tR\tcomponent\"K\n" + "\x16RestartServiceResponse\x121\n" + "\aresults\x18\x01 \x03(\v2\x17.mcp.v1.ComponentResultR\aresults\",\n" + "\x16UndeployServiceRequest\x12\x12\n" + diff --git a/internal/agent/lifecycle.go b/internal/agent/lifecycle.go index 9257447..9facc94 100644 --- a/internal/agent/lifecycle.go +++ b/internal/agent/lifecycle.go @@ -12,9 +12,9 @@ import ( "google.golang.org/grpc/status" ) -// StopService stops all components of a service. +// StopService stops all components of a service, or a single component if specified. func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest) (*mcpv1.StopServiceResponse, error) { - a.Logger.Info("StopService", "service", req.GetName()) + a.Logger.Info("StopService", "service", req.GetName(), "component", req.GetComponent()) if req.GetName() == "" { return nil, status.Error(codes.InvalidArgument, "service name is required") @@ -25,6 +25,13 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest) return nil, status.Errorf(codes.Internal, "list components: %v", err) } + if target := req.GetComponent(); target != "" { + components, err = filterComponents(components, req.GetName(), target) + if err != nil { + return nil, err + } + } + var results []*mcpv1.ComponentResult for _, c := range components { containerName := ContainerNameFor(req.GetName(), c.Name) @@ -59,10 +66,10 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest) return &mcpv1.StopServiceResponse{Results: results}, nil } -// StartService starts all components of a service. If a container already -// exists but is stopped, it is removed first so a fresh one can be created. +// StartService starts all components of a service, or a single component if specified. +// If a container already exists but is stopped, it is removed first so a fresh one can be created. func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest) (*mcpv1.StartServiceResponse, error) { - a.Logger.Info("StartService", "service", req.GetName()) + a.Logger.Info("StartService", "service", req.GetName(), "component", req.GetComponent()) if req.GetName() == "" { return nil, status.Error(codes.InvalidArgument, "service name is required") @@ -73,6 +80,13 @@ func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest return nil, status.Errorf(codes.Internal, "list components: %v", err) } + if target := req.GetComponent(); target != "" { + components, err = filterComponents(components, req.GetName(), target) + if err != nil { + return nil, err + } + } + var results []*mcpv1.ComponentResult for _, c := range components { r := startComponent(ctx, a, req.GetName(), &c) @@ -82,10 +96,10 @@ func (a *Agent) StartService(ctx context.Context, req *mcpv1.StartServiceRequest return &mcpv1.StartServiceResponse{Results: results}, nil } -// RestartService restarts all components of a service by stopping, removing, -// and re-creating each container. The desired_state is not changed. +// RestartService restarts all components of a service, or a single component if specified, +// by stopping, removing, and re-creating each container. The desired_state is not changed. func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceRequest) (*mcpv1.RestartServiceResponse, error) { - a.Logger.Info("RestartService", "service", req.GetName()) + a.Logger.Info("RestartService", "service", req.GetName(), "component", req.GetComponent()) if req.GetName() == "" { return nil, status.Error(codes.InvalidArgument, "service name is required") @@ -96,6 +110,13 @@ func (a *Agent) RestartService(ctx context.Context, req *mcpv1.RestartServiceReq return nil, status.Errorf(codes.Internal, "list components: %v", err) } + if target := req.GetComponent(); target != "" { + components, err = filterComponents(components, req.GetName(), target) + if err != nil { + return nil, err + } + } + var results []*mcpv1.ComponentResult for _, c := range components { r := restartComponent(ctx, a, req.GetName(), &c) @@ -167,6 +188,16 @@ func componentToSpec(service string, c *registry.Component) runtime.ContainerSpe } } +// filterComponents returns only the component matching target, or an error if not found. +func filterComponents(components []registry.Component, service, target string) ([]registry.Component, error) { + for _, c := range components { + if c.Name == target { + return []registry.Component{c}, nil + } + } + return nil, status.Errorf(codes.NotFound, "component %q not found in service %q", target, service) +} + // componentExists checks whether a component already exists in the registry. func componentExists(db *sql.DB, service, name string) bool { _, err := registry.GetComponent(db, service, name) diff --git a/proto/mcp/v1/mcp.proto b/proto/mcp/v1/mcp.proto index 6b5a24e..76922c7 100644 --- a/proto/mcp/v1/mcp.proto +++ b/proto/mcp/v1/mcp.proto @@ -92,6 +92,7 @@ message ComponentResult { message StopServiceRequest { string name = 1; + string component = 2; } message StopServiceResponse { @@ -100,6 +101,7 @@ message StopServiceResponse { message StartServiceRequest { string name = 1; + string component = 2; } message StartServiceResponse { @@ -108,6 +110,7 @@ message StartServiceResponse { message RestartServiceRequest { string name = 1; + string component = 2; } message RestartServiceResponse {