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

cli: add -json flag to support job commands #12591

Merged
merged 2 commits into from
Apr 21, 2022
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
3 changes: 3 additions & 0 deletions .changelog/12591.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
cli: Added -json flag to `nomad job {run,plan,validate}` to support parsing JSON formatted jobs
schmichael marked this conversation as resolved.
Show resolved Hide resolved
```
80 changes: 67 additions & 13 deletions command/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package command
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strconv"
Expand All @@ -14,6 +14,7 @@ import (

gg "github.com/hashicorp/go-getter"
"github.com/hashicorp/nomad/api"
flaghelper "github.com/hashicorp/nomad/helper/flags"
"github.com/hashicorp/nomad/jobspec"
"github.com/hashicorp/nomad/jobspec2"
"github.com/kr/text"
Expand Down Expand Up @@ -379,19 +380,54 @@ READ:
return l.ReadCloser.Read(p)
}

// JobGetter provides helpers for retrieving and parsing a jobpsec.
type JobGetter struct {
hcl1 bool
HCL1 bool
Vars flaghelper.StringFlag
VarFiles flaghelper.StringFlag
Strict bool
JSON bool

// The fields below can be overwritten for tests
testStdin io.Reader
}

func (j *JobGetter) Validate() error {
if j.HCL1 && j.Strict {
return fmt.Errorf("cannot parse job file as HCLv1 and HCLv2 strict.")
}
if j.HCL1 && j.JSON {
return fmt.Errorf("cannot parse job file as HCL and JSON.")
}
if len(j.Vars) > 0 && j.JSON {
return fmt.Errorf("cannot use variables with JSON files.")
}
if len(j.VarFiles) > 0 && j.JSON {
return fmt.Errorf("cannot use variables with JSON files.")
}
if len(j.Vars) > 0 && j.HCL1 {
return fmt.Errorf("cannot use variables with HCLv1.")
}
if len(j.VarFiles) > 0 && j.HCL1 {
return fmt.Errorf("cannot use variables with HCLv1.")
}
return nil
}

// ApiJob returns the Job struct from jobfile.
func (j *JobGetter) ApiJob(jpath string) (*api.Job, error) {
return j.ApiJobWithArgs(jpath, nil, nil, true)
}

func (j *JobGetter) ApiJobWithArgs(jpath string, vars []string, varfiles []string, strict bool) (*api.Job, error) {
j.Vars = vars
j.VarFiles = varfiles
j.Strict = strict

return j.Get(jpath)
}

func (j *JobGetter) Get(jpath string) (*api.Job, error) {
var jobfile io.Reader
pathName := filepath.Base(jpath)
switch jpath {
Expand All @@ -401,19 +437,19 @@ func (j *JobGetter) ApiJobWithArgs(jpath string, vars []string, varfiles []strin
} else {
jobfile = os.Stdin
}
pathName = "stdin.hcl"
pathName = "stdin"
default:
if len(jpath) == 0 {
return nil, fmt.Errorf("Error jobfile path has to be specified.")
}

job, err := ioutil.TempFile("", "jobfile")
jobFile, err := os.CreateTemp("", "jobfile")
if err != nil {
return nil, err
}
defer os.Remove(job.Name())
defer os.Remove(jobFile.Name())

if err := job.Close(); err != nil {
if err := jobFile.Close(); err != nil {
return nil, err
}

Expand All @@ -426,13 +462,13 @@ func (j *JobGetter) ApiJobWithArgs(jpath string, vars []string, varfiles []strin
client := &gg.Client{
Src: jpath,
Pwd: pwd,
Dst: job.Name(),
Dst: jobFile.Name(),
}

if err := client.Get(); err != nil {
return nil, fmt.Errorf("Error getting jobfile from %q: %v", jpath, err)
} else {
file, err := os.Open(job.Name())
file, err := os.Open(jobFile.Name())
if err != nil {
return nil, fmt.Errorf("Error opening file %q: %v", jpath, err)
}
Expand All @@ -444,9 +480,27 @@ func (j *JobGetter) ApiJobWithArgs(jpath string, vars []string, varfiles []strin
// Parse the JobFile
var jobStruct *api.Job
var err error
if j.hcl1 {
switch {
case j.HCL1:
jobStruct, err = jobspec.Parse(jobfile)
} else {
case j.JSON:
// Support JSON files with both a top-level Job key as well as
// ones without.
eitherJob := struct {
NestedJob *api.Job `json:"Job"`
api.Job
}{}

if err := json.NewDecoder(jobfile).Decode(&eitherJob); err != nil {
return nil, fmt.Errorf("Failed to parse JSON job: %w", err)
}

if eitherJob.NestedJob != nil {
jobStruct = eitherJob.NestedJob
} else {
jobStruct = &eitherJob.Job
}
default:
var buf bytes.Buffer
_, err = io.Copy(&buf, jobfile)
if err != nil {
Expand All @@ -455,11 +509,11 @@ func (j *JobGetter) ApiJobWithArgs(jpath string, vars []string, varfiles []strin
jobStruct, err = jobspec2.ParseWithConfig(&jobspec2.ParseConfig{
Path: pathName,
Body: buf.Bytes(),
ArgVars: vars,
ArgVars: j.Vars,
AllowFS: true,
VarFiles: varfiles,
VarFiles: j.VarFiles,
Envs: os.Environ(),
Strict: strict,
Strict: j.Strict,
})

if err != nil {
Expand Down
78 changes: 78 additions & 0 deletions command/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,84 @@ func TestJobGetter_HTTPServer(t *testing.T) {
}
}

func TestJobGetter_Validate(t *testing.T) {
cases := []struct {
name string
jg JobGetter
errContains string
}{
{
"StrictAndHCL1",
JobGetter{
HCL1: true,
Strict: true,
},
"HCLv1 and HCLv2 strict",
},
{
"JSONandHCL1",
JobGetter{
HCL1: true,
JSON: true,
},
"HCL and JSON",
},
{
"VarsAndHCL1",
JobGetter{
HCL1: true,
Vars: []string{"foo"},
},
"variables with HCLv1",
},
{
"VarFilesAndHCL1",
JobGetter{
HCL1: true,
VarFiles: []string{"foo.var"},
},
"variables with HCLv1",
},
{
"VarsAndJSON",
JobGetter{
JSON: true,
Vars: []string{"foo"},
},
"variables with JSON",
},
{
"VarFilesAndJSON",
JobGetter{
JSON: true,
VarFiles: []string{"foo.var"},
},
"variables with JSON files",
},
{
"JSON_OK",
JobGetter{
JSON: true,
},
"",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.jg.Validate()

switch tc.errContains {
case "":
require.NoError(t, err)
default:
require.ErrorContains(t, err, tc.errContains)
}

})
}
}

func TestPrettyTimeDiff(t *testing.T) {
// Grab the time and truncate to the nearest second. This allows our tests
// to be deterministic since we don't have to worry about rounding.
Expand Down
36 changes: 25 additions & 11 deletions command/job_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"time"

"github.com/hashicorp/nomad/api"
flaghelper "github.com/hashicorp/nomad/helper/flags"
"github.com/hashicorp/nomad/scheduler"
"github.com/posener/complete"
)
Expand Down Expand Up @@ -76,6 +75,11 @@ Plan Options:
Determines whether the diff between the remote job and planned job is shown.
Defaults to true.

-json
Parses the job file as JSON. If the outer object has a Job field, such as
from "nomad job inspect" or "nomad run -output", the value of the field is
used as the job.

-hcl1
Parses the job file as HCLv1.

Expand Down Expand Up @@ -109,6 +113,7 @@ func (c *JobPlanCommand) AutocompleteFlags() complete.Flags {
"-diff": complete.PredictNothing,
"-policy-override": complete.PredictNothing,
"-verbose": complete.PredictNothing,
"-json": complete.PredictNothing,
"-hcl1": complete.PredictNothing,
"-hcl2-strict": complete.PredictNothing,
"-var": complete.PredictAnything,
Expand All @@ -117,23 +122,27 @@ func (c *JobPlanCommand) AutocompleteFlags() complete.Flags {
}

func (c *JobPlanCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictOr(complete.PredictFiles("*.nomad"), complete.PredictFiles("*.hcl"))
return complete.PredictOr(
complete.PredictFiles("*.nomad"),
complete.PredictFiles("*.hcl"),
complete.PredictFiles("*.json"),
)
}

func (c *JobPlanCommand) Name() string { return "job plan" }
func (c *JobPlanCommand) Run(args []string) int {
var diff, policyOverride, verbose, hcl2Strict bool
var varArgs, varFiles flaghelper.StringFlag
var diff, policyOverride, verbose bool

flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient)
flagSet.Usage = func() { c.Ui.Output(c.Help()) }
flagSet.BoolVar(&diff, "diff", true, "")
flagSet.BoolVar(&policyOverride, "policy-override", false, "")
flagSet.BoolVar(&verbose, "verbose", false, "")
flagSet.BoolVar(&c.JobGetter.hcl1, "hcl1", false, "")
flagSet.BoolVar(&hcl2Strict, "hcl2-strict", true, "")
flagSet.Var(&varArgs, "var", "")
flagSet.Var(&varFiles, "var-file", "")
flagSet.BoolVar(&c.JobGetter.JSON, "json", false, "")
flagSet.BoolVar(&c.JobGetter.HCL1, "hcl1", false, "")
flagSet.BoolVar(&c.JobGetter.Strict, "hcl2-strict", true, "")
flagSet.Var(&c.JobGetter.Vars, "var", "")
flagSet.Var(&c.JobGetter.VarFiles, "var-file", "")

if err := flagSet.Parse(args); err != nil {
return 255
Expand All @@ -147,9 +156,14 @@ func (c *JobPlanCommand) Run(args []string) int {
return 255
}

if err := c.JobGetter.Validate(); err != nil {
c.Ui.Error(fmt.Sprintf("Invalid job options: %s", err))
return 1
}

path := args[0]
// Get Job struct from Jobfile
job, err := c.JobGetter.ApiJobWithArgs(args[0], varArgs, varFiles, hcl2Strict)
job, err := c.JobGetter.Get(path)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error getting job struct: %s", err))
return 255
Expand Down Expand Up @@ -193,11 +207,11 @@ func (c *JobPlanCommand) Run(args []string) int {
}

runArgs := strings.Builder{}
for _, varArg := range varArgs {
for _, varArg := range c.JobGetter.Vars {
runArgs.WriteString(fmt.Sprintf("-var=%q ", varArg))
}

for _, varFile := range varFiles {
for _, varFile := range c.JobGetter.VarFiles {
runArgs.WriteString(fmt.Sprintf("-var-file=%q ", varFile))
}

Expand Down
16 changes: 16 additions & 0 deletions command/job_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,19 @@ func TestPlanCommad_Preemptions(t *testing.T) {
require.Contains(out, "batch")
require.Contains(out, "service")
}

func TestPlanCommad_JSON(t *testing.T) {
ui := cli.NewMockUi()
cmd := &JobPlanCommand{
Meta: Meta{Ui: ui},
}

args := []string{
"-address=http://nope",
"-json",
"testdata/example-short.json",
}
code := cmd.Run(args)
require.Equal(t, 255, code)
require.Contains(t, ui.ErrorWriter.String(), "Error during plan: Put")
}
Loading