From 3db9f11c3765b215da0fb0646b6054c4264e1491 Mon Sep 17 00:00:00 2001
From: Phil Renaud
Date: Thu, 2 Feb 2023 10:37:40 -0500
Subject: [PATCH] [feat] Nomad Job Templates (#15746)
* Extend variables under the nomad path prefix to allow for job-templates (#15570)
* Extend variables under the nomad path prefix to allow for job-templates
* Add job-templates to error message hinting
* RadioCard component for Job Templates (#15582)
* chore: add
* test: component API
* ui: component template
* refact: remove bc naming collission
* styles: remove SASS var causing conflicts
* Disallow specific variable at nomad/job-templates (#15681)
* Disallows variables at exactly nomad/job-templates
* idiomatic refactor
* Expanding nomad job init to accept a template flag (#15571)
* Adding a string flag for templates on job init
* data-down actions-up version of a custom template editor within variable
* Dont force grid on job template editor
* list-templates flag started
* Correctly slice from end of path name
* Pre-review cleanup
* Variable form acceptance test for job template editing
* Some review cleanup
* List Job templates test
* Example from template test
* Using must.assertions instead of require etc
* ui: add choose template button (#15596)
* ui: add new routes
* chore: update file directory
* ui: add choose template button
* test: button and page navigation
* refact: update var name
* ui: use `Button` component from `HDS` (#15607)
* ui: integrate buttons
* refact: remove helper
* ui: remove icons on non-tertiary buttons
* refact: update normalize method for key/value pairs (#15612)
* `revert`: `onCancel` for `JobDefinition`
The `onCancel` method isn't included in the component API for `JobEditor` and the primary cancel behavior exists outside of the component. With the exception of the `JobDefinition` page where we include this button in the top right of the component instead of next to the `Plan` button.
* style: increase button size
* style: keep lime green
* ui: select template (#15613)
* ui: deprecate unused component
* ui: deprecate tests
* ui: jobs.run.templates.index
* ui: update logic to handle templates
* refact: revert key/value changes
* style: padding for cards + buttons
* temp: fixtures for mirage testing
* Revert "refact: revert key/value changes"
This reverts commit 124e95d12140be38fc921f7e15243034092c4063.
* ui: guard template for unsaved job
* ui: handle reading template variable
* Revert "refact: update normalize method for key/value pairs (#15612)"
This reverts commit 6f5ffc9b610702aee7c47fbff742cc81f819ab74.
* revert: remove test fixtures
* revert: prettier problems
* refact: test doesnt need filter expression
* styling: button sizes and responsive cards
* refact: remove route guarding
* ui: update variable adapter
* refact: remove model editing behavior
* refact: model should query variables to populate editor
* ui: clear qp on exit
* refact: cleanup deprecated API
* refact: query all namespaces
* refact: deprecate action
* ui: rely on collection
* refact: patch deprecate transition API
* refact: patch test to expect namespace qp
* styling: padding, conditionals
* ui: flashMessage on 404
* test: update for o(n+1) query
* ui: create new job template (#15744)
* refact: remove unused code
* refact: add type safety
* test: select template flow
* test: add data-test attrs
* chore: remove dead code
* test: create new job flow
* ui: add create button
* ui: create job template
* refact: no need for wildcard
* refact: record instead of delete
* styling: spacing
* ui: add error handling and form validation to job create template (#15767)
* ui: handle server side errors
* ui: show error to prevent duplicate
* refact: conditional namespace
* ui: save as template flow (#15787)
* bug: patches failing tests associated with `pretender` (#15812)
* refact: update assertion
* refact: test set-up
* ui: job templates manager view (#15815)
* ui: manager list view
* test: edit flow
* refact: deprecate column-helper
* ui: template edit and delete flow (#15823)
* ui: manager list view
* refact: update title
* refact: update permissions
* ui: template edit page
* bug: typo
* refact: update toast messages
* bug: clear selections on exit (#15827)
* bug: clear controllers on exit
* test: mirage config changes (#15828)
* refact: deprecate column-helper
* style: update z-index for HDS
* Revert "style: update z-index for HDS"
This reverts commit d3d87ceab6d083f7164941587448607838944fc1.
* refact: update delete button
* refact: edit redirect
* refact: patch reactivity issues
* styling: fixed width
* refact: override defaults
* styling: edit text causing overflow
* styling: add inline text
Co-authored-by: Phil Renaud
* bug: edit `text` to `template`
Co-authored-by: Phil Renaud
Co-authored-by: Phil Renaud
* test: delete flow job templates (#15896)
* refact: edit names
* bug: set correct ref to store
* chore: trim whitespace:
* test: delete flow
* bug: reactively update view (#15904)
* Initialized default jobs (#15856)
* Initialized default jobs
* More jobs scaffolded
* Better commenting on a couple example job specs
* Adapter doing the work
* fall back to epic config
* Label format helper and custom serialization logic
* Test updates to account for a never-empty state
* Test suite uses settled and maintain RecordArray in adapter return
* Updates to hello-world and variables example jobspecs
* Parameterized job gets optional payload output
* Formatting changes for param and service discovery job templates
* Multi-group service discovery job
* Basic test for default templates (#15965)
* Basic test for default templates
* Percy snapshot for manage page
* Some late-breaking design changes
* Some copy edits to the header paragraphs for job templates (#15967)
* Added some init options for job templates (#15994)
* Async method for populating default job templates from the variable adapter
---------
Co-authored-by: Jai <41024828+ChaiWithJai@users.noreply.github.com>
---
.changelog/15746.txt | 3 +
command/job_init.go | 105 +++-
command/job_init_test.go | 80 ++++
nomad/structs/variables.go | 56 ++-
nomad/structs/variables_test.go | 2 +
ui/app/adapters/variable.js | 87 +++-
ui/app/components/job-editor.js | 3 +
ui/app/components/variable-form.hbs | 131 ++---
ui/app/components/variable-form.js | 23 +-
.../variable-form/job-template-editor.hbs | 32 ++
.../variable-form/job-template-editor.js | 23 +
ui/app/controllers/jobs/run.js | 13 +-
ui/app/controllers/jobs/run/index.js | 21 +
.../controllers/jobs/run/templates/index.js | 16 +
.../controllers/jobs/run/templates/manage.js | 48 ++
ui/app/controllers/jobs/run/templates/new.js | 82 ++++
.../jobs/run/templates/template.js | 80 ++++
ui/app/helpers/format-template-label.js | 14 +
ui/app/helpers/invoke-fn.js | 8 +
ui/app/router.js | 8 +-
ui/app/routes/jobs/run.js | 34 --
ui/app/routes/jobs/run/index.js | 73 +++
ui/app/routes/jobs/run/templates/index.js | 28 ++
ui/app/routes/jobs/run/templates/manage.js | 23 +
ui/app/routes/jobs/run/templates/new.js | 62 +++
ui/app/routes/jobs/run/templates/template.js | 28 ++
ui/app/serializers/variable.js | 8 +
ui/app/styles/app.scss | 1 +
ui/app/styles/components.scss | 4 +-
.../{badge.scss => badge-nomad-internal.scss} | 0
ui/app/styles/components/codemirror.scss | 25 +-
...down.scss => dropdown-nomad-internal.scss} | 0
ui/app/styles/components/variables.scss | 19 +
ui/app/styles/core/forms.scss | 38 +-
ui/app/templates/components/job-editor.hbs | 55 +--
.../templates/jobs/{run.hbs => run/index.hbs} | 2 +-
ui/app/templates/jobs/run/templates/index.hbs | 34 ++
.../templates/jobs/run/templates/manage.hbs | 57 +++
ui/app/templates/jobs/run/templates/new.hbs | 51 ++
.../templates/jobs/run/templates/template.hbs | 84 ++++
ui/app/utils/default-job-templates.js | 62 +++
ui/app/utils/default_jobs/hello-world.js | 61 +++
ui/app/utils/default_jobs/parameterized.js | 41 ++
.../utils/default_jobs/service-discovery.js | 80 ++++
ui/app/utils/default_jobs/variables.js | 48 ++
ui/ember-cli-build.js | 6 +
ui/mirage/config.js | 7 +-
ui/mirage/scenarios/default.js | 49 ++
ui/package.json | 1 +
ui/tests/acceptance/job-run-test.js | 450 +++++++++++++++++-
ui/tests/acceptance/variables-test.js | 53 +++
ui/yarn.lock | 203 +++++++-
website/content/docs/commands/job/init.mdx | 2 +
53 files changed, 2342 insertions(+), 182 deletions(-)
create mode 100644 .changelog/15746.txt
create mode 100644 ui/app/components/variable-form/job-template-editor.hbs
create mode 100644 ui/app/components/variable-form/job-template-editor.js
create mode 100644 ui/app/controllers/jobs/run/index.js
create mode 100644 ui/app/controllers/jobs/run/templates/index.js
create mode 100644 ui/app/controllers/jobs/run/templates/manage.js
create mode 100644 ui/app/controllers/jobs/run/templates/new.js
create mode 100644 ui/app/controllers/jobs/run/templates/template.js
create mode 100644 ui/app/helpers/format-template-label.js
create mode 100644 ui/app/helpers/invoke-fn.js
delete mode 100644 ui/app/routes/jobs/run.js
create mode 100644 ui/app/routes/jobs/run/index.js
create mode 100644 ui/app/routes/jobs/run/templates/index.js
create mode 100644 ui/app/routes/jobs/run/templates/manage.js
create mode 100644 ui/app/routes/jobs/run/templates/new.js
create mode 100644 ui/app/routes/jobs/run/templates/template.js
rename ui/app/styles/components/{badge.scss => badge-nomad-internal.scss} (100%)
rename ui/app/styles/components/{dropdown.scss => dropdown-nomad-internal.scss} (100%)
rename ui/app/templates/jobs/{run.hbs => run/index.hbs} (68%)
create mode 100644 ui/app/templates/jobs/run/templates/index.hbs
create mode 100644 ui/app/templates/jobs/run/templates/manage.hbs
create mode 100644 ui/app/templates/jobs/run/templates/new.hbs
create mode 100644 ui/app/templates/jobs/run/templates/template.hbs
create mode 100644 ui/app/utils/default-job-templates.js
create mode 100644 ui/app/utils/default_jobs/hello-world.js
create mode 100644 ui/app/utils/default_jobs/parameterized.js
create mode 100644 ui/app/utils/default_jobs/service-discovery.js
create mode 100644 ui/app/utils/default_jobs/variables.js
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}
+ */
+ async getJobTemplate(templateID) {
+ await this.populateDefaultJobTemplates();
+ const defaultJobs = this.store
+ .peekAll('variable')
+ .filter((template) => template.isDefaultJobTemplate);
+ if (defaultJobs.find((job) => job.id === templateID)) {
+ return defaultJobs.find((job) => job.id === templateID);
+ } else {
+ return this.store.findRecord('variable', templateID);
+ }
+ }
+
urlForFindAll(modelName) {
let baseUrl = this.buildURL(modelName);
return pluralize(baseUrl);
@@ -26,9 +91,22 @@ export default class VariableAdapter extends ApplicationAdapter {
return pluralize(baseUrl);
}
- urlForFindRecord(identifier, modelName, snapshot) {
- const { namespace, id } = _extractIDAndNamespace(identifier, snapshot);
- let baseUrl = this.buildURL(modelName, id);
+ urlForFindRecord(identifier, modelName) {
+ let path,
+ namespace = null;
+
+ // TODO: Variables are namespaced. This Adapter should extend the WatchableNamespaceId Adapter.
+ // When that happens, we will need to refactor this to accept JSON tuple like we do for jobs.
+ const delimiter = identifier.lastIndexOf('@');
+ if (delimiter !== -1) {
+ path = identifier.slice(0, delimiter);
+ namespace = identifier.slice(delimiter + 1);
+ } else {
+ path = identifier;
+ namespace = 'default';
+ }
+
+ let baseUrl = this.buildURL(modelName, path);
return `${baseUrl}?namespace=${namespace}`;
}
@@ -50,6 +128,9 @@ export default class VariableAdapter extends ApplicationAdapter {
}
handleResponse(status, _, payload) {
+ if (status === 404) {
+ return new AdapterError([{ detail: payload, status: 404 }]);
+ }
if (status === 409) {
return new ConflictError([
{ detail: _normalizeConflictErrorObject(payload), status: 409 },
diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.js
index 1690c7830..91e4d80d0 100644
--- a/ui/app/components/job-editor.js
+++ b/ui/app/components/job-editor.js
@@ -13,11 +13,13 @@ import classic from 'ember-classic-decorator';
export default class JobEditor extends Component {
@service store;
@service config;
+ @service router;
'data-test-job-editor' = true;
job = null;
onSubmit() {}
+ handleSaveAsTemplate() {}
@computed('_context')
get context() {
@@ -104,6 +106,7 @@ export default class JobEditor extends Component {
})
submit;
+ @action
reset() {
this.set('planOutput', null);
this.set('planError', null);
diff --git a/ui/app/components/variable-form.hbs b/ui/app/components/variable-form.hbs
index 2e8ad2aa7..c105a8877 100644
--- a/ui/app/components/variable-form.hbs
+++ b/ui/app/components/variable-form.hbs
@@ -3,10 +3,12 @@
{{/if}}
+ {{#if this.isJobTemplateVariable}}
+
+ Use this variable to generate job templates with
+ nomad job init -template={{this.jobTemplateName}}
+
+
+
+ {{/if}}
- {{#if (eq this.view "json")}}
-
-
- {{#if this.JSONError}}
-
- {{this.JSONError}}
-
- {{/if}}
-
+ {{#if this.isJobTemplateVariable}}
+
{{else}}
- {{#each this.keyValues as |entry iter|}}
-
-
-
+ {{#if (eq this.view "json")}}
+
+
+ {{#if this.JSONError}}
+
+ {{this.JSONError}}
+
+ {{/if}}
+
+ {{else}}
+ {{#each this.keyValues as |entry iter|}}
+
+
+
- {{#each-in entry.warnings as |k v|}}
-
- {{v}}
-
- {{/each-in}}
-
- {{/each}}
+ {{#each-in entry.warnings as |k v|}}
+
+ {{v}}
+
+ {{/each-in}}
+
+ {{/each}}
+ {{/if}}
{{/if}}
{{#if (and this.shouldShowLinkedEntities @model.isNew)}}
@@ -138,15 +157,17 @@