diff --git a/command/deploy.go b/command/deploy.go index 8bfd463a8..e0f153eb4 100644 --- a/command/deploy.go +++ b/command/deploy.go @@ -87,11 +87,17 @@ General Options: Specify the format of Levant's logs. Valid values are HUMAN or JSON. The default is HUMAN. + -json + Process template file as JSON + -var-file= Path to a file containing user variables used when rendering the job template. You can repeat this flag multiple times to supply multiple var-files. Defaults to levant.(json|yaml|yml|tf). [default: levant.(json|yaml|yml|tf)] + + -log-on-error + Enables the return of log messages if a deployment error in nomad appears ` return strings.TrimSpace(helpText) } @@ -129,7 +135,9 @@ func (c *DeployCommand) Run(args []string) int { flags.StringVar(&format, "log-format", "HUMAN", "") flags.StringVar(&config.Deploy.VaultToken, "vault-token", "", "") flags.BoolVar(&config.Deploy.EnvVault, "vault", false, "") + flags.BoolVar(&config.Deploy.LogOnError, "log-on-error", false, "") + flags.BoolVar(&config.Template.IsJSON, "json", false, "") flags.Var((*helper.FlagStringSlice)(&config.Template.VariableFiles), "var-file", "") if err = flags.Parse(args); err != nil { @@ -163,7 +171,7 @@ func (c *DeployCommand) Run(args []string) int { } config.Template.Job, err = template.RenderJob(config.Template.TemplateFile, - config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars) + config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars, config.Template.IsJSON) if err != nil { c.UI.Error(fmt.Sprintf("[ERROR] levant/command: %v", err)) return 1 diff --git a/command/deploy_test.go b/command/deploy_test.go index afbd0a910..3cbb2f682 100644 --- a/command/deploy_test.go +++ b/command/deploy_test.go @@ -33,7 +33,7 @@ func TestDeploy_checkCanaryAutoPromote(t *testing.T) { } for i, c := range cases { - job, err := template.RenderJob(c.File, []string{}, "", &fVars) + job, err := template.RenderJob(c.File, []string{}, "", &fVars, false) if err != nil { t.Fatalf("case %d failed: %v", i, err) } @@ -64,7 +64,7 @@ func TestDeploy_checkForceBatch(t *testing.T) { } for i, c := range cases { - job, err := template.RenderJob(c.File, []string{}, "", &fVars) + job, err := template.RenderJob(c.File, []string{}, "", &fVars, false) if err != nil { t.Fatalf("case %d failed: %v", i, err) } diff --git a/command/plan.go b/command/plan.go index 8bc974de4..41894b005 100644 --- a/command/plan.go +++ b/command/plan.go @@ -67,6 +67,9 @@ General Options: Specify the format of Levant's logs. Valid values are HUMAN or JSON. The default is HUMAN. + -json + Process template file as JSON + -var-file= Path to a file containing user variables used when rendering the job template. You can repeat this flag multiple times to supply multiple @@ -101,6 +104,7 @@ func (c *PlanCommand) Run(args []string) int { flags.BoolVar(&config.Plan.IgnoreNoChanges, "ignore-no-changes", false, "") flags.StringVar(&level, "log-level", "INFO", "") flags.StringVar(&format, "log-format", "HUMAN", "") + flags.BoolVar(&config.Template.IsJSON, "json", false, "") flags.Var((*helper.FlagStringSlice)(&config.Template.VariableFiles), "var-file", "") if err = flags.Parse(args); err != nil { @@ -128,7 +132,7 @@ func (c *PlanCommand) Run(args []string) int { } config.Template.Job, err = template.RenderJob(config.Template.TemplateFile, - config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars) + config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars, config.Template.IsJSON) if err != nil { c.UI.Error(fmt.Sprintf("[ERROR] levant/command: %v", err)) diff --git a/levant/structs/config.go b/levant/structs/config.go index b311a1713..4e3687eb5 100644 --- a/levant/structs/config.go +++ b/levant/structs/config.go @@ -86,6 +86,10 @@ type TemplateConfig struct { // VariableFiles contains the variables which will be substituted into the // templateFile before deployment. VariableFiles []string + + // VariableFiles contains the variables which will be substituted into the + // templateFile before deployment. + IsJSON bool } // ScaleConfig contains all the scaling specific configuration options. diff --git a/template/render.go b/template/render.go index 9533c0e69..8722bfe9e 100644 --- a/template/render.go +++ b/template/render.go @@ -22,13 +22,37 @@ import ( // RenderJob takes in a template and variables performing a render of the // template followed by Nomad jobspec parse. -func RenderJob(templateFile string, variableFiles []string, addr string, flagVars *map[string]interface{}) (job *nomad.Job, err error) { +func RenderJob(templateFile string, variableFiles []string, addr string, flagVars *map[string]interface{}, isJSON bool) (job *nomad.Job, err error) { var tpl *bytes.Buffer tpl, err = RenderTemplate(templateFile, variableFiles, addr, flagVars) if err != nil { return } + if isJSON { + // Support JSON files with both a top-level Job key as well as + // ones without. + eitherJob := struct { + NestedJob *nomad.Job `json:"Job"` + nomad.Job + }{} + + if err := json.NewDecoder(tpl).Decode(&eitherJob); err != nil { + return nil, fmt.Errorf("Failed to parse JSON job: %w", err) + } + + if eitherJob.NestedJob != nil { + if eitherJob.NestedJob.Name == nil && eitherJob.NestedJob.ID != nil { + eitherJob.NestedJob.Name = eitherJob.NestedJob.ID + } + if eitherJob.NestedJob.ID == nil { + return nil, fmt.Errorf("JSON is missing ID field") + } + return eitherJob.NestedJob, nil + } + return &eitherJob.Job, nil + } + return jobspec.Parse(tpl) } diff --git a/template/render_test.go b/template/render_test.go index 3c4b0e1ce..8fad1eae4 100644 --- a/template/render_test.go +++ b/template/render_test.go @@ -28,7 +28,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { fVars := make(map[string]interface{}) // Test basic TF template render. - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf"}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf"}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -40,7 +40,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { } // Test basic YAML template render. - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.yaml"}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.yaml"}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -52,7 +52,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { } // Test multiple var-files - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.yaml", "test-fixtures/test-overwrite.yaml"}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.yaml", "test-fixtures/test-overwrite.yaml"}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { } // Test multiple var-files of different types - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf", "test-fixtures/test-overwrite.yaml"}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf", "test-fixtures/test-overwrite.yaml"}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -71,7 +71,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { // Test multiple var-files with var-args fVars["job_name"] = testJobNameOverwrite2 - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf", "test-fixtures/test-overwrite.yaml"}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{"test-fixtures/test.tf", "test-fixtures/test-overwrite.yaml"}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -80,7 +80,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { } // Test empty var-args and empty variable file render. - job, err = RenderJob("test-fixtures/none_templated.nomad", []string{}, "", &fVars) + job, err = RenderJob("test-fixtures/none_templated.nomad", []string{}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -90,7 +90,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { // Test var-args only render. fVars = map[string]interface{}{"job_name": testJobName, "task_resource_cpu": "1313"} - job, err = RenderJob("test-fixtures/single_templated.nomad", []string{}, "", &fVars) + job, err = RenderJob("test-fixtures/single_templated.nomad", []string{}, "", &fVars, false) if err != nil { t.Fatal(err) } @@ -105,7 +105,7 @@ func TestTemplater_RenderTemplate(t *testing.T) { delete(fVars, "job_name") fVars["datacentre"] = testDCName os.Setenv(testEnvName, testEnvValue) - job, err = RenderJob("test-fixtures/multi_templated.nomad", []string{"test-fixtures/test.yaml"}, "", &fVars) + job, err = RenderJob("test-fixtures/multi_templated.nomad", []string{"test-fixtures/test.yaml"}, "", &fVars, false) if err != nil { t.Fatal(err) } diff --git a/test/acctest/acctest.go b/test/acctest/acctest.go index 6116b5b72..03782c0e6 100644 --- a/test/acctest/acctest.go +++ b/test/acctest/acctest.go @@ -83,6 +83,9 @@ func Test(t *testing.T, c TestCase) { break } } + } else if step.ExpectErr { + t.Errorf("step %d/%d failed not but was expected to fail", stepNum, len(c.Steps)) + break } } diff --git a/test/acctest/deploy.go b/test/acctest/deploy.go index a22fd02b1..1f3c8beca 100644 --- a/test/acctest/deploy.go +++ b/test/acctest/deploy.go @@ -20,6 +20,7 @@ type DeployTestStepRunner struct { Canary int ForceBatch bool ForceCount bool + IsJSON bool } // Run renders the job fixture and triggers a deployment @@ -29,7 +30,7 @@ func (c DeployTestStepRunner) Run(s *TestState) error { } c.Vars["job_name"] = s.JobName - job, err := template.RenderJob("fixtures/"+c.FixtureName, []string{}, "", &c.Vars) + job, err := template.RenderJob("fixtures/"+c.FixtureName, []string{}, "", &c.Vars, c.IsJSON) if err != nil { return fmt.Errorf("error rendering template: %s", err) } @@ -42,7 +43,8 @@ func (c DeployTestStepRunner) Run(s *TestState) error { }, Client: &structs.ClientConfig{}, Template: &structs.TemplateConfig{ - Job: job, + Job: job, + IsJSON: c.IsJSON, }, } diff --git a/test/deploy_test.go b/test/deploy_test.go index 7f9cc7bea..f3e6b7a02 100644 --- a/test/deploy_test.go +++ b/test/deploy_test.go @@ -23,6 +23,75 @@ func TestDeploy_basic(t *testing.T) { }) } +func TestDeploy_basicJson(t *testing.T) { + acctest.Test(t, acctest.TestCase{ + Steps: []acctest.TestStep{ + { + Runner: acctest.DeployTestStepRunner{ + FixtureName: "deploy_basic.nomad.json", + IsJSON: true, + }, + Check: acctest.CheckDeploymentStatus("successful"), + }, + }, + CleanupFunc: acctest.CleanupPurgeJob, + }) +} + +func TestDeploy_invalidJson(t *testing.T) { + acctest.Test(t, acctest.TestCase{ + Steps: []acctest.TestStep{ + { + Runner: acctest.DeployTestStepRunner{ + FixtureName: "deploy_basic_invalid.nomad.json", + IsJSON: true, + }, + ExpectErr: true, + CheckErr: func(err error) bool { + return err.Error() == "error rendering template: Failed to parse JSON job: json: cannot unmarshal array into Go struct field Job.Job.Type of type string" + }, + }, + }, + CleanupFunc: acctest.CleanupPurgeJob, + }) +} + +func TestDeploy_jsonWithoutIdName(t *testing.T) { + acctest.Test(t, acctest.TestCase{ + Steps: []acctest.TestStep{ + { + Runner: acctest.DeployTestStepRunner{ + FixtureName: "deploy_jsonWithoutIdName.nomad.json", + IsJSON: true, + }, + ExpectErr: true, + CheckErr: func(err error) bool { + return err.Error() == "error rendering template: JSON is missing ID field" + }, + }, + }, + CleanupFunc: acctest.CleanupPurgeJob, + }) +} + +func TestDeploy_notJson(t *testing.T) { + acctest.Test(t, acctest.TestCase{ + Steps: []acctest.TestStep{ + { + Runner: acctest.DeployTestStepRunner{ + FixtureName: "deploy_basic.nomad", + IsJSON: true, + }, + ExpectErr: true, + CheckErr: func(err error) bool { + return err.Error() == "error rendering template: Detected JSON but failed to parse JSON job: invalid character '#' looking for beginning of value" + }, + }, + }, + CleanupFunc: acctest.CleanupPurgeJob, + }) +} + func TestDeploy_driverError(t *testing.T) { acctest.Test(t, acctest.TestCase{ Steps: []acctest.TestStep{ diff --git a/test/fixtures/deploy_basic.nomad.json b/test/fixtures/deploy_basic.nomad.json new file mode 100644 index 000000000..3af6834f8 --- /dev/null +++ b/test/fixtures/deploy_basic.nomad.json @@ -0,0 +1,48 @@ +{ + "Job": { + "ID": "[[.job_name]]", + "Type": "service", + "Datacenters": [ + "dc1" + ], + "TaskGroups": [ + { + "Name": "test", + "Count": 1, + "Tasks": [ + { + "Name": "alpine", + "Driver": "docker", + "Config": { + "args": [ + "-f", + "/dev/null" + ], + "command": "tail", + "image": "alpine" + }, + "Resources": { + "CPU": 100, + "MemoryMB": 128 + } + } + ], + "RestartPolicy": { + "Interval": 300000000000, + "Attempts": 10, + "Delay": 25000000000, + "Mode": "delay" + }, + "EphemeralDisk": { + "SizeMB": 300 + } + } + ], + "Update": { + "MaxParallel": 1, + "MinHealthyTime": 10000000000, + "HealthyDeadline": 60000000000, + "AutoRevert": true + } + } +} diff --git a/test/fixtures/deploy_basic_invalid.nomad.json b/test/fixtures/deploy_basic_invalid.nomad.json new file mode 100644 index 000000000..f29560c9b --- /dev/null +++ b/test/fixtures/deploy_basic_invalid.nomad.json @@ -0,0 +1,48 @@ +{ + "Job": { + "ID": "[[.job_name]]", + "Type": ["service"], + "Datacenters": [ + "dc1" + ], + "TaskGroups": [ + { + "Name": "test", + "Count": 1, + "Tasks": [ + { + "Name": "alpine", + "Driver": "docker", + "Config": { + "args": [ + "-f", + "/dev/null" + ], + "command": "tail", + "image": "alpine" + }, + "Resources": { + "CPU": 100, + "MemoryMB": 128 + } + } + ], + "RestartPolicy": { + "Interval": 300000000000, + "Attempts": 10, + "Delay": 25000000000, + "Mode": "delay" + }, + "EphemeralDisk": { + "SizeMB": 300 + } + } + ], + "Update": { + "MaxParallel": 1, + "MinHealthyTime": 10000000000, + "HealthyDeadline": 60000000000, + "AutoRevert": true + } + } +} diff --git a/test/fixtures/deploy_jsonWithoutIdName.nomad.json b/test/fixtures/deploy_jsonWithoutIdName.nomad.json new file mode 100644 index 000000000..7617f6488 --- /dev/null +++ b/test/fixtures/deploy_jsonWithoutIdName.nomad.json @@ -0,0 +1,47 @@ +{ + "Job": { + "Type": "service", + "Datacenters": [ + "dc1" + ], + "TaskGroups": [ + { + "Name": "test", + "Count": 1, + "Tasks": [ + { + "Name": "alpine", + "Driver": "docker", + "Config": { + "args": [ + "-f", + "/dev/null" + ], + "command": "tail", + "image": "alpine" + }, + "Resources": { + "CPU": 100, + "MemoryMB": 128 + } + } + ], + "RestartPolicy": { + "Interval": 300000000000, + "Attempts": 10, + "Delay": 25000000000, + "Mode": "delay" + }, + "EphemeralDisk": { + "SizeMB": 300 + } + } + ], + "Update": { + "MaxParallel": 1, + "MinHealthyTime": 10000000000, + "HealthyDeadline": 60000000000, + "AutoRevert": true + } + } +}