From abea3e229e0989b33d3adac6e54b0ae37cddb4d8 Mon Sep 17 00:00:00 2001 From: Anton Ovchinnikov Date: Wed, 5 Nov 2025 23:47:24 +0100 Subject: [PATCH] feat: "exec" command to start processes inside remote containers (#139) --- cmd/uncloud/main.go | 4 + cmd/uncloud/service/exec.go | 130 ++++ cmd/uncloud/service/root.go | 1 + go.mod | 2 +- internal/log/env.go | 19 + internal/machine/api/pb/docker.pb.go | 686 ++++++++++++++---- internal/machine/api/pb/docker.proto | 38 + internal/machine/api/pb/docker_grpc.pb.go | 32 + internal/machine/docker/client_exec.go | 303 ++++++++ internal/machine/docker/server.go | 249 +++++++ pkg/api/client.go | 1 + pkg/api/container_exec.go | 37 + pkg/client/container.go | 46 ++ test/e2e/exec_test.go | 243 +++++++ test/e2e/helpers.go | 1 + website/docs/9-cli-reference/uc.md | 1 + website/docs/9-cli-reference/uc_exec.md | 56 ++ website/docs/9-cli-reference/uc_service.md | 1 + .../docs/9-cli-reference/uc_service_exec.md | 56 ++ 19 files changed, 1773 insertions(+), 133 deletions(-) create mode 100644 cmd/uncloud/service/exec.go create mode 100644 internal/log/env.go create mode 100644 internal/machine/docker/client_exec.go create mode 100644 pkg/api/container_exec.go create mode 100644 test/e2e/exec_test.go create mode 100644 website/docs/9-cli-reference/uc_exec.md create mode 100644 website/docs/9-cli-reference/uc_service_exec.md diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 82833d2b..66057c65 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -16,6 +16,7 @@ import ( "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/fs" + "github.com/psviderski/uncloud/internal/log" "github.com/psviderski/uncloud/internal/version" "github.com/spf13/cobra" ) @@ -26,6 +27,8 @@ type globalOptions struct { } func main() { + log.InitLoggerFromEnv() + opts := globalOptions{} cmd := &cobra.Command{ Use: "uc", @@ -87,6 +90,7 @@ func main() { image.NewRootCommand(), machine.NewRootCommand(), service.NewRootCommand(), + service.NewExecCommand(), service.NewInspectCommand(), service.NewListCommand(), service.NewRmCommand(), diff --git a/cmd/uncloud/service/exec.go b/cmd/uncloud/service/exec.go new file mode 100644 index 00000000..0577cd6c --- /dev/null +++ b/cmd/uncloud/service/exec.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "fmt" + "os" + + "github.com/docker/cli/cli/streams" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type execCliOptions struct { + detach bool + interactive bool + noTty bool + context string + containerId string +} + +var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + +func NewExecCommand() *cobra.Command { + opts := execCliOptions{} + + execCmd := &cobra.Command{ + Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]", + Short: "Execute a command in a running service container", + Long: `Execute a command (interactive shell by default) in a running container within a service. +If the service has multiple replicas, the command will be executed in a random container. + `, + Example: ` + # Start an interactive shell ("bash" or "sh" will be tried by default) + uc exec web-service + + # Start an interactive shell with explicit command + uc exec web-service /bin/zsh + + # List files in the specific container of the service + uc exec --container d792ea7347e5 web-service ls -la + + # Pipe input to a command inside the service container + cat backup.sql | uc exec -T db-service psql -U postgres mydb + + # Run a task in the background (detached mode) + uc exec -d web-service /scripts/cleanup.sh`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + serviceName := args[0] + command := args[1:] + if len(command) == 0 { + command = DEFAULT_COMMAND + } + return runExec(cmd.Context(), uncli, serviceName, command, opts) + }, + } + + execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background") + + execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !cli.IsStdoutTerminal(), + "Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.") + + // Keep "-i" and "-t" flags hidden for compatibility with docker exec + execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached") + execCmd.Flags().MarkHidden("interactive") + + execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY") + execCmd.Flags().MarkHidden("tty") + + execCmd.Flags().StringVarP(&opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)") + + // Common flags + execCmd.Flags().StringVar(&opts.containerId, "container", "", + "ID of the container to exec into (default is the random container of the service)") + + // This tells Cobra that all flags must come before positional arguments, so that + // commands with their own flags can be handled correctly. + execCmd.Flags().SetInterspersed(false) + + return execCmd +} + +func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error { + if !opts.detach { + // Check if we're trying to attach to a TTY from a non-TTY client, e.g. + // when doing an 'cmd | uc exec ...' + stdin := streams.NewIn(os.Stdin) + // TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically + if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil { + return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err) + } + } + + client, err := uncli.ConnectClusterWithOptions(ctx, opts.context, cli.ConnectOptions{ + ShowProgress: false, + }) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + execConfig := api.ExecOptions{ + Command: command, + AttachStdin: opts.interactive, + Tty: !opts.noTty, + Detach: opts.detach, + } + + if !opts.detach { + execConfig.AttachStdout = true + execConfig.AttachStderr = true + } + + exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig) + if err != nil { + return fmt.Errorf("exec container: %w", err) + } + + // For non-detached mode, exit with the same code as the executed command + if !opts.detach { + if exitCode != 0 { + os.Exit(exitCode) + } + } + + return nil +} diff --git a/cmd/uncloud/service/root.go b/cmd/uncloud/service/root.go index e159e672..5a33c541 100644 --- a/cmd/uncloud/service/root.go +++ b/cmd/uncloud/service/root.go @@ -16,6 +16,7 @@ func NewRootCommand() *cobra.Command { NewRmCommand(), NewRunCommand(), NewScaleCommand(), + NewExecCommand(), ) return cmd } diff --git a/go.mod b/go.mod index 3e6b8c41..81f794e6 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/lmittmann/tint v1.0.5 github.com/miekg/dns v1.1.65 github.com/mitchellh/mapstructure v1.5.0 + github.com/moby/term v0.5.2 github.com/muesli/termenv v0.16.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -271,7 +272,6 @@ require ( github.com/moby/sys/symlink v0.3.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect diff --git a/internal/log/env.go b/internal/log/env.go new file mode 100644 index 00000000..784f4cd6 --- /dev/null +++ b/internal/log/env.go @@ -0,0 +1,19 @@ +package log + +import ( + "log/slog" + "os" + "slices" + "strings" +) + +func InitLoggerFromEnv() { + debugValues := []string{"1", "true", "yes"} + if slices.Contains(debugValues, strings.ToLower(os.Getenv("DEBUG"))) { + logger := slog.New(NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + slog.SetDefault(logger) + } + slog.Debug("logger initialized") +} diff --git a/internal/machine/api/pb/docker.pb.go b/internal/machine/api/pb/docker.pb.go index be6332cc..35e44621 100644 --- a/internal/machine/api/pb/docker.pb.go +++ b/internal/machine/api/pb/docker.pb.go @@ -1727,6 +1727,329 @@ func (x *MachineServiceContainers) GetContainers() []*ServiceContainer { return nil } +type ExecContainerRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *ExecContainerRequest_Config + // *ExecContainerRequest_Stdin + // *ExecContainerRequest_Resize + Payload isExecContainerRequest_Payload `protobuf_oneof:"payload"` +} + +func (x *ExecContainerRequest) Reset() { + *x = ExecContainerRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecContainerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecContainerRequest) ProtoMessage() {} + +func (x *ExecContainerRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[32] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecContainerRequest.ProtoReflect.Descriptor instead. +func (*ExecContainerRequest) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{32} +} + +func (m *ExecContainerRequest) GetPayload() isExecContainerRequest_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *ExecContainerRequest) GetConfig() *ExecConfig { + if x, ok := x.GetPayload().(*ExecContainerRequest_Config); ok { + return x.Config + } + return nil +} + +func (x *ExecContainerRequest) GetStdin() []byte { + if x, ok := x.GetPayload().(*ExecContainerRequest_Stdin); ok { + return x.Stdin + } + return nil +} + +func (x *ExecContainerRequest) GetResize() *ResizeEvent { + if x, ok := x.GetPayload().(*ExecContainerRequest_Resize); ok { + return x.Resize + } + return nil +} + +type isExecContainerRequest_Payload interface { + isExecContainerRequest_Payload() +} + +type ExecContainerRequest_Config struct { + // Initial configuration for the exec session. Must be sent as the first message. + Config *ExecConfig `protobuf:"bytes,1,opt,name=config,proto3,oneof"` +} + +type ExecContainerRequest_Stdin struct { + // Raw stdin data to be written to the exec process. + Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` +} + +type ExecContainerRequest_Resize struct { + // TTY resize event (only used when TTY is enabled). + Resize *ResizeEvent `protobuf:"bytes,3,opt,name=resize,proto3,oneof"` +} + +func (*ExecContainerRequest_Config) isExecContainerRequest_Payload() {} + +func (*ExecContainerRequest_Stdin) isExecContainerRequest_Payload() {} + +func (*ExecContainerRequest_Resize) isExecContainerRequest_Payload() {} + +type ExecConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Container ID to execute the command in. + ContainerId string `protobuf:"bytes,1,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"` + // JSON serialised ExecOptions + Options []byte `protobuf:"bytes,2,opt,name=options,proto3" json:"options,omitempty"` +} + +func (x *ExecConfig) Reset() { + *x = ExecConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecConfig) ProtoMessage() {} + +func (x *ExecConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[33] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecConfig.ProtoReflect.Descriptor instead. +func (*ExecConfig) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{33} +} + +func (x *ExecConfig) GetContainerId() string { + if x != nil { + return x.ContainerId + } + return "" +} + +func (x *ExecConfig) GetOptions() []byte { + if x != nil { + return x.Options + } + return nil +} + +type ResizeEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Height uint32 `protobuf:"varint,1,opt,name=height,proto3" json:"height,omitempty"` + Width uint32 `protobuf:"varint,2,opt,name=width,proto3" json:"width,omitempty"` +} + +func (x *ResizeEvent) Reset() { + *x = ResizeEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResizeEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResizeEvent) ProtoMessage() {} + +func (x *ResizeEvent) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[34] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResizeEvent.ProtoReflect.Descriptor instead. +func (*ResizeEvent) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{34} +} + +func (x *ResizeEvent) GetHeight() uint32 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *ResizeEvent) GetWidth() uint32 { + if x != nil { + return x.Width + } + return 0 +} + +type ExecContainerResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Payload: + // + // *ExecContainerResponse_ExecId + // *ExecContainerResponse_Stdout + // *ExecContainerResponse_Stderr + // *ExecContainerResponse_ExitCode + Payload isExecContainerResponse_Payload `protobuf_oneof:"payload"` +} + +func (x *ExecContainerResponse) Reset() { + *x = ExecContainerResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ExecContainerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecContainerResponse) ProtoMessage() {} + +func (x *ExecContainerResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_machine_api_pb_docker_proto_msgTypes[35] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecContainerResponse.ProtoReflect.Descriptor instead. +func (*ExecContainerResponse) Descriptor() ([]byte, []int) { + return file_internal_machine_api_pb_docker_proto_rawDescGZIP(), []int{35} +} + +func (m *ExecContainerResponse) GetPayload() isExecContainerResponse_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *ExecContainerResponse) GetExecId() string { + if x, ok := x.GetPayload().(*ExecContainerResponse_ExecId); ok { + return x.ExecId + } + return "" +} + +func (x *ExecContainerResponse) GetStdout() []byte { + if x, ok := x.GetPayload().(*ExecContainerResponse_Stdout); ok { + return x.Stdout + } + return nil +} + +func (x *ExecContainerResponse) GetStderr() []byte { + if x, ok := x.GetPayload().(*ExecContainerResponse_Stderr); ok { + return x.Stderr + } + return nil +} + +func (x *ExecContainerResponse) GetExitCode() int32 { + if x, ok := x.GetPayload().(*ExecContainerResponse_ExitCode); ok { + return x.ExitCode + } + return 0 +} + +type isExecContainerResponse_Payload interface { + isExecContainerResponse_Payload() +} + +type ExecContainerResponse_ExecId struct { + // Exec instance ID returned after creating the exec. + ExecId string `protobuf:"bytes,1,opt,name=exec_id,json=execId,proto3,oneof"` +} + +type ExecContainerResponse_Stdout struct { + // Raw stdout data from the exec process. + Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3,oneof"` +} + +type ExecContainerResponse_Stderr struct { + // Raw stderr data from the exec process (only when TTY is disabled). + Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3,oneof"` +} + +type ExecContainerResponse_ExitCode struct { + // Exit code of the exec process. Sent as the final message. + ExitCode int32 `protobuf:"varint,4,opt,name=exit_code,json=exitCode,proto3,oneof"` +} + +func (*ExecContainerResponse_ExecId) isExecContainerResponse_Payload() {} + +func (*ExecContainerResponse_Stdout) isExecContainerResponse_Payload() {} + +func (*ExecContainerResponse_Stderr) isExecContainerResponse_Payload() {} + +func (*ExecContainerResponse_ExitCode) isExecContainerResponse_Payload() {} + var File_internal_machine_api_pb_docker_proto protoreflect.FileDescriptor var file_internal_machine_api_pb_docker_proto_rawDesc = []byte{ @@ -1887,91 +2210,123 @@ var file_internal_machine_api_pb_docker_proto_rawDesc = []byte{ 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x32, 0xfb, - 0x09, 0x0a, 0x06, 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, - 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x65, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x22, 0x90, + 0x01, 0x0a, 0x14, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, + 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, 0x00, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x16, 0x0a, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x64, 0x69, 0x6e, 0x12, 0x2a, 0x0a, 0x06, 0x72, 0x65, + 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x52, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x06, + 0x72, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, + 0x64, 0x22, 0x49, 0x0a, 0x0a, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3b, 0x0a, 0x0b, + 0x52, 0x65, 0x73, 0x69, 0x7a, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x68, + 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, 0x90, 0x01, 0x0a, 0x15, 0x45, 0x78, + 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x07, 0x65, 0x78, 0x65, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x65, 0x78, 0x65, 0x63, 0x49, 0x64, 0x12, 0x18, + 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, + 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, + 0x72, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, + 0x72, 0x72, 0x12, 0x1d, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, + 0x65, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x32, 0xc7, 0x0a, 0x0a, + 0x06, 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x4c, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, - 0x0a, 0x0d, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, - 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, - 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x12, 0x49, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, - 0x6e, 0x65, 0x72, 0x73, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, - 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, - 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, - 0x0f, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, - 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, - 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, - 0x67, 0x65, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x4a, 0x53, 0x4f, 0x4e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, - 0x0c, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, - 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x55, 0x0a, 0x12, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, - 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, - 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, - 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, - 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, - 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, - 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x40, 0x0a, 0x0c, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, - 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, - 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, - 0x79, 0x12, 0x5a, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, - 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, - 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, - 0x17, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, - 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, - 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x5e, 0x0a, - 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, - 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, - 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, - 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, - 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, - 0x16, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, - 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, - 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, + 0x73, 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, + 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, + 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x37, 0x5a, 0x35, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, - 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x42, 0x0a, 0x0d, + 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, + 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x12, 0x49, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, + 0x72, 0x73, 0x12, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, + 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, + 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0f, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, + 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x09, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, + 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x75, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4a, 0x53, + 0x4f, 0x4e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x30, 0x01, 0x12, 0x43, 0x0a, 0x0c, 0x49, + 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, 0x70, + 0x65, 0x63, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x55, 0x0a, 0x12, 0x49, 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, + 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, + 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, + 0x70, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x49, + 0x6d, 0x61, 0x67, 0x65, 0x73, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x56, 0x6f, 0x6c, + 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x4c, + 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x6f, + 0x6c, 0x75, 0x6d, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, + 0x0c, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x18, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, + 0x5a, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, + 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x17, 0x49, + 0x6e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x6e, 0x73, + 0x70, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x5e, 0x0a, 0x15, 0x4c, + 0x69, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, + 0x6e, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, + 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x16, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, + 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4a, 0x0a, 0x0d, 0x45, 0x78, + 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x12, 0x19, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x78, 0x65, + 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, + 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1986,7 +2341,7 @@ func file_internal_machine_api_pb_docker_proto_rawDescGZIP() []byte { return file_internal_machine_api_pb_docker_proto_rawDescData } -var file_internal_machine_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_internal_machine_api_pb_docker_proto_msgTypes = make([]protoimpl.MessageInfo, 36) var file_internal_machine_api_pb_docker_proto_goTypes = []any{ (*CreateContainerRequest)(nil), // 0: api.CreateContainerRequest (*CreateContainerResponse)(nil), // 1: api.CreateContainerResponse @@ -2020,62 +2375,70 @@ var file_internal_machine_api_pb_docker_proto_goTypes = []any{ (*ListServiceContainersRequest)(nil), // 29: api.ListServiceContainersRequest (*ListServiceContainersResponse)(nil), // 30: api.ListServiceContainersResponse (*MachineServiceContainers)(nil), // 31: api.MachineServiceContainers - (*Metadata)(nil), // 32: api.Metadata - (*emptypb.Empty)(nil), // 33: google.protobuf.Empty + (*ExecContainerRequest)(nil), // 32: api.ExecContainerRequest + (*ExecConfig)(nil), // 33: api.ExecConfig + (*ResizeEvent)(nil), // 34: api.ResizeEvent + (*ExecContainerResponse)(nil), // 35: api.ExecContainerResponse + (*Metadata)(nil), // 36: api.Metadata + (*emptypb.Empty)(nil), // 37: google.protobuf.Empty } var file_internal_machine_api_pb_docker_proto_depIdxs = []int32{ 8, // 0: api.ListContainersResponse.messages:type_name -> api.MachineContainers - 32, // 1: api.MachineContainers.metadata:type_name -> api.Metadata + 36, // 1: api.MachineContainers.metadata:type_name -> api.Metadata 14, // 2: api.InspectImageResponse.messages:type_name -> api.Image - 32, // 3: api.Image.metadata:type_name -> api.Metadata + 36, // 3: api.Image.metadata:type_name -> api.Metadata 17, // 4: api.InspectRemoteImageResponse.messages:type_name -> api.RemoteImage - 32, // 5: api.RemoteImage.metadata:type_name -> api.Metadata + 36, // 5: api.RemoteImage.metadata:type_name -> api.Metadata 20, // 6: api.ListImagesResponse.messages:type_name -> api.MachineImages - 32, // 7: api.MachineImages.metadata:type_name -> api.Metadata + 36, // 7: api.MachineImages.metadata:type_name -> api.Metadata 25, // 8: api.ListVolumesResponse.messages:type_name -> api.MachineVolumes - 32, // 9: api.MachineVolumes.metadata:type_name -> api.Metadata + 36, // 9: api.MachineVolumes.metadata:type_name -> api.Metadata 31, // 10: api.ListServiceContainersResponse.messages:type_name -> api.MachineServiceContainers - 32, // 11: api.MachineServiceContainers.metadata:type_name -> api.Metadata + 36, // 11: api.MachineServiceContainers.metadata:type_name -> api.Metadata 28, // 12: api.MachineServiceContainers.containers:type_name -> api.ServiceContainer - 0, // 13: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest - 2, // 14: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest - 4, // 15: api.Docker.StartContainer:input_type -> api.StartContainerRequest - 5, // 16: api.Docker.StopContainer:input_type -> api.StopContainerRequest - 6, // 17: api.Docker.ListContainers:input_type -> api.ListContainersRequest - 9, // 18: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest - 10, // 19: api.Docker.PullImage:input_type -> api.PullImageRequest - 12, // 20: api.Docker.InspectImage:input_type -> api.InspectImageRequest - 15, // 21: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest - 18, // 22: api.Docker.ListImages:input_type -> api.ListImagesRequest - 21, // 23: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest - 23, // 24: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest - 26, // 25: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest - 27, // 26: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest - 2, // 27: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest - 29, // 28: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest - 9, // 29: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest - 1, // 30: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse - 3, // 31: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse - 33, // 32: api.Docker.StartContainer:output_type -> google.protobuf.Empty - 33, // 33: api.Docker.StopContainer:output_type -> google.protobuf.Empty - 7, // 34: api.Docker.ListContainers:output_type -> api.ListContainersResponse - 33, // 35: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty - 11, // 36: api.Docker.PullImage:output_type -> api.JSONMessage - 13, // 37: api.Docker.InspectImage:output_type -> api.InspectImageResponse - 16, // 38: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse - 19, // 39: api.Docker.ListImages:output_type -> api.ListImagesResponse - 22, // 40: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse - 24, // 41: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse - 33, // 42: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty - 1, // 43: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse - 28, // 44: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer - 30, // 45: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse - 33, // 46: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty - 30, // [30:47] is the sub-list for method output_type - 13, // [13:30] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 33, // 13: api.ExecContainerRequest.config:type_name -> api.ExecConfig + 34, // 14: api.ExecContainerRequest.resize:type_name -> api.ResizeEvent + 0, // 15: api.Docker.CreateContainer:input_type -> api.CreateContainerRequest + 2, // 16: api.Docker.InspectContainer:input_type -> api.InspectContainerRequest + 4, // 17: api.Docker.StartContainer:input_type -> api.StartContainerRequest + 5, // 18: api.Docker.StopContainer:input_type -> api.StopContainerRequest + 6, // 19: api.Docker.ListContainers:input_type -> api.ListContainersRequest + 9, // 20: api.Docker.RemoveContainer:input_type -> api.RemoveContainerRequest + 10, // 21: api.Docker.PullImage:input_type -> api.PullImageRequest + 12, // 22: api.Docker.InspectImage:input_type -> api.InspectImageRequest + 15, // 23: api.Docker.InspectRemoteImage:input_type -> api.InspectRemoteImageRequest + 18, // 24: api.Docker.ListImages:input_type -> api.ListImagesRequest + 21, // 25: api.Docker.CreateVolume:input_type -> api.CreateVolumeRequest + 23, // 26: api.Docker.ListVolumes:input_type -> api.ListVolumesRequest + 26, // 27: api.Docker.RemoveVolume:input_type -> api.RemoveVolumeRequest + 27, // 28: api.Docker.CreateServiceContainer:input_type -> api.CreateServiceContainerRequest + 2, // 29: api.Docker.InspectServiceContainer:input_type -> api.InspectContainerRequest + 29, // 30: api.Docker.ListServiceContainers:input_type -> api.ListServiceContainersRequest + 9, // 31: api.Docker.RemoveServiceContainer:input_type -> api.RemoveContainerRequest + 32, // 32: api.Docker.ExecContainer:input_type -> api.ExecContainerRequest + 1, // 33: api.Docker.CreateContainer:output_type -> api.CreateContainerResponse + 3, // 34: api.Docker.InspectContainer:output_type -> api.InspectContainerResponse + 37, // 35: api.Docker.StartContainer:output_type -> google.protobuf.Empty + 37, // 36: api.Docker.StopContainer:output_type -> google.protobuf.Empty + 7, // 37: api.Docker.ListContainers:output_type -> api.ListContainersResponse + 37, // 38: api.Docker.RemoveContainer:output_type -> google.protobuf.Empty + 11, // 39: api.Docker.PullImage:output_type -> api.JSONMessage + 13, // 40: api.Docker.InspectImage:output_type -> api.InspectImageResponse + 16, // 41: api.Docker.InspectRemoteImage:output_type -> api.InspectRemoteImageResponse + 19, // 42: api.Docker.ListImages:output_type -> api.ListImagesResponse + 22, // 43: api.Docker.CreateVolume:output_type -> api.CreateVolumeResponse + 24, // 44: api.Docker.ListVolumes:output_type -> api.ListVolumesResponse + 37, // 45: api.Docker.RemoveVolume:output_type -> google.protobuf.Empty + 1, // 46: api.Docker.CreateServiceContainer:output_type -> api.CreateContainerResponse + 28, // 47: api.Docker.InspectServiceContainer:output_type -> api.ServiceContainer + 30, // 48: api.Docker.ListServiceContainers:output_type -> api.ListServiceContainersResponse + 37, // 49: api.Docker.RemoveServiceContainer:output_type -> google.protobuf.Empty + 35, // 50: api.Docker.ExecContainer:output_type -> api.ExecContainerResponse + 33, // [33:51] is the sub-list for method output_type + 15, // [15:33] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_internal_machine_api_pb_docker_proto_init() } @@ -2469,6 +2832,65 @@ func file_internal_machine_api_pb_docker_proto_init() { return nil } } + file_internal_machine_api_pb_docker_proto_msgTypes[32].Exporter = func(v any, i int) any { + switch v := v.(*ExecContainerRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_machine_api_pb_docker_proto_msgTypes[33].Exporter = func(v any, i int) any { + switch v := v.(*ExecConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_machine_api_pb_docker_proto_msgTypes[34].Exporter = func(v any, i int) any { + switch v := v.(*ResizeEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_internal_machine_api_pb_docker_proto_msgTypes[35].Exporter = func(v any, i int) any { + switch v := v.(*ExecContainerResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_internal_machine_api_pb_docker_proto_msgTypes[32].OneofWrappers = []any{ + (*ExecContainerRequest_Config)(nil), + (*ExecContainerRequest_Stdin)(nil), + (*ExecContainerRequest_Resize)(nil), + } + file_internal_machine_api_pb_docker_proto_msgTypes[35].OneofWrappers = []any{ + (*ExecContainerResponse_ExecId)(nil), + (*ExecContainerResponse_Stdout)(nil), + (*ExecContainerResponse_Stderr)(nil), + (*ExecContainerResponse_ExitCode)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -2476,7 +2898,7 @@ func file_internal_machine_api_pb_docker_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_internal_machine_api_pb_docker_proto_rawDesc, NumEnums: 0, - NumMessages: 32, + NumMessages: 36, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/machine/api/pb/docker.proto b/internal/machine/api/pb/docker.proto index 966d320c..aa6b609a 100644 --- a/internal/machine/api/pb/docker.proto +++ b/internal/machine/api/pb/docker.proto @@ -30,6 +30,8 @@ service Docker { rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer); rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse); rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty); + + rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse); } message CreateContainerRequest { @@ -214,3 +216,39 @@ message MachineServiceContainers { Metadata metadata = 1; repeated ServiceContainer containers = 2; } + +message ExecContainerRequest { + oneof payload { + // Initial configuration for the exec session. Must be sent as the first message. + ExecConfig config = 1; + // Raw stdin data to be written to the exec process. + bytes stdin = 2; + // TTY resize event (only used when TTY is enabled). + ResizeEvent resize = 3; + } +} + +message ExecConfig { + // Container ID to execute the command in. + string container_id = 1; + // JSON serialised ExecOptions + bytes options = 2; +} + +message ResizeEvent { + uint32 height = 1; + uint32 width = 2; +} + +message ExecContainerResponse { + oneof payload { + // Exec instance ID returned after creating the exec. + string exec_id = 1; + // Raw stdout data from the exec process. + bytes stdout = 2; + // Raw stderr data from the exec process (only when TTY is disabled). + bytes stderr = 3; + // Exit code of the exec process. Sent as the final message. + int32 exit_code = 4; + } +} diff --git a/internal/machine/api/pb/docker_grpc.pb.go b/internal/machine/api/pb/docker_grpc.pb.go index f60cfb8b..27024d57 100644 --- a/internal/machine/api/pb/docker_grpc.pb.go +++ b/internal/machine/api/pb/docker_grpc.pb.go @@ -37,6 +37,7 @@ const ( Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer" Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers" Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer" + Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer" ) // DockerClient is the client API for Docker service. @@ -62,6 +63,7 @@ type DockerClient interface { InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error) ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error) RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) } type dockerClient struct { @@ -251,6 +253,19 @@ func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveCon return out, nil } +func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ExecContainer_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse] + // DockerServer is the server API for Docker service. // All implementations must embed UnimplementedDockerServer // for forward compatibility. @@ -274,6 +289,7 @@ type DockerServer interface { InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error) ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) + ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error mustEmbedUnimplementedDockerServer() } @@ -335,6 +351,9 @@ func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListSer func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented") } +func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error { + return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented") +} func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {} func (UnimplementedDockerServer) testEmbeddedByValue() {} @@ -655,6 +674,13 @@ func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse] + // Docker_ServiceDesc is the grpc.ServiceDesc for Docker service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -733,6 +759,12 @@ var Docker_ServiceDesc = grpc.ServiceDesc{ Handler: _Docker_PullImage_Handler, ServerStreams: true, }, + { + StreamName: "ExecContainer", + Handler: _Docker_ExecContainer_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "internal/machine/api/pb/docker.proto", } diff --git a/internal/machine/docker/client_exec.go b/internal/machine/docker/client_exec.go new file mode 100644 index 00000000..c14f13e9 --- /dev/null +++ b/internal/machine/docker/client_exec.go @@ -0,0 +1,303 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + + "github.com/moby/term" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/psviderski/uncloud/pkg/api" + "golang.org/x/sync/errgroup" + "golang.org/x/sys/unix" +) + +// ExecConfig contains options for executing a command in a container. +type ExecConfig struct { + // Container ID to execute the command in. + ContainerID string + // Exec configuration. + Options api.ExecOptions +} + +// sendResizeRequest sends a terminal resize request to the exec stream. +func sendResizeRequest(stream pb.Docker_ExecContainerClient, size *term.Winsize) error { + slog.Debug("sending resize request", "width", size.Width, "height", size.Height) + return stream.Send( + &pb.ExecContainerRequest{ + Payload: &pb.ExecContainerRequest_Resize{ + Resize: &pb.ResizeEvent{ + Height: uint32(size.Height), + Width: uint32(size.Width), + }, + }, + }) +} + +// setupTerminal configures the terminal for interactive TTY sessions. +// It checks if stdin is a terminal, sets it to raw mode, and sets up resize handling. +// Returns a cleanup function to restore terminal state, or an error. +func setupTerminal(ctx context.Context, stream pb.Docker_ExecContainerClient) (func(), error) { + inFd, isTerminal := term.GetFdInfo(os.Stdin) + if !isTerminal { + return nil, fmt.Errorf("stdin is not a terminal") + } + + // Set terminal to raw mode + oldState, err := term.SetRawTerminal(inFd) + if err != nil { + return nil, fmt.Errorf("set raw terminal: %w", err) + } + + // Cleanup function + restoreFunc := func() { + _ = term.RestoreTerminal(inFd, oldState) + } + + // Set up resize handling + if err := handleTerminalResize(ctx, inFd, stream); err != nil { + restoreFunc() + return nil, err + } + + return restoreFunc, nil +} + +// handleTerminalResize sends initial window size and handles window resize signals for TTY sessions. +func handleTerminalResize(ctx context.Context, inFd uintptr, stream pb.Docker_ExecContainerClient) error { + // Handle window resize signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, unix.SIGWINCH) + + // Send initial window size + if size, err := term.GetWinsize(inFd); err == nil { + _ = sendResizeRequest(stream, size) + } + + go func() { + defer signal.Stop(sigCh) + for { + select { + case <-ctx.Done(): + return + case <-sigCh: + size, err := term.GetWinsize(inFd) + if err != nil { + slog.Debug("get window size", "error", err) + continue + } + if err = sendResizeRequest(stream, size); err != nil { + slog.Debug("send resize request", "error", err) + } + } + } + }() + + return nil +} + +// handleClientInputStream reads from stdin and sends data to the remote server. +// It also periodically checks for context cancellation to exit gracefully when e.g. +// the output stream is closed. +func handleClientInputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdin io.Reader) error { + slog.Debug("Input goroutine started") + defer slog.Debug("Input goroutine exited") + + defer stream.CloseSend() + + // Channel to receive stdin data + stdinCh := make(chan []byte) + + stdinErrCh := make(chan error, 1) + + // Read from stdin in a separate goroutine + // Note: this goroutine may continue blocking on Read even after we exit from the function, + // but that's OK - it will eventually unblock when data arrives or stdin closes. + go func() { + buf := make([]byte, 32*1024) // 32KB buffer + for { + n, err := stdin.Read(buf) + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + select { + case stdinCh <- data: + case <-ctx.Done(): + slog.Debug("stdin reader exiting due to context done") + return + } + } + if err != nil { + if err == io.EOF { + slog.Debug("stdin reader: EOF received") + } else { + slog.Debug("stdin reader error", "error", err) + } + stdinErrCh <- err + return + } + } + }() + + // Send stdin data to the server or exit when context is cancelled + for { + select { + case <-ctx.Done(): + return nil + case data := <-stdinCh: + if err := stream.Send(&pb.ExecContainerRequest{ + Payload: &pb.ExecContainerRequest_Stdin{Stdin: data}, + }); err != nil { + return fmt.Errorf("send stdin: %w", err) + } + case err := <-stdinErrCh: + if err != io.EOF { + return fmt.Errorf("read stdin: %w", err) + } + return nil + } + } +} + +// handleClientOutputStream receives output from the exec stream and writes to stdout/stderr. +// It also captures the exit code and signals completion via context cancellation. +func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdout, stderr io.Writer, exitCode *int) error { + slog.Debug("Output goroutine started") + defer slog.Debug("Output goroutine exited") + + for { + resp, err := stream.Recv() + if err == io.EOF { + slog.Debug("output stream: EOF received") + return nil + } + if err != nil { + return fmt.Errorf("receive from stream: %w", err) + } + + switch payload := resp.Payload.(type) { + case *pb.ExecContainerResponse_ExecId: + // This is sent first; we already processed it earlier, so just ignore duplicates. + case *pb.ExecContainerResponse_Stdout: + if _, err := stdout.Write(payload.Stdout); err != nil { + return fmt.Errorf("write stdout: %w", err) + } + case *pb.ExecContainerResponse_Stderr: + if _, err := stderr.Write(payload.Stderr); err != nil { + return fmt.Errorf("write stderr: %w", err) + } + case *pb.ExecContainerResponse_ExitCode: + slog.Debug("received exit code", "code", payload.ExitCode) + *exitCode = int(payload.ExitCode) + return nil + } + } +} + +// ExecContainer executes a command in a running container with bidirectional streaming. +// TODO: This can be merged with pkg/client as it's an unnecessary logic split. +func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) { + // TODO: We need to handle Ctrl-C and other signals here to forward them to the container process. + // Right now, Ctrl-C will just terminate the client process, which is not ideal. + // We should catch the signal, send it to the container process, and only exit + // when the container process exits. + + slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options) + + // Initialize exit code to non-zero in case we have to return early + exitCode = 1 + + // Set up I/O streams - use custom streams if provided, otherwise default to os.Stdin/Stdout/Stderr + stdin := io.Reader(os.Stdin) + stdout := io.Writer(os.Stdout) + stderr := io.Writer(os.Stderr) + + if opts.Options.Stdin != nil { + stdin = opts.Options.Stdin + } + if opts.Options.Stdout != nil { + stdout = opts.Options.Stdout + } + if opts.Options.Stderr != nil { + stderr = opts.Options.Stderr + } + + // Marshal the exec config + configBytes, err := json.Marshal(opts.Options) + if err != nil { + return -1, fmt.Errorf("marshal exec config: %w", err) + } + + // Create the bidirectional stream + stream, err := c.GRPCClient.ExecContainer(ctx) + if err != nil { + return -1, fmt.Errorf("create exec stream: %w", err) + } + + // Send the initial configuration + if err := stream.Send(&pb.ExecContainerRequest{ + Payload: &pb.ExecContainerRequest_Config{ + Config: &pb.ExecConfig{ + ContainerId: opts.ContainerID, + Options: configBytes, + }, + }, + }); err != nil { + return -1, fmt.Errorf("send exec config: %w", err) + } + + // Receive the exec ID + resp, err := stream.Recv() + if err != nil { + return -1, fmt.Errorf("receive exec ID: %w", err) + } + execID := resp.GetExecId() + if execID == "" { + return -1, fmt.Errorf("expected exec ID in first response") + } + + errGroup, ctx := errgroup.WithContext(ctx) + + // Create cancellable context for goroutine coordination + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Handle terminal setup for interactive sessions + if opts.Options.AttachStdin && opts.Options.Tty { + restoreTerminal, err := setupTerminal(ctx, stream) + if err != nil { + return -1, fmt.Errorf("setup terminal: %w", err) + } + if restoreTerminal != nil { + defer restoreTerminal() + } + } + + // Handle stdin stream if needed + if opts.Options.AttachStdin { + errGroup.Go(func() error { + return handleClientInputStream(ctx, stream, stdin) + }) + } else { + // Close send direction immediately if not attaching stdin + stream.CloseSend() + } + + // Handle output streams (stdout/stderr) + errGroup.Go(func() error { + defer cancel() + return handleClientOutputStream(ctx, stream, stdout, stderr, &exitCode) + }) + + err = errGroup.Wait() + + if err == nil && opts.Options.Detach { + return 0, nil + } + + return exitCode, err +} diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index b5f3807f..b5869468 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -23,6 +23,7 @@ import ( "github.com/distribution/reference" dockercommand "github.com/docker/cli/cli/command" dockerconfig "github.com/docker/cli/cli/config" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" @@ -30,6 +31,7 @@ import ( "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -1012,3 +1014,250 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta return resp, nil } + +// receiveExecConfig receives and validates the initial exec configuration from the stream. +func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) { + req, err := stream.Recv() + if err != nil { + return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "receive config: %v", err) + } + + execConfig := req.GetConfig() + if execConfig == nil { + return nil, api.ExecOptions{}, status.Error(codes.InvalidArgument, "first message must contain exec config") + } + + // Unmarshal the Uncloud's execOpts + var execOpts api.ExecOptions + if err := json.Unmarshal(execConfig.Options, &execOpts); err != nil { + return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "unmarshal exec config: %v", err) + } + + return execConfig, execOpts, nil +} + +// handleServerExecInput reads from the gRPC stream and writes to Docker stdin, handling resize requests. +func (s *Server) handleServerExecInput( + ctx context.Context, + stream pb.Docker_ExecContainerServer, + attachConn types.HijackedResponse, + execID string, + tty bool, +) error { + slog.Debug("Input goroutine started", "exec_id", execID, "tty", tty) + defer slog.Debug("Input goroutine exited", "exec_id", execID) + + defer attachConn.CloseWrite() + for { + req, err := stream.Recv() + switch { + case errors.Is(err, io.EOF): + slog.Debug("Input goroutine received EOF", "exec_id", execID) + return nil + case status.Code(err) == codes.Canceled: + // Can be the case when the output goroutine ends and the stream context is canceled. + slog.Debug("Input goroutine context canceled", "exec_id", execID) + return nil + case err == nil: + // continue processing + default: + return fmt.Errorf("receive from stream: %w", err) + } + + switch payload := req.Payload.(type) { + case *pb.ExecContainerRequest_Stdin: + if _, err := attachConn.Conn.Write(payload.Stdin); err != nil { + return fmt.Errorf("write to stdin: %w", err) + } + case *pb.ExecContainerRequest_Resize: + if tty { + resizeOpts := container.ResizeOptions{ + Height: uint(payload.Resize.Height), + Width: uint(payload.Resize.Width), + } + if err := s.client.ContainerExecResize(ctx, execID, resizeOpts); err != nil { + slog.Warn("Failed to resize TTY", "err", err, "exec_id", execID) + } + } + } + } +} + +// handleServerExecOutput reads from Docker stdout/stderr and writes to the gRPC stream. +func (s *Server) handleServerExecOutput( + stream pb.Docker_ExecContainerServer, + attachResp types.HijackedResponse, + execID string, + tty bool, +) error { + slog.Debug("Output goroutine started", "exec_id", execID, "tty", tty) + defer slog.Debug("Output goroutine exited", "exec_id", execID) + + if tty { + // In TTY mode, all output is stdout - copy directly to stream + stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false} + _, err := io.Copy(stdoutWriter, attachResp.Reader) + if err != nil && err != io.EOF { + return fmt.Errorf("copy tty output: %w", err) + } + return nil + } else { + // In non-TTY mode, Docker multiplexes stdout/stderr with headers + // Use stdcopy to demultiplex + slog.Debug("Starting StdCopy for non-TTY", "exec_id", execID) + stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false} + stderrWriter := &grpcStreamWriter{stream: stream, isStderr: true} + + written, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attachResp.Reader) + slog.Debug("StdCopy completed", "exec_id", execID, "bytes", written, "err", err) + if err != nil && err != io.EOF { + return fmt.Errorf("demultiplex docker output: %w", err) + } + return nil + } +} + +// grpcStreamWriter is a writer that sends data to a gRPC stream as stdout or stderr. +type grpcStreamWriter struct { + stream pb.Docker_ExecContainerServer + isStderr bool +} + +func (w *grpcStreamWriter) Write(p []byte) (n int, err error) { + data := make([]byte, len(p)) + copy(data, p) + + var resp *pb.ExecContainerResponse + if w.isStderr { + resp = &pb.ExecContainerResponse{ + Payload: &pb.ExecContainerResponse_Stderr{Stderr: data}, + } + } else { + resp = &pb.ExecContainerResponse{ + Payload: &pb.ExecContainerResponse_Stdout{Stdout: data}, + } + } + + if err := w.stream.Send(resp); err != nil { + return 0, err + } + return len(p), nil +} + +// ExecContainer executes a command in a running container with bidirectional streaming for stdin/stdout/stderr. +func (s *Server) ExecContainer(stream pb.Docker_ExecContainerServer) error { + slog.Debug("ExecContainer server-side called") + defer slog.Debug("ExecContainer server-side ended") + + ctx := stream.Context() + + // Receive and validate configuration + execConfig, execOpts, err := s.receiveExecConfig(stream) + if err != nil { + return err + } + + // Convert to Docker's ExecOptions + dockerExecOpts := container.ExecOptions{ + Cmd: execOpts.Command, + AttachStdin: execOpts.AttachStdin, + AttachStdout: execOpts.AttachStdout, + AttachStderr: execOpts.AttachStderr, + Tty: execOpts.Tty, + User: execOpts.User, + Privileged: execOpts.Privileged, + WorkingDir: execOpts.WorkingDir, + Env: execOpts.Env, + } + + // Create the exec instance + execResp, err := s.client.ContainerExecCreate(ctx, execConfig.ContainerId, dockerExecOpts) + if err != nil { + if errdefs.IsNotFound(err) { + return status.Error(codes.NotFound, err.Error()) + } + return status.Errorf(codes.Internal, "create exec: %v", err) + } + + // Send the exec ID back to the client + if err := stream.Send(&pb.ExecContainerResponse{ + Payload: &pb.ExecContainerResponse_ExecId{ExecId: execResp.ID}, + }); err != nil { + return status.Errorf(codes.Internal, "send exec ID: %v", err) + } + slog.Debug("Sent exec ID to the client", "exec_id", execResp.ID) + + // For detached mode, start without attaching + if execOpts.Detach { + dockerStartOpts := container.ExecStartOptions{ + Tty: dockerExecOpts.Tty, + Detach: true, + } + if err := s.client.ContainerExecStart(ctx, execResp.ID, dockerStartOpts); err != nil { + return status.Errorf(codes.Internal, "start exec: %v", err) + } + return nil + } + + // For attached mode, attach to the exec instance + attachOpts := container.ExecAttachOptions{ + Tty: dockerExecOpts.Tty, + } + attachConn, err := s.client.ContainerExecAttach(ctx, execResp.ID, attachOpts) + if err != nil { + return status.Errorf(codes.Internal, "attach to exec: %v", err) + } + defer attachConn.Close() + + // Create a cancelable context for the handlers + handlerCtx, cancel := context.WithCancel(ctx) + defer cancel() // Ensure handlers are canceled when we return + + // Create a channel to wait for output completion + outputDone := make(chan error, 1) + + // Start stdin handler if stdin is attached + if dockerExecOpts.AttachStdin { + go func() { + err := s.handleServerExecInput(handlerCtx, stream, attachConn, execResp.ID, dockerExecOpts.Tty) + if err != nil { + slog.Warn("Error in exec input handler", "err", err, "exec_id", execResp.ID) + } + }() + } else { + // If not attaching stdin, close the write side immediately + attachConn.CloseWrite() + } + + // Start output handler + // We only wait for this goroutine to complete - it signals when the exec process finishes + go func() { + outputDone <- s.handleServerExecOutput(stream, attachConn, execResp.ID, dockerExecOpts.Tty) + }() + + // Wait for the output goroutine to complete (it signals when done) + // We only wait for output, not for stdin goroutine. + if err := <-outputDone; err != nil { + slog.Warn("Error in exec output handler", "err", err, "exec_id", execResp.ID) + } + // The stdin goroutine may still be blocked in stream.Recv() waiting for client data, + // so cancel it explicitly. + cancel() + + inspectResp, err := s.client.ContainerExecInspect(ctx, execResp.ID) + if err != nil { + slog.Error("Failed to inspect exec after completion", "err", err, "exec_id", execResp.ID) + return status.Errorf(codes.Internal, "inspect exec: %v", err) + } + + // Send the exit code + slog.Debug("Sending exec exit code", "exec_id", execResp.ID, "exit_code", inspectResp.ExitCode) + if err := stream.Send(&pb.ExecContainerResponse{ + Payload: &pb.ExecContainerResponse_ExitCode{ExitCode: int32(inspectResp.ExitCode)}, + }); err != nil { + slog.Error("Failed to send exec exit code", "err", err, "exec_id", execResp.ID) + return status.Errorf(codes.Internal, "send exit code: %v", err) + } + + return nil +} diff --git a/pkg/api/client.go b/pkg/api/client.go index 1a7f688f..9c541300 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -28,6 +28,7 @@ type ContainerClient interface { RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error + ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error) } type DNSClient interface { diff --git a/pkg/api/container_exec.go b/pkg/api/container_exec.go new file mode 100644 index 00000000..c55f90e1 --- /dev/null +++ b/pkg/api/container_exec.go @@ -0,0 +1,37 @@ +package api + +import "io" + +// ExecOptions contains configuration for executing a command in a container. +type ExecOptions struct { + // Command is the command to run in the container. + Command []string + // AttachStdin attaches the stdin stream to the exec session. + AttachStdin bool + // AttachStdout attaches the stdout stream to the exec session. + AttachStdout bool + // AttachStderr attaches the stderr stream to the exec session. + AttachStderr bool + // Tty allocates a pseudo-TTY for the exec session. + Tty bool + // Detach runs the command in the background without attaching to streams. + Detach bool + + //// Not yet implemented fields + // User specifies the user to run the command as. + User string + // Privileged runs the command in privileged mode. + Privileged bool + // WorkingDir sets the working directory for the command. + WorkingDir string + // Env sets environment variables for the command. + Env []string + + // Client-side only fields (not serialized, not sent to server) + // Stdin is the input stream. Defaults to os.Stdin if nil. + Stdin io.Reader `json:"-"` + // Stdout is the output stream. Defaults to os.Stdout if nil. + Stdout io.Writer `json:"-"` + // Stderr is the error stream. Defaults to os.Stderr if nil. + Stderr io.Writer `json:"-"` +} diff --git a/pkg/client/container.go b/pkg/client/container.go index 41610a31..1ac5a02a 100644 --- a/pkg/client/container.go +++ b/pkg/client/container.go @@ -299,3 +299,49 @@ func (cli *Client) RemoveContainer( return nil } + +// ExecContainer executes a command in a container within the service. +// If containerNameOrID is empty, the first container in the service will be used. +func (cli *Client) ExecContainer( + ctx context.Context, serviceNameOrID, containerNameOrID string, execOpts api.ExecOptions, +) (int, error) { + var ctr api.MachineServiceContainer + + if containerNameOrID == "" { + // Find the first (random) container in the service + service, err := cli.InspectService(ctx, serviceNameOrID) + if err != nil { + return -1, fmt.Errorf("inspect service: %w", err) + } + if len(service.Containers) == 0 { + return -1, fmt.Errorf("no containers found in service %s", serviceNameOrID) + } + ctr = service.Containers[0] + } else { + // Find the specific container + var err error + ctr, err = cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID) + if err != nil { + return -1, fmt.Errorf("inspect container: %w", err) + } + } + + machine, err := cli.InspectMachine(ctx, ctr.MachineID) + if err != nil { + return -1, fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err) + } + + // Proxy Docker gRPC requests to the machine hosting the container + ctx = proxyToMachine(ctx, machine.Machine) + + // Execute the command in the container + exitCode, err := cli.Docker.ExecContainer(ctx, machinedocker.ExecConfig{ + ContainerID: ctr.Container.ID, + Options: execOpts, + }) + if err != nil { + return exitCode, fmt.Errorf("exec in container %s: %w", ctr.Container.Name, err) + } + + return exitCode, nil +} diff --git a/test/e2e/exec_test.go b/test/e2e/exec_test.go new file mode 100644 index 00000000..c3cc959f --- /dev/null +++ b/test/e2e/exec_test.go @@ -0,0 +1,243 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/psviderski/uncloud/internal/ucind" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// deployTestService is a helper function to deploy a simple alpine service for testing exec +func deployTestService(t *testing.T, ctx context.Context, cli *client.Client, name string, replicas uint) { + t.Helper() + + spec := api.ServiceSpec{ + Name: name, + Replicas: replicas, + Container: api.ContainerSpec{ + Image: "alpine:3.20", + Command: []string{"sleep", "3600"}, + }, + } + + deployment := cli.NewDeployment(spec, nil) + err := deployment.Validate(ctx) + require.NoError(t, err) + + _, err = deployment.Run(ctx) + require.NoError(t, err) + + // Wait for all replicas to be running + require.Eventually(t, func() bool { + service, err := cli.InspectService(ctx, name) + if err != nil { + return false + } + if uint(len(service.Containers)) != replicas { + return false + } + for _, ctr := range service.Containers { + if ctr.Container.State.Status != "running" { + return false + } + } + return true + }, 30*time.Second, 1*time.Second, fmt.Sprintf("service %s should have %d running replicas", name, replicas)) +} + +// TestExecBasicCommands tests basic command execution, errors, and stderr handling +func TestExecBasicCommands(t *testing.T) { + t.Parallel() + + clusterName := "ucind-test.exec-basic" + ctx := context.Background() + c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true) + + cli, err := c.Machines[0].Connect(ctx) + require.NoError(t, err) + + // Test: Non-existent service should fail + t.Run("non-existent service", func(t *testing.T) { + execOptions := api.ExecOptions{ + Command: []string{"echo", "test"}, + AttachStdout: true, + AttachStderr: true, + } + + _, err := cli.ExecContainer(ctx, "non-existent-service", "", execOptions) + assert.Error(t, err, "should fail for non-existent service") + assert.Contains(t, strings.ToLower(err.Error()), "inspect service: not found") + }) + + // Deploy a simple service for all remaining tests + serviceName := "test-exec-service" + deployTestService(t, ctx, cli, serviceName, 1) + + // Test: Execute a simple echo command + t.Run("echo command", func(t *testing.T) { + var stdout, stderr bytes.Buffer + execOptions := api.ExecOptions{ + Command: []string{"echo", "hello world"}, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + require.NoError(t, err) + assert.Equal(t, 0, exitCode, "command should exit with code 0") + assert.Equal(t, "hello world\n", stdout.String(), "should capture stdout") + assert.Empty(t, stderr.String(), "stderr should be empty") + }) + + // Test: Command with non-zero exit code + t.Run("non-zero exit code", func(t *testing.T) { + execOptions := api.ExecOptions{ + Command: []string{"sh", "-c", "exit 42"}, + AttachStdout: true, + AttachStderr: true, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + require.NoError(t, err) + assert.Equal(t, 42, exitCode, "should return actual exit code") + }) + + // Test: Invalid command should fail + t.Run("invalid command", func(t *testing.T) { + var stdout, stderr bytes.Buffer + execOptions := api.ExecOptions{ + Command: []string{"nonexistent-command"}, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + require.NoError(t, err, "exec should not fail, but command should return non-zero exit") + assert.Equal(t, 126, exitCode, "invalid command should return non-zero exit code") + assert.Contains(t, stdout.String(), "executable file not found") + assert.Equal(t, "", stderr.String(), "stderr should be empty") + }) + + // Test: Non-existent container ID + t.Run("non-existent container", func(t *testing.T) { + execOptions := api.ExecOptions{ + Command: []string{"echo", "test"}, + AttachStdout: true, + AttachStderr: true, + } + + _, err := cli.ExecContainer(ctx, serviceName, "non-existent-container-id", execOptions) + assert.Error(t, err, "should fail for non-existent container") + }) + + // Test: Empty command + t.Run("empty command", func(t *testing.T) { + execOptions := api.ExecOptions{ + Command: []string{}, + AttachStdout: true, + AttachStderr: true, + } + + _, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + assert.Error(t, err, "should fail for empty command") + }) + + // Test: Command with both stdout and stderr + t.Run("mixed stdout/stderr", func(t *testing.T) { + var stdout, stderr bytes.Buffer + execOptions := api.ExecOptions{ + Command: []string{"sh", "-c", "echo 'stdout'; echo 'stderr' >&2"}, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "stdout\n", stdout.String(), "should capture stdout") + assert.Equal(t, "stderr\n", stderr.String(), "should capture stderr") + }) + + // Test: Detached command should return immediately + t.Run("detached command", func(t *testing.T) { + var stdout, stderr bytes.Buffer + start := time.Now() + execOptions := api.ExecOptions{ + Command: []string{"sh", "-c", "sleep 10; echo hello"}, // Long-running command + Stdout: &stdout, + Stderr: &stderr, + Detach: true, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + elapsed := time.Since(start) + + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + // Should return immediately, not wait for command to complete + assert.Less(t, elapsed, 5*time.Second, "detached command should return quickly") + assert.Empty(t, stdout.String(), "stdout should be empty for detached command") + assert.Empty(t, stderr.String(), "stderr should be empty for detached command") + }) + + // Test: Detached mode with invalid command + t.Run("detached invalid command", func(t *testing.T) { + var stdout, stderr bytes.Buffer + execOptions := api.ExecOptions{ + Command: []string{"nonexistent-detached-command"}, + Stdout: &stdout, + Stderr: &stderr, + Detach: true, + } + + exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) + + require.ErrorContains(t, err, "executable file not found") + assert.Equal(t, 1, exitCode) + assert.Empty(t, stdout.String(), "stdout should be empty for detached command") + assert.Empty(t, stderr.String(), "stderr should be empty for detached command") + }) + + // Deploy a service with multiple replicas + multiServiceName := "multi-replica-service" + deployTestService(t, ctx, cli, multiServiceName, 2) + + // Test: Execute command on specific container + t.Run("exec on specific container", func(t *testing.T) { + service, err := cli.InspectService(ctx, multiServiceName) + require.NoError(t, err) + require.GreaterOrEqual(t, len(service.Containers), 2, "should have at least 2 containers") + + var stdout, stderr bytes.Buffer + + containerID := service.Containers[1].Container.ID + execOptions := api.ExecOptions{ + Command: []string{"hostname"}, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + } + + exitCode, err := cli.ExecContainer(ctx, multiServiceName, containerID, execOptions) + + require.NoError(t, err) + assert.Equal(t, 0, exitCode) + assert.Greater(t, len(stdout.String()), 4) + assert.Equal(t, service.Containers[1].Container.Name+"\n", stdout.String()) + }) +} diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index f411ff8d..f8d3b7ab 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -26,6 +26,7 @@ type fileInfo struct { // Uncloud API does not currently expose exec functionality to run commands inside containers. // Instead this helper function uses "docker cp" inside the ucind container to copy the file from the target container // to a temporary location (also inside the ucind container), and then inspect its content and permissions. +// TODO: update when exec functionality is implemented func readFileInfoInContainer(t *testing.T, machine *ucind.Machine, containerName, filePath string) (fileInfo, error) { t.Helper() ctx := context.Background() diff --git a/website/docs/9-cli-reference/uc.md b/website/docs/9-cli-reference/uc.md index 2bd2a4f7..b22f863d 100644 --- a/website/docs/9-cli-reference/uc.md +++ b/website/docs/9-cli-reference/uc.md @@ -18,6 +18,7 @@ A CLI tool for managing Uncloud resources such as machines, services, and volume * [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts. * [uc deploy](uc_deploy.md) - Deploy services from a Compose file. * [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS. +* [uc exec](uc_exec.md) - Execute a command in a running service container * [uc image](uc_image.md) - Manage images on machines in the cluster. * [uc images](uc_images.md) - List images on machines in the cluster. * [uc inspect](uc_inspect.md) - Display detailed information on a service. diff --git a/website/docs/9-cli-reference/uc_exec.md b/website/docs/9-cli-reference/uc_exec.md new file mode 100644 index 00000000..b101d8d2 --- /dev/null +++ b/website/docs/9-cli-reference/uc_exec.md @@ -0,0 +1,56 @@ +# uc exec + +Execute a command in a running service container + +## Synopsis + +Execute a command (interactive shell by default) in a running container within a service. +If the service has multiple replicas, the command will be executed in a random container. + + +``` +uc exec [OPTIONS] SERVICE [COMMAND ARGS...] [flags] +``` + +## Examples + +``` + + # Start an interactive shell ("bash" or "sh" will be tried by default) + uc exec web-service + + # Start an interactive shell with explicit command + uc exec web-service /bin/zsh + + # List files in the specific container of the service + uc exec --container d792ea7347e5 web-service ls -la + + # Pipe input to a command inside the service container + cat /var/log/app.log | uc exec -T web-service grep "ERROR" + + # Run a task in the background (detached mode) + uc exec -d web-service /scripts/cleanup.sh +``` + +## Options + +``` + --container string ID of the container to exec into (default is the random container of the service) + -c, --context string Name of the cluster context. (default is the current context) + -d, --detach Detached mode: run command in the background + -h, --help help for exec + -T, --no-tty Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal. +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port] or tcp://host:port + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. + diff --git a/website/docs/9-cli-reference/uc_service.md b/website/docs/9-cli-reference/uc_service.md index 4ba418bd..4dda0fd1 100644 --- a/website/docs/9-cli-reference/uc_service.md +++ b/website/docs/9-cli-reference/uc_service.md @@ -19,6 +19,7 @@ Manage services in an Uncloud cluster. ## See also * [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. +* [uc service exec](uc_service_exec.md) - Execute a command in a running service container * [uc service inspect](uc_service_inspect.md) - Display detailed information on a service. * [uc service ls](uc_service_ls.md) - List services. * [uc service rm](uc_service_rm.md) - Remove one or more services. diff --git a/website/docs/9-cli-reference/uc_service_exec.md b/website/docs/9-cli-reference/uc_service_exec.md new file mode 100644 index 00000000..b48bb4c2 --- /dev/null +++ b/website/docs/9-cli-reference/uc_service_exec.md @@ -0,0 +1,56 @@ +# uc service exec + +Execute a command in a running service container + +## Synopsis + +Execute a command (interactive shell by default) in a running container within a service. +If the service has multiple replicas, the command will be executed in a random container. + + +``` +uc service exec [OPTIONS] SERVICE [COMMAND ARGS...] [flags] +``` + +## Examples + +``` + + # Start an interactive shell ("bash" or "sh" will be tried by default) + uc exec web-service + + # Start an interactive shell with explicit command + uc exec web-service /bin/zsh + + # List files in the specific container of the service + uc exec --container d792ea7347e5 web-service ls -la + + # Pipe input to a command inside the service container + cat /var/log/app.log | uc exec -T web-service grep "ERROR" + + # Run a task in the background (detached mode) + uc exec -d web-service /scripts/cleanup.sh +``` + +## Options + +``` + --container string ID of the container to exec into (default is the random container of the service) + -c, --context string Name of the cluster context. (default is the current context) + -d, --detach Detached mode: run command in the background + -h, --help help for exec + -T, --no-tty Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal. +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port] or tcp://host:port + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc service](uc_service.md) - Manage services in an Uncloud cluster. +