diff --git a/api/allocations_exec.go b/api/allocations_exec.go index 5300f5f6082..49c673eccaa 100644 --- a/api/allocations_exec.go +++ b/api/allocations_exec.go @@ -28,6 +28,7 @@ type execSession struct { task string tty bool command []string + action string stdin io.Reader stdout io.Writer @@ -94,9 +95,15 @@ func (s *execSession) startConnection() (*websocket.Conn, error) { q.Params["tty"] = strconv.FormatBool(s.tty) q.Params["task"] = s.task q.Params["command"] = string(commandBytes) - reqPath := fmt.Sprintf("/v1/client/allocation/%s/exec", s.alloc.ID) + if s.action != "" { + q.Params["action"] = s.action + q.Params["allocID"] = s.alloc.ID + q.Params["group"] = s.alloc.TaskGroup + reqPath = fmt.Sprintf("/v1/job/%s/action", s.alloc.JobID) + } + var conn *websocket.Conn if nodeClient != nil { diff --git a/api/jobs.go b/api/jobs.go index 297e595c97e..ca7608a95ad 100644 --- a/api/jobs.go +++ b/api/jobs.go @@ -4,8 +4,10 @@ package api import ( + "context" "errors" "fmt" + "io" "net/url" "sort" "strconv" @@ -1514,3 +1516,30 @@ type JobEvaluateRequest struct { type EvalOptions struct { ForceReschedule bool } + +// ActionExec is used to run a pre-defined command inside a running task. +// The call blocks until command terminates (or an error occurs), and returns the exit code. +func (j *Jobs) ActionExec(ctx context.Context, + alloc *Allocation, task string, tty bool, command []string, + action string, + stdin io.Reader, stdout, stderr io.Writer, + terminalSizeCh <-chan TerminalSize, q *QueryOptions) (exitCode int, err error) { + + s := &execSession{ + client: j.client, + alloc: alloc, + task: task, + tty: tty, + command: command, + action: action, + + stdin: stdin, + stdout: stdout, + stderr: stderr, + + terminalSizeCh: terminalSizeCh, + q: q, + } + + return s.run(ctx) +} diff --git a/command/action.go b/command/action.go new file mode 100644 index 00000000000..2d3dd791a0a --- /dev/null +++ b/command/action.go @@ -0,0 +1,317 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "bytes" + "context" + "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/posener/complete" +) + +type ActionCommand struct { + Meta + + Stdin io.Reader + Stdout io.WriteCloser + Stderr io.WriteCloser +} + +func (l *ActionCommand) Help() string { + helpText := ` +Usage: nomad action [options] + + Perform a predefined 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 a task'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 task's namespace. + +General Options: + + ` + generalOptionsUsage(usageOptsNoNamespace) + ` + +Action Specific Options: + + -job + Specifies the job in which the Action is defined + + -allocation + Specifies the allocation in which the Action is defined. If not provided, + a group and task name must be provided and a random allocation will be + selected from the job. + + -task + Specifies the task in which the Action is defined. Required if no + allocation is provided. + + -group + Specifies the group in which the Action is defined. Required if no + allocation is provided. + + -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 *ActionCommand) Synopsis() string { + return "Run a pre-defined action from a Nomad task" +} + +func (l *ActionCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(l.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-task": complete.PredictAnything, + "-job": complete.PredictAnything, + "-allocation": complete.PredictAnything, + }) +} + +func (l *ActionCommand) 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 *ActionCommand) Name() string { return "action" } + +func (l *ActionCommand) Run(args []string) int { + + var stdinOpt, ttyOpt bool + var task, allocation, job, group, escapeChar string + + flags := l.Meta.FlagSet(l.Name(), FlagSetClient) + flags.Usage = func() { l.Ui.Output(l.Help()) } + flags.StringVar(&task, "task", "", "") + flags.StringVar(&group, "group", "", "") + flags.StringVar(&allocation, "allocation", "", "") + flags.StringVar(&job, "job", "", "") + flags.BoolVar(&stdinOpt, "i", true, "") + flags.BoolVar(&ttyOpt, "t", isTty(), "") + flags.StringVar(&escapeChar, "e", "~", "") + + if err := flags.Parse(args); err != nil { + l.Ui.Error(fmt.Sprintf("Error parsing flags: %s", err)) + return 1 + } + + args = flags.Args() + + if len(args) < 1 { + l.Ui.Error("An action name is required") + return 1 + } + + if job == "" { + l.Ui.Error("A job ID is required") + 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 no allocation provided, grab a random one from the job + if allocation == "" { + + // Group param cannot be empty if allocation is empty, + // since we'll need to get a random allocation from the group + if group == "" { + l.Ui.Error("A group name is required if no allocation is provided") + return 1 + } + + if task == "" { + l.Ui.Error("A task name is required if no allocation is provided") + return 1 + } + + jobID, ns, err := l.JobIDByPrefix(client, job, nil) + if err != nil { + l.Ui.Error(err.Error()) + return 1 + } + + allocStub, err = getRandomJobAlloc(client, jobID, group, ns) + if err != nil { + l.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err)) + return 1 + } + } else { + allocs, _, err := client.Allocations().PrefixList(sanitizeUUIDPrefix(allocation)) + 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", allocation)) + 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 + } + + action := args[0] + + code, err := l.execImpl(client, alloc, task, job, action, ttyOpt, 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 *ActionCommand) execImpl(client *api.Client, alloc *api.Allocation, task string, job string, action string, tty bool, + 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.Jobs().ActionExec(ctx, + alloc, task, tty, make([]string, 0), action, stdin, stdout, stderr, sizeCh, nil) +} diff --git a/command/alloc_exec.go b/command/alloc_exec.go index af1cf0d03f0..888f513039c 100644 --- a/command/alloc_exec.go +++ b/command/alloc_exec.go @@ -168,7 +168,7 @@ func (l *AllocExecCommand) Run(args []string) int { return 1 } - allocStub, err = getRandomJobAlloc(client, jobID, ns) + allocStub, err = getRandomJobAlloc(client, jobID, "", ns) if err != nil { l.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err)) return 1 diff --git a/command/alloc_fs.go b/command/alloc_fs.go index 82d86411cff..60d99e1daa6 100644 --- a/command/alloc_fs.go +++ b/command/alloc_fs.go @@ -173,7 +173,7 @@ func (f *AllocFSCommand) Run(args []string) int { return 1 } - allocID, err = getRandomJobAllocID(client, jobID, ns) + allocID, err = getRandomJobAllocID(client, jobID, "", ns) if err != nil { f.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err)) return 1 @@ -387,7 +387,7 @@ func (f *AllocFSCommand) followFile(client *api.Client, alloc *api.Allocation, // Get Random Allocation from a known jobID. Prefer to use a running allocation, // but use a dead allocation if no running allocations are found -func getRandomJobAlloc(client *api.Client, jobID, namespace string) (*api.AllocationListStub, error) { +func getRandomJobAlloc(client *api.Client, jobID, taskGroupName, namespace string) (*api.AllocationListStub, error) { var runningAllocs []*api.AllocationListStub q := &api.QueryOptions{ Namespace: namespace, @@ -403,6 +403,19 @@ func getRandomJobAlloc(client *api.Client, jobID, namespace string) (*api.Alloca return nil, fmt.Errorf("job %q doesn't exist or it has no allocations", jobID) } + if taskGroupName != "" { + var filteredAllocs []*api.AllocationListStub + for _, alloc := range allocs { + if alloc.TaskGroup == taskGroupName { + filteredAllocs = append(filteredAllocs, alloc) + } + } + allocs = filteredAllocs + if len(allocs) == 0 { + return nil, fmt.Errorf("task group %q doesn't exist or it has no allocations", taskGroupName) + } + } + for _, v := range allocs { if v.ClientStatus == "running" { runningAllocs = append(runningAllocs, v) @@ -419,8 +432,8 @@ func getRandomJobAlloc(client *api.Client, jobID, namespace string) (*api.Alloca return alloc, err } -func getRandomJobAllocID(client *api.Client, jobID, namespace string) (string, error) { - alloc, err := getRandomJobAlloc(client, jobID, namespace) +func getRandomJobAllocID(client *api.Client, jobID, group, namespace string) (string, error) { + alloc, err := getRandomJobAlloc(client, jobID, group, namespace) if err != nil { return "", err } diff --git a/command/alloc_logs.go b/command/alloc_logs.go index fb430a753ca..3d9c2fc6962 100644 --- a/command/alloc_logs.go +++ b/command/alloc_logs.go @@ -173,7 +173,7 @@ func (l *AllocLogsCommand) Run(args []string) int { return 1 } - allocID, err = getRandomJobAllocID(client, jobID, ns) + allocID, err = getRandomJobAllocID(client, jobID, "", ns) if err != nil { l.Ui.Error(fmt.Sprintf("Error fetching allocations: %v", err)) return 1 diff --git a/command/commands.go b/command/commands.go index d665c80e5e8..093c6beebe1 100644 --- a/command/commands.go +++ b/command/commands.go @@ -235,6 +235,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "action": func() (cli.Command, error) { + return &ActionCommand{ + Meta: meta, + }, nil + }, "alloc": func() (cli.Command, error) { return &AllocCommand{ Meta: meta,