From 23ea37f8a413ba105a1d15d792e07302b7a27aaa Mon Sep 17 00:00:00 2001 From: Ilya Shevelev Date: Tue, 12 May 2026 23:58:32 +0300 Subject: [PATCH] cli: add -json-output and -t flags to nomad job plan command Add output formatting options to `nomad job plan` so users can get the plan result as JSON or formatted via Go templates. The existing `-json` flag is used for input parsing, so a new `-json-output` flag is introduced for output formatting to avoid conflicts. The `-t` flag enables Go template formatting, consistent with other commands like `nomad job inspect`. Both flags work for single-region and multi-region plans, and the existing human-readable output remains the default. Closes #27369 Co-Authored-By: Claude Opus 4.6 (1M context) --- command/job_plan.go | 52 ++++++++++++++++++++++++--- command/job_plan_test.go | 76 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/command/job_plan.go b/command/job_plan.go index 02bf5a372ec..05e7f7fc3cf 100644 --- a/command/job_plan.go +++ b/command/job_plan.go @@ -105,6 +105,12 @@ Plan Options: -verbose Increase diff verbosity. + + -json-output + Output the plan result in JSON format. + + -t + Format and display the plan result using a Go template. ` return strings.TrimSpace(helpText) } @@ -120,10 +126,12 @@ func (c *JobPlanCommand) AutocompleteFlags() complete.Flags { "-policy-override": complete.PredictNothing, "-verbose": complete.PredictNothing, "-json": complete.PredictNothing, + "-json-output": complete.PredictNothing, "-hcl2-strict": complete.PredictNothing, "-vault-namespace": complete.PredictAnything, "-var": complete.PredictAnything, "-var-file": complete.PredictFiles("*.var"), + "-t": complete.PredictAnything, }) } @@ -137,8 +145,8 @@ func (c *JobPlanCommand) AutocompleteArgs() complete.Predictor { func (c *JobPlanCommand) Name() string { return "job plan" } func (c *JobPlanCommand) Run(args []string) int { - var diff, policyOverride, verbose bool - var vaultNamespace string + var diff, policyOverride, verbose, jsonOutput bool + var vaultNamespace, tmpl string flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient) flagSet.Usage = func() { c.Ui.Output(c.Help()) } @@ -146,8 +154,10 @@ func (c *JobPlanCommand) Run(args []string) int { flagSet.BoolVar(&policyOverride, "policy-override", false, "") flagSet.BoolVar(&verbose, "verbose", false, "") flagSet.BoolVar(&c.JobGetter.JSON, "json", false, "") + flagSet.BoolVar(&jsonOutput, "json-output", false, "") flagSet.BoolVar(&c.JobGetter.Strict, "hcl2-strict", true, "") flagSet.StringVar(&vaultNamespace, "vault-namespace", "", "") + flagSet.StringVar(&tmpl, "t", "", "") flagSet.Var(&c.JobGetter.Vars, "var", "") flagSet.Var(&c.JobGetter.VarFiles, "var-file", "") @@ -155,6 +165,11 @@ func (c *JobPlanCommand) Run(args []string) int { return 255 } + if jsonOutput && len(tmpl) > 0 { + c.Ui.Error("Both -json-output and -t formatting are not allowed") + return 255 + } + // Check that we got exactly one job args = flagSet.Args() if len(args) != 1 { @@ -208,7 +223,7 @@ func (c *JobPlanCommand) Run(args []string) int { } if job.IsMultiregion() { - return c.multiregionPlan(client, job, opts, diff, verbose) + return c.multiregionPlan(client, job, opts, diff, verbose, jsonOutput, tmpl) } // Submit the job @@ -218,6 +233,17 @@ func (c *JobPlanCommand) Run(args []string) int { return 255 } + // If JSON or template output is requested, format the response and return. + if jsonOutput || len(tmpl) > 0 { + out, err := Format(jsonOutput, tmpl, resp) + if err != nil { + c.Ui.Error(err.Error()) + return 255 + } + c.Ui.Output(out) + return getExitCode(resp) + } + runArgs := strings.Builder{} for _, varArg := range c.JobGetter.Vars { runArgs.WriteString(fmt.Sprintf("-var=%q ", varArg)) @@ -243,7 +269,7 @@ func (c *JobPlanCommand) Run(args []string) int { return exitCode } -func (c *JobPlanCommand) multiregionPlan(client *api.Client, job *api.Job, opts *api.PlanOptions, diff, verbose bool) int { +func (c *JobPlanCommand) multiregionPlan(client *api.Client, job *api.Job, opts *api.PlanOptions, diff, verbose, jsonOutput bool, tmpl string) int { var exitCode int plans := map[string]*api.JobPlanResponse{} @@ -266,6 +292,24 @@ func (c *JobPlanCommand) multiregionPlan(client *api.Client, job *api.Job, opts return exitCode } + // If JSON or template output is requested, format the full map of region + // plans and return. + if jsonOutput || len(tmpl) > 0 { + out, err := Format(jsonOutput, tmpl, plans) + if err != nil { + c.Ui.Error(err.Error()) + return 255 + } + c.Ui.Output(out) + for _, resp := range plans { + regionExitCode := getExitCode(resp) + if regionExitCode > exitCode { + exitCode = regionExitCode + } + } + return exitCode + } + for regionName, resp := range plans { c.Ui.Output(c.Colorize().Color(fmt.Sprintf("[bold]Region: %q[reset]", regionName))) regionExitCode := c.outputPlannedJob(job, resp, diff, verbose) diff --git a/command/job_plan_test.go b/command/job_plan_test.go index cfaeae592b8..346ac84f46f 100644 --- a/command/job_plan_test.go +++ b/command/job_plan_test.go @@ -4,6 +4,7 @@ package command import ( + "encoding/json" "os" "strconv" "strings" @@ -306,3 +307,78 @@ func TestPlanCommand_JSON(t *testing.T) { must.Eq(t, 255, code) must.StrContains(t, ui.ErrorWriter.String(), "Error during plan: Put") } + +func TestPlanCommand_JsonOutputAndTemplateMutuallyExclusive(t *testing.T) { + ci.Parallel(t) + ui := cli.NewMockUi() + cmd := &JobPlanCommand{Meta: Meta{Ui: ui}} + + args := []string{ + "-json-output", + "-t", "{{.JobModifyIndex}}", + "testdata/example-short.json", + } + code := cmd.Run(args) + must.Eq(t, 255, code) + must.StrContains(t, ui.ErrorWriter.String(), "Both -json-output and -t formatting are not allowed") +} + +func TestPlanCommand_JsonOutput(t *testing.T) { + // Create a Vault server + v := testutil.NewTestVault(t) + defer v.Stop() + + // Create a Nomad server + s := testutil.NewTestServer(t, func(c *testutil.TestServerConfig) { + c.Vaults[0].Address = v.HTTPAddr + c.Vaults[0].Enabled = true + c.Vaults[0].AllowUnauthenticated = pointer.Of(false) + c.Vaults[0].Token = v.RootToken + }) + defer s.Stop() + + ui := cli.NewMockUi() + cmd := &JobPlanCommand{Meta: Meta{Ui: ui}} + args := []string{"-address", "http://" + s.HTTPAddr, "-json-output", "testdata/example-basic.nomad"} + code := cmd.Run(args) + // Exit code 1 means allocations created/destroyed (no client running to place) + must.Eq(t, 1, code, must.Sprintf("expected exit code 1, got %d: stderr=%s", code, ui.ErrorWriter.String())) + + // Verify the output is valid JSON matching the JobPlanResponse structure + out := ui.OutputWriter.Bytes() + var resp api.JobPlanResponse + must.NoError(t, json.Unmarshal(out, &resp), must.Sprintf("output should be valid JSON: %s", string(out))) + + // The response should have a Diff and Annotations since we always request diff + must.NotNil(t, resp.Diff) + must.NotNil(t, resp.Annotations) +} + +func TestPlanCommand_TemplateOutput(t *testing.T) { + // Create a Vault server + v := testutil.NewTestVault(t) + defer v.Stop() + + // Create a Nomad server + s := testutil.NewTestServer(t, func(c *testutil.TestServerConfig) { + c.Vaults[0].Address = v.HTTPAddr + c.Vaults[0].Enabled = true + c.Vaults[0].AllowUnauthenticated = pointer.Of(false) + c.Vaults[0].Token = v.RootToken + }) + defer s.Stop() + + ui := cli.NewMockUi() + cmd := &JobPlanCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", "http://" + s.HTTPAddr, + "-t", "{{.Diff.ID}}", + "testdata/example-basic.nomad", + } + code := cmd.Run(args) + must.Eq(t, 1, code, must.Sprintf("expected exit code 1, got %d: stderr=%s", code, ui.ErrorWriter.String())) + + // The template should produce the job ID from the diff + out := strings.TrimSpace(ui.OutputWriter.String()) + must.Eq(t, "example", out, must.Sprintf("expected job ID 'example', got: %s", out)) +}