github.com/hernad/nomad@v1.6.112/command/alloc_exec.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package command
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"os/signal"
    14  	"strings"
    15  	"syscall"
    16  
    17  	"github.com/hernad/nomad/api"
    18  	"github.com/hernad/nomad/api/contexts"
    19  	"github.com/hernad/nomad/helper/escapingio"
    20  	"github.com/moby/term"
    21  	"github.com/posener/complete"
    22  )
    23  
    24  type AllocExecCommand struct {
    25  	Meta
    26  
    27  	Stdin  io.Reader
    28  	Stdout io.WriteCloser
    29  	Stderr io.WriteCloser
    30  }
    31  
    32  func (l *AllocExecCommand) Help() string {
    33  	helpText := `
    34  Usage: nomad alloc exec [options] <allocation> <command>
    35  
    36    Run command inside the environment of the given allocation and task.
    37  
    38    When ACLs are enabled, this command requires a token with the 'alloc-exec',
    39    'read-job', and 'list-jobs' capabilities for the allocation's namespace. If
    40    the task driver does not have file system isolation (as with 'raw_exec'),
    41    this command requires the 'alloc-node-exec', 'read-job', and 'list-jobs'
    42    capabilities for the allocation's namespace.
    43  
    44  General Options:
    45  
    46    ` + generalOptionsUsage(usageOptsDefault) + `
    47  
    48  Exec Specific Options:
    49  
    50    -task <task-name>
    51      Sets the task to exec command in
    52  
    53    -job
    54      Use a random allocation from the specified job ID.
    55  
    56    -i
    57      Pass stdin to the container, defaults to true.  Pass -i=false to disable.
    58  
    59    -t
    60      Allocate a pseudo-tty, defaults to true if stdin is detected to be a tty session.
    61      Pass -t=false to disable explicitly.
    62  
    63    -e <escape_char>
    64      Sets the escape character for sessions with a pty (default: '~').  The escape
    65      character is only recognized at the beginning of a line.  The escape character
    66      followed by a dot ('.') closes the connection.  Setting the character to
    67      'none' disables any escapes and makes the session fully transparent.
    68    `
    69  	return strings.TrimSpace(helpText)
    70  }
    71  
    72  func (l *AllocExecCommand) Synopsis() string {
    73  	return "Execute commands in task"
    74  }
    75  
    76  func (l *AllocExecCommand) AutocompleteFlags() complete.Flags {
    77  	return mergeAutocompleteFlags(l.Meta.AutocompleteFlags(FlagSetClient),
    78  		complete.Flags{
    79  			"--task": complete.PredictAnything,
    80  			"-job":   complete.PredictAnything,
    81  			"-i":     complete.PredictNothing,
    82  			"-t":     complete.PredictNothing,
    83  			"-e":     complete.PredictSet("none", "~"),
    84  		})
    85  }
    86  
    87  func (l *AllocExecCommand) AutocompleteArgs() complete.Predictor {
    88  	return complete.PredictFunc(func(a complete.Args) []string {
    89  		client, err := l.Meta.Client()
    90  		if err != nil {
    91  			return nil
    92  		}
    93  
    94  		resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Allocs, nil)
    95  		if err != nil {
    96  			return []string{}
    97  		}
    98  		return resp.Matches[contexts.Allocs]
    99  	})
   100  }
   101  
   102  func (l *AllocExecCommand) Name() string { return "alloc exec" }
   103  
   104  func (l *AllocExecCommand) Run(args []string) int {
   105  	var job, stdinOpt, ttyOpt bool
   106  	var task, escapeChar string
   107  
   108  	flags := l.Meta.FlagSet(l.Name(), FlagSetClient)
   109  	flags.Usage = func() { l.Ui.Output(l.Help()) }
   110  	flags.BoolVar(&job, "job", false, "")
   111  	flags.BoolVar(&stdinOpt, "i", true, "")
   112  	flags.BoolVar(&ttyOpt, "t", isTty(), "")
   113  	flags.StringVar(&escapeChar, "e", "~", "")
   114  	flags.StringVar(&task, "task", "", "")
   115  
   116  	if err := flags.Parse(args); err != nil {
   117  		return 1
   118  	}
   119  
   120  	args = flags.Args()
   121  
   122  	if len(args) < 1 {
   123  		if job {
   124  			l.Ui.Error("A job ID is required")
   125  		} else {
   126  			l.Ui.Error("An allocation ID is required")
   127  		}
   128  		l.Ui.Error(commandErrorText(l))
   129  		return 1
   130  	}
   131  
   132  	if !job && len(args[0]) == 1 {
   133  		l.Ui.Error("Alloc ID must contain at least two characters")
   134  		return 1
   135  	}
   136  
   137  	if len(args) < 2 {
   138  		l.Ui.Error("A command is required")
   139  		l.Ui.Error(commandErrorText(l))
   140  		return 1
   141  	}
   142  
   143  	if ttyOpt && !stdinOpt {
   144  		l.Ui.Error("-i must be enabled if running with tty")
   145  		return 1
   146  	}
   147  
   148  	if escapeChar == "none" {
   149  		escapeChar = ""
   150  	}
   151  
   152  	if len(escapeChar) > 1 {
   153  		l.Ui.Error("-e requires 'none' or a single character")
   154  		return 1
   155  	}
   156  
   157  	client, err := l.Meta.Client()
   158  	if err != nil {
   159  		l.Ui.Error(fmt.Sprintf("Error initializing client: %v", err))
   160  		return 1
   161  	}
   162  
   163  	var allocStub *api.AllocationListStub
   164  	if job {
   165  		jobID := args[0]
   166  		allocStub, err = getRandomJobAlloc(client, jobID)
   167  		if err != nil {
   168  			l.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err))
   169  			return 1
   170  		}
   171  	} else {
   172  		allocID := args[0]
   173  		allocs, _, err := client.Allocations().PrefixList(sanitizeUUIDPrefix(allocID))
   174  		if err != nil {
   175  			l.Ui.Error(fmt.Sprintf("Error querying allocation: %v", err))
   176  			return 1
   177  		}
   178  
   179  		if len(allocs) == 0 {
   180  			l.Ui.Error(fmt.Sprintf("No allocation(s) with prefix or id %q found", allocID))
   181  			return 1
   182  		}
   183  
   184  		if len(allocs) > 1 {
   185  			out := formatAllocListStubs(allocs, false, shortId)
   186  			l.Ui.Error(fmt.Sprintf("Prefix matched multiple allocations\n\n%s", out))
   187  			return 1
   188  		}
   189  
   190  		allocStub = allocs[0]
   191  	}
   192  
   193  	q := &api.QueryOptions{Namespace: allocStub.Namespace}
   194  	alloc, _, err := client.Allocations().Info(allocStub.ID, q)
   195  	if err != nil {
   196  		l.Ui.Error(fmt.Sprintf("Error querying allocation: %s", err))
   197  		return 1
   198  	}
   199  
   200  	if task != "" {
   201  		err = validateTaskExistsInAllocation(task, alloc)
   202  	} else {
   203  		task, err = lookupAllocTask(alloc)
   204  	}
   205  	if err != nil {
   206  		l.Ui.Error(err.Error())
   207  		return 1
   208  	}
   209  
   210  	if !stdinOpt {
   211  		l.Stdin = bytes.NewReader(nil)
   212  	}
   213  
   214  	if l.Stdin == nil {
   215  		l.Stdin = os.Stdin
   216  	}
   217  
   218  	if l.Stdout == nil {
   219  		l.Stdout = os.Stdout
   220  	}
   221  
   222  	if l.Stderr == nil {
   223  		l.Stderr = os.Stderr
   224  	}
   225  
   226  	code, err := l.execImpl(client, alloc, task, ttyOpt, args[1:], escapeChar, l.Stdin, l.Stdout, l.Stderr)
   227  	if err != nil {
   228  		l.Ui.Error(fmt.Sprintf("failed to exec into task: %v", err))
   229  		return 1
   230  	}
   231  
   232  	return code
   233  }
   234  
   235  // execImpl invokes the Alloc Exec api call, it also prepares and restores terminal states as necessary.
   236  func (l *AllocExecCommand) execImpl(client *api.Client, alloc *api.Allocation, task string, tty bool,
   237  	command []string, escapeChar string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) {
   238  
   239  	sizeCh := make(chan api.TerminalSize, 1)
   240  
   241  	ctx, cancelFn := context.WithCancel(context.Background())
   242  	defer cancelFn()
   243  
   244  	// When tty, ensures we capture all user input and monitor terminal resizes.
   245  	if tty {
   246  		if stdin == nil {
   247  			return -1, fmt.Errorf("stdin is null")
   248  		}
   249  
   250  		inCleanup, err := setRawTerminal(stdin)
   251  		if err != nil {
   252  			return -1, err
   253  		}
   254  		defer inCleanup()
   255  
   256  		outCleanup, err := setRawTerminalOutput(stdout)
   257  		if err != nil {
   258  			return -1, err
   259  		}
   260  		defer outCleanup()
   261  
   262  		sizeCleanup, err := watchTerminalSize(stdout, sizeCh)
   263  		if err != nil {
   264  			return -1, err
   265  		}
   266  		defer sizeCleanup()
   267  
   268  		if escapeChar != "" {
   269  			stdin = escapingio.NewReader(stdin, escapeChar[0], func(c byte) bool {
   270  				switch c {
   271  				case '.':
   272  					// need to restore tty state so error reporting here
   273  					// gets emitted at beginning of line
   274  					outCleanup()
   275  					inCleanup()
   276  
   277  					stderr.Write([]byte("\nConnection closed\n"))
   278  					cancelFn()
   279  					return true
   280  				default:
   281  					return false
   282  				}
   283  			})
   284  		}
   285  	}
   286  
   287  	signalCh := make(chan os.Signal, 1)
   288  	signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
   289  	go func() {
   290  		for range signalCh {
   291  			cancelFn()
   292  		}
   293  	}()
   294  
   295  	return client.Allocations().Exec(ctx,
   296  		alloc, task, tty, command, stdin, stdout, stderr, sizeCh, nil)
   297  }
   298  
   299  // isTty returns true if both stdin and stdout are a TTY
   300  func isTty() bool {
   301  	_, isStdinTerminal := term.GetFdInfo(os.Stdin)
   302  	_, isStdoutTerminal := term.GetFdInfo(os.Stdout)
   303  	return isStdinTerminal && isStdoutTerminal
   304  }
   305  
   306  // setRawTerminal sets the stream terminal in raw mode, so process captures
   307  // Ctrl+C and other commands to forward to remote process.
   308  // It returns a cleanup function that restores terminal to original mode.
   309  func setRawTerminal(stream interface{}) (cleanup func(), err error) {
   310  	fd, isTerminal := term.GetFdInfo(stream)
   311  	if !isTerminal {
   312  		return nil, errors.New("not a terminal")
   313  	}
   314  
   315  	state, err := term.SetRawTerminal(fd)
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  
   320  	return func() { term.RestoreTerminal(fd, state) }, nil
   321  }
   322  
   323  // setRawTerminalOutput sets the output stream in Windows to raw mode,
   324  // so it disables LF -> CRLF translation.
   325  // It's basically a no-op on unix.
   326  func setRawTerminalOutput(stream interface{}) (cleanup func(), err error) {
   327  	fd, isTerminal := term.GetFdInfo(stream)
   328  	if !isTerminal {
   329  		return nil, errors.New("not a terminal")
   330  	}
   331  
   332  	state, err := term.SetRawTerminalOutput(fd)
   333  	if err != nil {
   334  		return nil, err
   335  	}
   336  
   337  	return func() { term.RestoreTerminal(fd, state) }, nil
   338  }
   339  
   340  // watchTerminalSize watches terminal size changes to propagate to remote tty.
   341  func watchTerminalSize(out io.Writer, resize chan<- api.TerminalSize) (func(), error) {
   342  	fd, isTerminal := term.GetFdInfo(out)
   343  	if !isTerminal {
   344  		return nil, errors.New("not a terminal")
   345  	}
   346  
   347  	ctx, cancel := context.WithCancel(context.Background())
   348  
   349  	signalCh := make(chan os.Signal, 1)
   350  	setupWindowNotification(signalCh)
   351  
   352  	sendTerminalSize := func() {
   353  		s, err := term.GetWinsize(fd)
   354  		if err != nil {
   355  			return
   356  		}
   357  
   358  		resize <- api.TerminalSize{
   359  			Height: int(s.Height),
   360  			Width:  int(s.Width),
   361  		}
   362  	}
   363  	go func() {
   364  		for {
   365  			select {
   366  			case <-ctx.Done():
   367  				return
   368  			case <-signalCh:
   369  				sendTerminalSize()
   370  			}
   371  		}
   372  	}()
   373  
   374  	go func() {
   375  		// send initial size
   376  		sendTerminalSize()
   377  	}()
   378  
   379  	return cancel, nil
   380  }