package service import ( "context" "fmt" "os" "github.com/docker/cli/cli/streams" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" ) type execCliOptions struct { detach bool interactive bool noTty bool containerId string } var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} func NewExecCommand(groupID string) *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 and no container ID is specified, 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; --container accepts full ID or a (unique) prefix uc exec --container d792e 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, command := normalizeExecArgs(args) if len(command) == 0 { command = DEFAULT_COMMAND } return runExec(cmd.Context(), uncli, serviceName, command, opts) }, GroupID: groupID, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } uncli := cmd.Context().Value("cli").(*cli.CLI) return completion.Services(cmd.Context(), uncli, args, toComplete) }, } execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background") execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", false, "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") // Common flags execCmd.Flags().StringVar(&opts.containerId, "container", "", "ID of the container to exec into. Accepts full ID or a unique prefix "+ "(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 normalizeExecArgs(args []string) (serviceName string, command []string) { serviceName = args[0] command = args[1:] if len(command) > 0 && command[0] == "--" { command = command[1:] } return serviceName, command } func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error { // Disable TTY allocation if not connected to a terminal if !tui.IsStdoutTerminal() { opts.noTty = true } 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, 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 }