// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package command import ( "bytes" "context" "errors" "fmt" "io" "os" "os/signal" "strings" "syscall" "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/api/contexts" "github.com/hashicorp/nomad/helper/escapingio" "github.com/moby/term" "github.com/posener/complete" ) type AllocExecCommand struct { Meta Stdin io.Reader Stdout io.WriteCloser Stderr io.WriteCloser } func (l *AllocExecCommand) Help() string { helpText := ` Usage: nomad alloc exec [options] Run command inside the environment of the given allocation and task. When ACLs are enabled, this command requires a token with the 'alloc-exec', 'read-job', and 'list-jobs' capabilities for the allocation's namespace. If the task driver does not have file system isolation (as with 'raw_exec'), this command requires the 'alloc-node-exec', 'read-job', and 'list-jobs' capabilities for the allocation's namespace. General Options: ` + generalOptionsUsage(usageOptsDefault) + ` Exec Specific Options: -task Sets the task to exec command in -job Use a random allocation from the specified job ID. -i Pass stdin to the container, defaults to true. Pass -i=false to disable. -t Allocate a pseudo-tty, defaults to true if stdin is detected to be a tty session. Pass -t=false to disable explicitly. -e Sets the escape character for sessions with a pty (default: '~'). The escape character is only recognized at the beginning of a line. The escape character followed by a dot ('.') closes the connection. Setting the character to 'none' disables any escapes and makes the session fully transparent. ` return strings.TrimSpace(helpText) } func (l *AllocExecCommand) Synopsis() string { return "Execute commands in task" } func (l *AllocExecCommand) AutocompleteFlags() complete.Flags { return mergeAutocompleteFlags(l.Meta.AutocompleteFlags(FlagSetClient), complete.Flags{ "--task": complete.PredictAnything, "-job": complete.PredictAnything, "-i": complete.PredictNothing, "-t": complete.PredictNothing, "-e": complete.PredictSet("none", "~"), }) } func (l *AllocExecCommand) AutocompleteArgs() complete.Predictor { return complete.PredictFunc(func(a complete.Args) []string { client, err := l.Meta.Client() if err != nil { return nil } resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Allocs, nil) if err != nil { return []string{} } return resp.Matches[contexts.Allocs] }) } func (l *AllocExecCommand) Name() string { return "alloc exec" } func (l *AllocExecCommand) Run(args []string) int { var job, stdinOpt, ttyOpt bool var task, escapeChar string flags := l.Meta.FlagSet(l.Name(), FlagSetClient) flags.Usage = func() { l.Ui.Output(l.Help()) } flags.BoolVar(&job, "job", false, "") flags.BoolVar(&stdinOpt, "i", true, "") flags.BoolVar(&ttyOpt, "t", isTty(), "") flags.StringVar(&escapeChar, "e", "~", "") flags.StringVar(&task, "task", "", "") if err := flags.Parse(args); err != nil { return 1 } args = flags.Args() if len(args) < 1 { if job { l.Ui.Error("A job ID is required") } else { l.Ui.Error("An allocation ID is required") } l.Ui.Error(commandErrorText(l)) return 1 } if !job && len(args[0]) == 1 { l.Ui.Error("Alloc ID must contain at least two characters") return 1 } if len(args) < 2 { l.Ui.Error("A command is required") l.Ui.Error(commandErrorText(l)) return 1 } if ttyOpt && !stdinOpt { l.Ui.Error("-i must be enabled if running with tty") return 1 } if escapeChar == "none" { escapeChar = "" } if len(escapeChar) > 1 { l.Ui.Error("-e requires 'none' or a single character") return 1 } client, err := l.Meta.Client() if err != nil { l.Ui.Error(fmt.Sprintf("Error initializing client: %v", err)) return 1 } var allocStub *api.AllocationListStub if job { jobID := args[0] allocStub, err = getRandomJobAlloc(client, jobID) if err != nil { l.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err)) return 1 } } else { allocID := args[0] allocs, _, err := client.Allocations().PrefixList(sanitizeUUIDPrefix(allocID)) if err != nil { l.Ui.Error(fmt.Sprintf("Error querying allocation: %v", err)) return 1 } if len(allocs) == 0 { l.Ui.Error(fmt.Sprintf("No allocation(s) with prefix or id %q found", allocID)) return 1 } if len(allocs) > 1 { out := formatAllocListStubs(allocs, false, shortId) l.Ui.Error(fmt.Sprintf("Prefix matched multiple allocations\n\n%s", out)) return 1 } allocStub = allocs[0] } q := &api.QueryOptions{Namespace: allocStub.Namespace} alloc, _, err := client.Allocations().Info(allocStub.ID, q) if err != nil { l.Ui.Error(fmt.Sprintf("Error querying allocation: %s", err)) return 1 } if task != "" { err = validateTaskExistsInAllocation(task, alloc) } else { task, err = lookupAllocTask(alloc) } if err != nil { l.Ui.Error(err.Error()) return 1 } if !stdinOpt { l.Stdin = bytes.NewReader(nil) } if l.Stdin == nil { l.Stdin = os.Stdin } if l.Stdout == nil { l.Stdout = os.Stdout } if l.Stderr == nil { l.Stderr = os.Stderr } code, err := l.execImpl(client, alloc, task, ttyOpt, args[1:], escapeChar, l.Stdin, l.Stdout, l.Stderr) if err != nil { l.Ui.Error(fmt.Sprintf("failed to exec into task: %v", err)) return 1 } return code } // execImpl invokes the Alloc Exec api call, it also prepares and restores terminal states as necessary. func (l *AllocExecCommand) execImpl(client *api.Client, alloc *api.Allocation, task string, tty bool, command []string, escapeChar string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) { sizeCh := make(chan api.TerminalSize, 1) ctx, cancelFn := context.WithCancel(context.Background()) defer cancelFn() // When tty, ensures we capture all user input and monitor terminal resizes. if tty { if stdin == nil { return -1, fmt.Errorf("stdin is null") } inCleanup, err := setRawTerminal(stdin) if err != nil { return -1, err } defer inCleanup() outCleanup, err := setRawTerminalOutput(stdout) if err != nil { return -1, err } defer outCleanup() sizeCleanup, err := watchTerminalSize(stdout, sizeCh) if err != nil { return -1, err } defer sizeCleanup() if escapeChar != "" { stdin = escapingio.NewReader(stdin, escapeChar[0], func(c byte) bool { switch c { case '.': // need to restore tty state so error reporting here // gets emitted at beginning of line outCleanup() inCleanup() stderr.Write([]byte("\nConnection closed\n")) cancelFn() return true default: return false } }) } } signalCh := make(chan os.Signal, 1) signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) go func() { for range signalCh { cancelFn() } }() return client.Allocations().Exec(ctx, alloc, task, tty, command, stdin, stdout, stderr, sizeCh, nil) } // isTty returns true if both stdin and stdout are a TTY func isTty() bool { _, isStdinTerminal := term.GetFdInfo(os.Stdin) _, isStdoutTerminal := term.GetFdInfo(os.Stdout) return isStdinTerminal && isStdoutTerminal } // setRawTerminal sets the stream terminal in raw mode, so process captures // Ctrl+C and other commands to forward to remote process. // It returns a cleanup function that restores terminal to original mode. func setRawTerminal(stream interface{}) (cleanup func(), err error) { fd, isTerminal := term.GetFdInfo(stream) if !isTerminal { return nil, errors.New("not a terminal") } state, err := term.SetRawTerminal(fd) if err != nil { return nil, err } return func() { term.RestoreTerminal(fd, state) }, nil } // setRawTerminalOutput sets the output stream in Windows to raw mode, // so it disables LF -> CRLF translation. // It's basically a no-op on unix. func setRawTerminalOutput(stream interface{}) (cleanup func(), err error) { fd, isTerminal := term.GetFdInfo(stream) if !isTerminal { return nil, errors.New("not a terminal") } state, err := term.SetRawTerminalOutput(fd) if err != nil { return nil, err } return func() { term.RestoreTerminal(fd, state) }, nil } // watchTerminalSize watches terminal size changes to propagate to remote tty. func watchTerminalSize(out io.Writer, resize chan<- api.TerminalSize) (func(), error) { fd, isTerminal := term.GetFdInfo(out) if !isTerminal { return nil, errors.New("not a terminal") } ctx, cancel := context.WithCancel(context.Background()) signalCh := make(chan os.Signal, 1) setupWindowNotification(signalCh) sendTerminalSize := func() { s, err := term.GetWinsize(fd) if err != nil { return } resize <- api.TerminalSize{ Height: int(s.Height), Width: int(s.Width), } } go func() { for { select { case <-ctx.Done(): return case <-signalCh: sendTerminalSize() } } }() go func() { // send initial size sendTerminalSize() }() return cancel, nil }