diff --git a/.changelog/15746.txt b/.changelog/15746.txt
new file mode 100644
index 000000000..b19da1647
--- /dev/null
+++ b/.changelog/15746.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui, cli: Adds Job Templates to the "Run Job" Web UI and makes them accessible via new flags on nomad job init
+```
diff --git a/command/job_init.go b/command/job_init.go
index 94522ae7e..531c9eeea 100644
--- a/command/job_init.go
+++ b/command/job_init.go
@@ -6,6 +6,7 @@ import (
"os"
"strings"
+ "github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
@@ -36,6 +37,14 @@ Init Options:
-connect
If the connect flag is set, the jobspec includes Consul Connect integration.
+
+ -template
+ Specifies a predefined template to initialize. Must be a Nomad Variable that
+ lives at nomad/job-templates/
+
+ -list-templates
+ Display a list of possible job templates to pass to -template. Reads from
+ all variables pathed at nomad/job-templates/
`
return strings.TrimSpace(helpText)
}
@@ -60,11 +69,15 @@ func (c *JobInitCommand) Name() string { return "job init" }
func (c *JobInitCommand) Run(args []string) int {
var short bool
var connect bool
+ var template string
+ var listTemplates bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&short, "short", false, "")
flags.BoolVar(&connect, "connect", false, "")
+ flags.StringVar(&template, "template", "", "The name of the job template variable to initialize")
+ flags.BoolVar(&listTemplates, "list-templates", false, "")
if err := flags.Parse(args); err != nil {
return 1
@@ -90,27 +103,87 @@ func (c *JobInitCommand) Run(args []string) int {
c.Ui.Error(fmt.Sprintf("Failed to stat '%s': %v", filename, err))
return 1
}
- if !os.IsNotExist(err) {
+ if !os.IsNotExist(err) && !listTemplates {
c.Ui.Error(fmt.Sprintf("Job '%s' already exists", filename))
return 1
}
var jobSpec []byte
- switch {
- case connect && !short:
- jobSpec, err = Asset("command/assets/connect.nomad")
- case connect && short:
- jobSpec, err = Asset("command/assets/connect-short.nomad")
- case !connect && short:
- jobSpec, err = Asset("command/assets/example-short.nomad")
- default:
- jobSpec, err = Asset("command/assets/example.nomad")
- }
- if err != nil {
- // should never see this because we've precompiled the assets
- // as part of `make generate-examples`
- c.Ui.Error(fmt.Sprintf("Accessed non-existent asset: %s", err))
- return 1
+
+ if listTemplates {
+ // Get the HTTP client
+ client, err := c.Meta.Client()
+ if err != nil {
+ c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
+ return 1
+ }
+ qo := &api.QueryOptions{
+ Namespace: c.Meta.namespace,
+ }
+
+ // Get and list all variables at nomad/job-templates
+ vars, _, err := client.Variables().PrefixList("nomad/job-templates/", qo)
+ if err != nil {
+ c.Ui.Error(fmt.Sprintf("Error retrieving job templates from the server; unable to read variables at path nomad/job-templates/. Error: %s", err))
+ return 1
+ }
+
+ if len(vars) == 0 {
+ c.Ui.Error("No variables in nomad/job-templates")
+ return 1
+ } else {
+ c.Ui.Output("Use nomad job init -template= with any of the following:")
+ for _, v := range vars {
+ c.Ui.Output(fmt.Sprintf(" %s", strings.TrimPrefix(v.Path, "nomad/job-templates/")))
+ }
+ }
+ return 0
+ } else if template != "" {
+ // Get the HTTP client
+ client, err := c.Meta.Client()
+ if err != nil {
+ c.Ui.Error(fmt.Sprintf("Error initializing: %s", err))
+ return 1
+ }
+
+ qo := &api.QueryOptions{
+ Namespace: c.Meta.namespace,
+ }
+ sv, _, err := client.Variables().Read("nomad/job-templates/"+template, qo)
+ if err != nil {
+ if err.Error() == "variable not found" {
+ c.Ui.Warn(errVariableNotFound)
+ return 1
+ }
+ c.Ui.Error(fmt.Sprintf("Error retrieving variable: %s", err))
+ return 1
+ }
+
+ if v, ok := sv.Items["template"]; ok {
+ c.Ui.Output(fmt.Sprintf("Initializing a job template from %s", template))
+ jobSpec = []byte(v)
+ } else {
+ c.Ui.Error(fmt.Sprintf("Job template %q is malformed and is missing a template field. Please visit the jobs/run/templates route in the Nomad UI to add it", template))
+ return 1
+ }
+
+ } else {
+ switch {
+ case connect && !short:
+ jobSpec, err = Asset("command/assets/connect.nomad")
+ case connect && short:
+ jobSpec, err = Asset("command/assets/connect-short.nomad")
+ case !connect && short:
+ jobSpec, err = Asset("command/assets/example-short.nomad")
+ default:
+ jobSpec, err = Asset("command/assets/example.nomad")
+ }
+ if err != nil {
+ // should never see this because we've precompiled the assets
+ // as part of `make generate-examples`
+ c.Ui.Error(fmt.Sprintf("Accessed non-existent asset: %s", err))
+ return 1
+ }
}
// Write out the example
diff --git a/command/job_init_test.go b/command/job_init_test.go
index 4c474e2b4..4ffd71cbe 100644
--- a/command/job_init_test.go
+++ b/command/job_init_test.go
@@ -1,6 +1,7 @@
package command
import (
+ "fmt"
"io/ioutil"
"os"
"strings"
@@ -8,6 +9,7 @@ import (
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
+ "github.com/shoenig/test/must"
"github.com/stretchr/testify/require"
)
@@ -86,6 +88,84 @@ func TestInitCommand_defaultJob(t *testing.T) {
}
}
+func TestInitCommand_listTemplates(t *testing.T) {
+ ci.Parallel(t)
+ srv, _, url := testServer(t, true, nil)
+ defer srv.Shutdown()
+
+ ui := cli.NewMockUi()
+
+ jobCmd := &JobInitCommand{Meta: Meta{Ui: ui}}
+ jobCmd.Run([]string{"-address=" + url, "-list-templates"})
+ expectedOutput := "No variables in nomad/job-templates\n"
+ must.StrContains(t, ui.ErrorWriter.String(), expectedOutput)
+
+ varCmd := &VarPutCommand{Meta: Meta{Ui: ui}}
+ // Set up 3 job template variables
+ for i := 1; i <= 3; i++ {
+ templateName := fmt.Sprintf("template-%d", i)
+ must.Eq(t, 0, varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/" + templateName, "k1=v1", "k2=v2", "k3=v3"}))
+ }
+ ui.ErrorWriter.Reset()
+ ui.OutputWriter.Reset()
+
+ jobCmd = &JobInitCommand{Meta: Meta{Ui: ui}}
+ must.Eq(t, 0, jobCmd.Run([]string{"-address=" + url, "-list-templates"}))
+ expectedOutput = "Use nomad job init -template= with any of the following:\n template-1\n template-2\n template-3\n"
+ must.StrContains(t, ui.OutputWriter.String(), expectedOutput)
+}
+
+func TestInitCommand_fromJobTemplate(t *testing.T) {
+ ci.Parallel(t)
+ srv, _, url := testServer(t, true, nil)
+ defer srv.Shutdown()
+
+ ui := cli.NewMockUi()
+ varCmd := &VarPutCommand{Meta: Meta{Ui: ui}}
+
+ tinyJob := `job "tiny" {
+ group "foo" {
+ task "bar" {
+ }
+ }
+ }`
+
+ // Set up job template variables
+ varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/invalid-template", "k1=v1"})
+ varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/valid-template", "template=" + tinyJob})
+ ui.ErrorWriter.Reset()
+ ui.OutputWriter.Reset()
+
+ // Ensure we change the cwd back
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("err: %s", err)
+ }
+ defer os.Chdir(origDir)
+
+ // Create a temp dir and change into it
+ dir := t.TempDir()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("err: %s", err)
+ }
+
+ jobCmd := &JobInitCommand{Meta: Meta{Ui: ui}}
+
+ // Doesnt work if our var lacks a template key
+ must.Eq(t, 1, jobCmd.Run([]string{"-address=" + url, "-template=invalid-template"}))
+
+ // Works if the file doesn't exist
+ must.Eq(t, 0, jobCmd.Run([]string{"-address=" + url, "-template=valid-template"}))
+
+ content, err := ioutil.ReadFile(DefaultInitName)
+ must.NoError(t, err)
+ must.Eq(t, string(content), string(tinyJob))
+
+ ui.ErrorWriter.Reset()
+ expectedOutput := "Initializing a job template from valid-template\nExample job file written to example.nomad\n"
+ must.StrContains(t, ui.OutputWriter.String(), expectedOutput)
+}
+
func TestInitCommand_customFilename(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
diff --git a/nomad/structs/variables.go b/nomad/structs/variables.go
index c9d798228..2c7b54e25 100644
--- a/nomad/structs/variables.go
+++ b/nomad/structs/variables.go
@@ -157,19 +157,8 @@ var (
func (v VariableDecrypted) Validate() error {
- if len(v.Path) == 0 {
- return fmt.Errorf("variable requires path")
- }
- if !validVariablePath.MatchString(v.Path) {
- return fmt.Errorf("invalid path %q", v.Path)
- }
-
- parts := strings.Split(v.Path, "/")
- switch {
- case len(parts) == 1 && parts[0] == "nomad":
- return fmt.Errorf("\"nomad\" is a reserved top-level directory path, but you may write variables to \"nomad/jobs\" or below")
- case len(parts) >= 2 && parts[0] == "nomad" && parts[1] != "jobs":
- return fmt.Errorf("only paths at \"nomad/jobs\" or below are valid paths under the top-level \"nomad\" directory")
+ if v.Namespace == AllNamespacesSentinel {
+ return errors.New("can not target wildcard (\"*\")namespace")
}
if len(v.Items) == 0 {
@@ -178,12 +167,49 @@ func (v VariableDecrypted) Validate() error {
if v.Items.Size() > maxVariableSize {
return errors.New("variables are limited to 64KiB in total size")
}
- if v.Namespace == AllNamespacesSentinel {
- return errors.New("can not target wildcard (\"*\")namespace")
+
+ if err := validatePath(v.Path); err != nil {
+ return err
}
+
return nil
}
+func validatePath(path string) error {
+ if len(path) == 0 {
+ return fmt.Errorf("variable requires path")
+ }
+ if !validVariablePath.MatchString(path) {
+ return fmt.Errorf("invalid path %q", path)
+ }
+
+ parts := strings.Split(path, "/")
+
+ if parts[0] != "nomad" {
+ return nil
+ }
+
+ // Don't allow a variable with path "nomad"
+ if len(parts) == 1 {
+ return fmt.Errorf("\"nomad\" is a reserved top-level directory path, but you may write variables to \"nomad/jobs\", \"nomad/job-templates\", or below")
+ }
+
+ switch {
+ case parts[1] == "jobs":
+ // Any path including "nomad/jobs" is valid
+ return nil
+ case parts[1] == "job-templates" && len(parts) == 3:
+ // Paths including "nomad/job-templates" is valid, provided they have single further path part
+ return nil
+ case parts[1] == "job-templates":
+ // Disallow exactly nomad/job-templates with no further paths
+ return fmt.Errorf("\"nomad/job-templates\" is a reserved directory path, but you may write variables at the level below it, for example, \"nomad/job-templates/template-name\"")
+ default:
+ // Disallow arbitrary sub-paths beneath nomad/
+ return fmt.Errorf("only paths at \"nomad/jobs\" or \"nomad/job-templates\" and below are valid paths under the top-level \"nomad\" directory")
+ }
+}
+
func (sv *VariableDecrypted) Canonicalize() {
if sv.Namespace == "" {
sv.Namespace = DefaultNamespace
diff --git a/nomad/structs/variables_test.go b/nomad/structs/variables_test.go
index 18762d9b2..e1b14290c 100644
--- a/nomad/structs/variables_test.go
+++ b/nomad/structs/variables_test.go
@@ -54,6 +54,8 @@ func TestStructs_VariableDecrypted_Validate(t *testing.T) {
{path: "example/_-~/whatever", ok: true},
{path: "example/@whatever"},
{path: "example/what.ever"},
+ {path: "nomad/job-templates"},
+ {path: "nomad/job-templates/whatever", ok: true},
}
for _, tc := range testCases {
tc := tc
diff --git a/ui/app/adapters/variable.js b/ui/app/adapters/variable.js
index 5d57f8c44..329fe71ed 100644
--- a/ui/app/adapters/variable.js
+++ b/ui/app/adapters/variable.js
@@ -1,10 +1,16 @@
+// @ts-check
import ApplicationAdapter from './application';
+import AdapterError from '@ember-data/adapter/error';
import { pluralize } from 'ember-inflector';
import classic from 'ember-classic-decorator';
import { ConflictError } from '@ember-data/adapter/error';
+import DEFAULT_JOB_TEMPLATES from 'nomad-ui/utils/default-job-templates';
+import { inject as service } from '@ember/service';
@classic
export default class VariableAdapter extends ApplicationAdapter {
+ @service store;
+
pathForType = () => 'var';
// PUT instead of POST on create;
@@ -16,6 +22,65 @@ export default class VariableAdapter extends ApplicationAdapter {
return this.ajax(`${baseUrl}?cas=${checkAndSetValue}`, 'PUT', { data });
}
+ /**
+ * Query for job templates, both defaults and variables at the nomad/job-templates path.
+ * @returns {Promise<{variables: Variable[], default: Variable[]}>}
+ */
+ async getJobTemplates() {
+ await this.populateDefaultJobTemplates();
+ const jobTemplateVariables = await this.store.query('variable', {
+ prefix: 'nomad/job-templates',
+ namespace: '*',
+ });
+
+ // Ensure we run a findRecord on each to get its keyValues
+ await Promise.all(
+ jobTemplateVariables.map((t) => this.store.findRecord('variable', t.id))
+ );
+
+ const defaultTemplates = this.store
+ .peekAll('variable')
+ .filter((t) => t.isDefaultJobTemplate);
+
+ return { variables: jobTemplateVariables, default: defaultTemplates };
+ }
+
+ async populateDefaultJobTemplates() {
+ await Promise.all(
+ DEFAULT_JOB_TEMPLATES.map((template) => {
+ if (!this.store.peekRecord('variable', template.id)) {
+ let variableSerializer = this.store.serializerFor('variable');
+ let normalized =
+ variableSerializer.normalizeDefaultJobTemplate(template);
+ return this.store.createRecord('variable', normalized);
+ }
+ return null;
+ })
+ );
+ }
+
+ /**
+ * @typedef Variable
+ * @type {object}
+ */
+
+ /**
+ * Lookup a job template variable by ID/path.
+ * @param {string} templateID
+ * @returns {Promise
+ Use this variable to generate job templates with
+ nomad job init -template={{this.jobTemplateName}}
+
+
- {{this.JSONError}} -
- {{/if}} -+ {{this.JSONError}} +
+ {{/if}} +