diff --git a/.changelog/11242.txt b/.changelog/11242.txt new file mode 100644 index 000000000000..a2ea64d8f818 --- /dev/null +++ b/.changelog/11242.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add `nomad job allocs` command +``` diff --git a/command/commands.go b/command/commands.go index 9ac4484857eb..2087741d843b 100644 --- a/command/commands.go +++ b/command/commands.go @@ -300,6 +300,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "job allocs": func() (cli.Command, error) { + return &JobAllocsCommand{ + Meta: meta, + }, nil + }, "job deployments": func() (cli.Command, error) { return &JobDeploymentsCommand{ Meta: meta, diff --git a/command/job_allocs.go b/command/job_allocs.go new file mode 100644 index 000000000000..77139199cc4c --- /dev/null +++ b/command/job_allocs.go @@ -0,0 +1,159 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/api/contexts" + "github.com/posener/complete" +) + +type JobAllocsCommand struct { + Meta +} + +func (c *JobAllocsCommand) Help() string { + helpText := ` +Usage: nomad job allocs [options] + + Display allocations for a particular job. + + When ACLs are enabled, this command requires a token with the 'read-job' and + 'list-jobs' capabilities for the job's namespace. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Allocs Options: + + -all + Display all allocations matching the job ID, even those from an older + instance of the job. + + -json + Output the allocations in a JSON format. + + -t + Format and display allocations using a Go template. + + -verbose + Display full information. +` + return strings.TrimSpace(helpText) +} + +func (c *JobAllocsCommand) Synopsis() string { + return "List allocations for a job" +} + +func (c *JobAllocsCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + "-verbose": complete.PredictNothing, + "-all": complete.PredictNothing, + }) +} + +func (c *JobAllocsCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictFunc(func(a complete.Args) []string { + client, err := c.Meta.Client() + if err != nil { + return nil + } + + resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Jobs, nil) + if err != nil { + return []string{} + } + return resp.Matches[contexts.Jobs] + }) +} + +func (c *JobAllocsCommand) Name() string { return "job allocations" } + +func (c *JobAllocsCommand) Run(args []string) int { + var json, verbose, all bool + var tmpl string + + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&verbose, "verbose", false, "") + flags.BoolVar(&all, "all", false, "") + flags.BoolVar(&json, "json", false, "") + flags.StringVar(&tmpl, "t", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got exactly one job + args = flags.Args() + if len(args) != 1 { + c.Ui.Error("This command takes one argument: ") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + jobID := strings.TrimSpace(args[0]) + + // Check if the job exists + jobs, _, err := client.Jobs().PrefixList(jobID) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error listing jobs: %s", err)) + return 1 + } + if len(jobs) == 0 { + c.Ui.Error(fmt.Sprintf("No job(s) with prefix or id %q found", jobID)) + return 1 + } + if len(jobs) > 1 { + if jobID != jobs[0].ID { + c.Ui.Error(fmt.Sprintf("Prefix matched multiple jobs\n\n%s", createStatusListOutput(jobs, c.allNamespaces()))) + return 1 + } + if c.allNamespaces() && jobs[0].ID == jobs[1].ID { + c.Ui.Error(fmt.Sprintf("Prefix matched multiple jobs\n\n%s", createStatusListOutput(jobs, c.allNamespaces()))) + return 1 + } + } + + jobID = jobs[0].ID + q := &api.QueryOptions{Namespace: jobs[0].JobSummary.Namespace} + + allocs, _, err := client.Jobs().Allocations(jobID, all, q) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error retrieving allocations: %s", err)) + return 1 + } + + if json || len(tmpl) > 0 { + out, err := Format(json, tmpl, allocs) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output(out) + return 0 + } + + // Truncate the id unless full length is requested + length := shortId + if verbose { + length = fullId + } + + c.Ui.Output(formatAllocListStubs(allocs, verbose, length)) + return 0 +} diff --git a/command/job_allocs_test.go b/command/job_allocs_test.go new file mode 100644 index 000000000000..449a12253a5b --- /dev/null +++ b/command/job_allocs_test.go @@ -0,0 +1,174 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/nomad/structs" + + "github.com/hashicorp/nomad/nomad/mock" + "github.com/mitchellh/cli" + "github.com/posener/complete" + "github.com/stretchr/testify/require" +) + +func TestJobAllocsCommand_Implements(t *testing.T) { + t.Parallel() + var _ cli.Command = &JobAllocsCommand{} +} + +func TestJobAllocsCommand_Fails(t *testing.T) { + t.Parallel() + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + + ui := cli.NewMockUi() + cmd := &JobAllocsCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + code := cmd.Run([]string{"some", "bad", "args"}) + outerr := ui.ErrorWriter.String() + require.Equalf(t, 1, code, "expected exit code 1, got: %d", code) + require.Containsf(t, outerr, commandErrorText(cmd), "expected help output, got: %s", outerr) + + ui.ErrorWriter.Reset() + + // Bad address + code = cmd.Run([]string{"-address=nope", "foo"}) + outerr = ui.ErrorWriter.String() + require.Equalf(t, 1, code, "expected exit code 1, got: %d", code) + require.Containsf(t, outerr, "Error listing jobs", "expected failed query error, got: %s", outerr) + + ui.ErrorWriter.Reset() + + // Bad job name + code = cmd.Run([]string{"-address=" + url, "foo"}) + outerr = ui.ErrorWriter.String() + require.Equalf(t, 1, code, "expected exit 1, got: %d", code) + require.Containsf(t, outerr, "No job(s) with prefix or id \"foo\" found", "expected no job found, got: %s", outerr) + + ui.ErrorWriter.Reset() +} + +func TestJobAllocsCommand_Run(t *testing.T) { + t.Parallel() + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + + ui := cli.NewMockUi() + cmd := &JobAllocsCommand{Meta: Meta{Ui: ui}} + + // Create a job without an allocation + job := mock.Job() + state := srv.Agent.Server().State() + require.Nil(t, state.UpsertJob(structs.MsgTypeTestSetup, 100, job)) + + // Should display no match if the job doesn't have allocations + code := cmd.Run([]string{"-address=" + url, job.ID}) + out := ui.OutputWriter.String() + require.Equalf(t, 0, code, "expected exit 0, got: %d", code) + require.Containsf(t, out, "No allocations placed", "expected no allocations placed, got: %s", out) + + ui.OutputWriter.Reset() + + // Inject an allocation + a := mock.Alloc() + a.Job = job + a.JobID = job.ID + a.TaskGroup = job.TaskGroups[0].Name + a.Metrics = &structs.AllocMetric{} + a.DesiredStatus = structs.AllocDesiredStatusRun + a.ClientStatus = structs.AllocClientStatusRunning + require.Nil(t, state.UpsertAllocs(structs.MsgTypeTestSetup, 200, []*structs.Allocation{a})) + + // Should now display the alloc + code = cmd.Run([]string{"-address=" + url, "-verbose", job.ID}) + out = ui.OutputWriter.String() + outerr := ui.ErrorWriter.String() + require.Equalf(t, 0, code, "expected exit 0, got: %d", code) + require.Emptyf(t, outerr, "expected no error output, got: \n\n%s", outerr) + require.Containsf(t, out, a.ID, "expected alloc output, got: %s", out) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} + +func TestJobAllocsCommand_Template(t *testing.T) { + t.Parallel() + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + + ui := cli.NewMockUi() + cmd := &JobAllocsCommand{Meta: Meta{Ui: ui}} + + // Create a job + job := mock.Job() + state := srv.Agent.Server().State() + require.Nil(t, state.UpsertJob(structs.MsgTypeTestSetup, 100, job)) + + // Inject a running allocation + a := mock.Alloc() + a.Job = job + a.JobID = job.ID + a.TaskGroup = job.TaskGroups[0].Name + a.Metrics = &structs.AllocMetric{} + a.DesiredStatus = structs.AllocDesiredStatusRun + a.ClientStatus = structs.AllocClientStatusRunning + require.Nil(t, state.UpsertAllocs(structs.MsgTypeTestSetup, 200, []*structs.Allocation{a})) + + // Inject a pending allocation + b := mock.Alloc() + b.Job = job + b.JobID = job.ID + b.TaskGroup = job.TaskGroups[0].Name + b.Metrics = &structs.AllocMetric{} + b.DesiredStatus = structs.AllocDesiredStatusRun + b.ClientStatus = structs.AllocClientStatusPending + require.Nil(t, state.UpsertAllocs(structs.MsgTypeTestSetup, 300, []*structs.Allocation{b})) + + // Should display an AllocacitonListStub object + code := cmd.Run([]string{"-address=" + url, "-t", "'{{printf \"%#+v\" .}}'", job.ID}) + out := ui.OutputWriter.String() + outerr := ui.ErrorWriter.String() + + require.Equalf(t, 0, code, "expected exit 0, got: %d", code) + require.Emptyf(t, outerr, "expected no error output, got: \n\n%s", outerr) + require.Containsf(t, out, "api.AllocationListStub", "expected alloc output, got: %s", out) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Should display only the running allocation ID + code = cmd.Run([]string{"-address=" + url, "-t", "'{{ range . }}{{ if eq .ClientStatus \"running\" }}{{ println .ID }}{{ end }}{{ end }}'", job.ID}) + out = ui.OutputWriter.String() + outerr = ui.ErrorWriter.String() + + require.Equalf(t, 0, code, "expected exit 0, got: %d", code) + require.Emptyf(t, outerr, "expected no error output, got: \n\n%s", outerr) + require.Containsf(t, out, a.ID, "expected ID of alloc a, got: %s", out) + require.NotContainsf(t, out, b.ID, "should not contain ID of alloc b, got: %s", out) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} + +func TestJobAllocsCommand_AutocompleteArgs(t *testing.T) { + t.Parallel() + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + + ui := cli.NewMockUi() + cmd := &JobAllocsCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + // Create a fake job + state := srv.Agent.Server().State() + j := mock.Job() + require.Nil(t, state.UpsertJob(structs.MsgTypeTestSetup, 1000, j)) + + prefix := j.ID[:len(j.ID)-5] + args := complete.Args{Last: prefix} + predictor := cmd.AutocompleteArgs() + + res := predictor.Predict(args) + require.Equal(t, 1, len(res)) + require.Equal(t, j.ID, res[0]) +} diff --git a/website/content/docs/commands/job/allocs.mdx b/website/content/docs/commands/job/allocs.mdx new file mode 100644 index 000000000000..c5bdaf8e4a35 --- /dev/null +++ b/website/content/docs/commands/job/allocs.mdx @@ -0,0 +1,76 @@ +--- +layout: docs +page_title: 'Commands: job allocs' +description: | + The allocs command is used to list allocations for a job. +--- + +# Command: job allocs + +The `job allocs` command is used to display the allocations for a +particular job. + +## Usage + +```plaintext +nomad job allocs [options] +``` + +The `job allocs` command requires a single argument, the job ID or an ID +prefix of a job to display the list of allocations for. + +When ACLs are enabled, this command requires a token with the `read-job` and +`list-jobs` capabilities for the job's namespace. + +## General Options + +@include 'general_options.mdx' + +## Allocs Options + +- `-all`: Display all allocations matching the job ID, even those from an + older instance of the job. + +- `-json`: Output the allocations in JSON format. + +- `-t`: Format and display the allocations using a Go template. + +- `-verbose`: Show full information. + +## Examples + +List the allocations for a particular job: + +```shell-session +$ nomad job allocs example +ID Node ID Task Group Version Desired Status Created Modified +c2b4606d 35085106 cache 2 run running 21s ago 10s ago +c413424b 35085106 cache 2 run pending 1m8s ago 11s ago +``` + +Verbose listing of allocations for a particular job: + +```shell-session +$ nomad job allocs -verbose example +ID Eval ID Node ID Node Name Task Group Version Desired Status Created Modified +c2b4606d-1b02-0d8d-5fdd-031167cd4c91 5e2badb6-b7cf-5177-8281-8fe14f7193d2 35085106-9480-b465-a348-deb745024394 ubuntu cache 2 run running 2021-09-23T14:45:09-04:00 2021-09-23T14:45:19-04:00 +c413424b-d80e-9bc6-ea92-a02b336eaaf5 5e2badb6-b7cf-5177-8281-8fe14f7193d2 35085106-9480-b465-a348-deb745024394 ubuntu cache 2 run pending 2021-09-23T14:44:22-04:00 2021-09-23T14:45:19-04:00 +``` + +Format job allocations using a Go template: +```shell-session +$ nomad job allocs -t '{{ range . }}{{ println .ID }}{{ end }}' example +c2b4606d-1b02-0d8d-5fdd-031167cd4c91 +c413424b-d80e-9bc6-ea92-a02b336eaaf5 +``` + +Use a Go template to filter only allocations which are running +```shell-session +$ nomad job allocs -t '{{ range . }}{{ if eq .ClientStatus "running" }}{{ println .ID }}{{ end }}{{ end }}' example +c2b4606d-1b02-0d8d-5fdd-031167cd4c91 +``` + +Refer to the [Format Nomad Command Output With Templates][format_tutorial] +tutorial for more examples of using Go templates to format Nomad CLI output. + +[format_tutorial]: https://learn.hashicorp.com/tutorials/nomad/format-output-with-templates diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 656c917c0d28..158a93709695 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -359,6 +359,10 @@ "title": "Overview", "path": "commands/job" }, + { + "title": "allocs", + "path": "commands/job/allocs" + }, { "title": "deployments", "path": "commands/job/deployments"