Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Action CLI command #18778

Merged
merged 6 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion api/allocations_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type execSession struct {
task string
tty bool
command []string
action string

stdin io.Reader
stdout io.Writer
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions api/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
package api

import (
"context"
"errors"
"fmt"
"io"
"net/url"
"sort"
"strconv"
Expand Down Expand Up @@ -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)
}
317 changes: 317 additions & 0 deletions command/action.go
Original file line number Diff line number Diff line change
@@ -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] <action>

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 <job-id>
Specifies the job in which the Action is defined

-allocation <allocation-id>
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 <task-name>
Specifies the task in which the Action is defined. Required if no
allocation is provided.

-group <group-name>
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 <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 *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) {
philrenaud marked this conversation as resolved.
Show resolved Hide resolved

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)
}
2 changes: 1 addition & 1 deletion command/alloc_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading