386 lines
9.0 KiB
Go
386 lines
9.0 KiB
Go
// 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] <allocation> <command>
|
|
|
|
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 <task-name>
|
|
Sets the task to exec command in
|
|
|
|
-job
|
|
Use a random allocation from the specified job ID or prefix.
|
|
|
|
-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 <escape_char>
|
|
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, ns, err := l.JobIDByPrefix(client, args[0], nil)
|
|
if err != nil {
|
|
l.Ui.Error(err.Error())
|
|
return 1
|
|
}
|
|
|
|
allocStub, err = getRandomJobAlloc(client, jobID, ns)
|
|
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
|
|
}
|