[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 <phil.renaud@hashicorp.com>

* bug: edit `text` to `template`

Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com>

Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com>

* 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>
This commit is contained in:
Phil Renaud 2023-02-02 10:37:40 -05:00 committed by GitHub
parent 4caac1a92f
commit 3db9f11c37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2342 additions and 182 deletions

3
.changelog/15746.txt Normal file
View File

@ -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
```

View File

@ -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/<template>
-list-templates
Display a list of possible job templates to pass to -template. Reads from
all variables pathed at nomad/job-templates/<template>
`
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=<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

View File

@ -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=<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()

View File

@ -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

View File

@ -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

View File

@ -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<Variable>}
*/
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 },

View File

@ -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);

View File

@ -3,10 +3,12 @@
<form class="new-variables" autocomplete="off" {{on "submit" this.save}}>
{{#if @model.isNew}}
<div class="related-entities related-entities-hint">
<p>Prefix your path with <code>nomad/jobs/</code> to automatically make your variable accessible to a specified job, task group, or task.<br />
Format: <code>nomad/jobs/&lt;jobname&gt;</code>, <code>nomad/jobs/&lt;jobname&gt;/&lt;groupname&gt;</code>, <code>nomad/jobs/&lt;jobname&gt;/&lt;groupname&gt;/&lt;taskname&gt;</code></p>
</div>
{{#unless this.isJobTemplateVariable}}
<div class="related-entities related-entities-hint">
<p>Prefix your path with <code>nomad/jobs/</code> to automatically make your variable accessible to a specified job, task group, or task.<br />
Format: <code>nomad/jobs/&lt;jobname&gt;</code>, <code>nomad/jobs/&lt;jobname&gt;/&lt;groupname&gt;</code>, <code>nomad/jobs/&lt;jobname&gt;/&lt;groupname&gt;/&lt;taskname&gt;</code></p>
</div>
{{/unless}}
{{/if}}
{{#if this.hasConflict}}
@ -61,6 +63,16 @@
.
</p>
{{/if}}
{{#if this.isJobTemplateVariable}}
<p class="help job-template-hint">
Use this variable to generate job templates with
<code>nomad job init -template={{this.jobTemplateName}}
<CopyButton
@clipboardText="nomad job init -template={{this.jobTemplateName}}"
/>
</code>
</p>
{{/if}}
</label>
<VariableForm::NamespaceFilter
@data={{hash
@ -74,42 +86,48 @@
}}
/>
</div>
{{#if (eq this.view "json")}}
<div
class="editor-wrapper boxed-section-body is-full-bleed
{{if this.JSONError 'error'}}"
>
<div
data-test-json-editor
{{code-mirror
content=this.JSONItems
onUpdate=this.updateCode
extraKeys=(hash Cmd-Enter=(action "save"))
}}
></div>
{{#if this.JSONError}}
<p class="help is-danger">
{{this.JSONError}}
</p>
{{/if}}
</div>
{{#if this.isJobTemplateVariable}}
<VariableForm::JobTemplateEditor
@keyValues={{this.keyValues}}
@updateKeyValue={{this.updateKeyValue}}
/>
{{else}}
{{#each this.keyValues as |entry iter|}}
<div class="key-value">
<label>
<span>
Key
</span>
<Input
data-test-var-key
@type="text"
@value={{entry.key}}
class="input"
{{autofocus ignore=(eq iter 0)}}
{{on "input" (fn this.validateKey entry)}}
/>
</label>
<VariableForm::InputGroup @entry={{entry}} />
{{#if (eq this.view "json")}}
<div
class="editor-wrapper boxed-section-body is-full-bleed
{{if this.JSONError 'error'}}"
>
<div
data-test-json-editor
{{code-mirror
content=this.JSONItems
onUpdate=this.updateCode
extraKeys=(hash Cmd-Enter=(action "save"))
}}
></div>
{{#if this.JSONError}}
<p class="help is-danger">
{{this.JSONError}}
</p>
{{/if}}
</div>
{{else}}
{{#each this.keyValues as |entry iter|}}
<div class="key-value">
<label>
<span>
Key
</span>
<Input
data-test-var-key
@type="text"
@value={{entry.key}}
class="input"
{{autofocus ignore=(eq iter 0)}}
{{on "input" (fn this.validateKey entry)}}
/>
</label>
<VariableForm::InputGroup @entry={{entry}} />
<button
class="delete-row button is-danger is-inverted"
type="button"
@ -117,13 +135,14 @@
>
Delete
</button>
{{#each-in entry.warnings as |k v|}}
<span class="key-value-error help is-danger">
{{v}}
</span>
{{/each-in}}
</div>
{{/each}}
{{#each-in entry.warnings as |k v|}}
<span class="key-value-error help is-danger">
{{v}}
</span>
{{/each-in}}
</div>
{{/each}}
{{/if}}
{{/if}}
{{#if (and this.shouldShowLinkedEntities @model.isNew)}}
@ -138,15 +157,17 @@
<footer>
{{#unless this.isJSONView}}
<button
class="add-more button is-info is-inverted"
type="button"
disabled={{not (and this.keyValues.lastObject.key this.keyValues.lastObject.value)}}
{{on "click" this.appendRow}}
data-test-add-kv
>
Add More
</button>
{{#unless this.isJobTemplateVariable}}
<button
class="add-more button is-info is-inverted"
type="button"
disabled={{not (and this.keyValues.lastObject.key this.keyValues.lastObject.value)}}
{{on "click" this.appendRow}}
data-test-add-kv
>
Add More
</button>
{{/unless}}
{{/unless}}
<button
disabled={{this.shouldDisableSave}}

View File

@ -64,10 +64,23 @@ export default class VariableFormComponent extends Component {
get shouldDisableSave() {
const disallowedPath =
this.path?.startsWith('nomad/') && !this.path?.startsWith('nomad/jobs');
this.path?.startsWith('nomad/') &&
!(
this.path?.startsWith('nomad/jobs') ||
(this.path?.startsWith('nomad/job-templates') &&
trimPath([this.path]) !== 'nomad/job-templates')
);
return !!this.JSONError || !this.path || disallowedPath;
}
get isJobTemplateVariable() {
return this.path?.startsWith('nomad/job-templates/');
}
get jobTemplateName() {
return this.path.split('nomad/job-templates/').slice(-1);
}
/**
* @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
*/
@ -176,6 +189,14 @@ export default class VariableFormComponent extends Component {
set(this.args.model, 'path', e.target.value);
}
@action updateKeyValue(key, value) {
if (this.keyValues.find((kv) => kv.key === key)) {
this.keyValues.find((kv) => kv.key === key).value = value;
} else {
this.keyValues.pushObject({ key, value, warnings: EmberObject.create() });
}
}
@action
async save(e, overwrite = false) {
if (e.type === 'submit') {

View File

@ -0,0 +1,32 @@
{{did-insert this.establishKeyValues}}
<div>
<label>
<span>
Description
</span>
<Input
@type="text"
@value={{this.description}}
{{on "input" this.updateDescription}}
class="input value-input"
data-test-template-description
/>
</label>
</div>
<div>
<label>
<span>
Job Template
</span>
<div
data-test-template-json
{{code-mirror
theme="hashi"
mode="ruby"
autofocus=false
content=this.template
onUpdate=(action this.updateTemplate)
}}
></div>
</label>
</div>

View File

@ -0,0 +1,23 @@
// @ts-check
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class JobTemplateEditor extends Component {
@tracked description;
@tracked template;
@action
establishKeyValues() {
this.description = this.args.keyValues.findBy('key', 'description')?.value;
this.template = this.args.keyValues.findBy('key', 'template')?.value;
}
@action
updateDescription(event) {
this.args.updateKeyValue('description', event.target.value);
}
@action
updateTemplate(value) {
this.args.updateKeyValue('template', value);
}
}

View File

@ -1,9 +1,12 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class RunController extends Controller {
@service router;
onSubmit(id, namespace) {
this.router.transitionTo('jobs.job', `${id}@${namespace || 'default'}`);
export default class JobsRunController extends Controller {
@tracked jsonTemplate = null;
@action
setTemplate(template) {
this.jsonTemplate = template;
}
}

View File

@ -0,0 +1,21 @@
import { getOwner } from '@ember/application';
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class RunController extends Controller {
@service router;
queryParams = ['template'];
@action
handleSaveAsTemplate() {
getOwner(this)
.lookup('controller:jobs.run')
.setTemplate(this.model._newDefinition);
}
onSubmit(id, namespace) {
this.router.transitionTo('jobs.job', `${id}@${namespace || 'default'}`);
}
}

View File

@ -0,0 +1,16 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class JobsRunTemplatesIndexController extends Controller {
@tracked selectedTemplate = null;
get templates() {
return [...this.model.variables.toArray(), ...this.model.default];
}
@action
onChange(e) {
this.selectedTemplate = e.target.id;
}
}

View File

@ -0,0 +1,48 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class JobsRunTemplatesManageController extends Controller {
@service flashMessages;
@service router;
get templates() {
return [...this.model.variables.toArray(), ...this.model.default];
}
@tracked selectedTemplate = null;
columns = ['name', 'namespace', 'description', 'delete'].map((column) => {
return {
key: column,
label: `${column.charAt(0).toUpperCase()}${column.substring(1)}`,
};
});
formatTemplateLabel(path) {
return path.split('nomad/job-templates/')[1];
}
@task(function* (model) {
try {
yield model.destroyRecord();
this.flashMessages.add({
title: 'Job template deleted',
message: `${model.path} successfully deleted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
} catch (err) {
this.flashMessages.add({
title: `Job template could not be deleted.`,
message: err,
type: 'error',
destroyOnClick: false,
sticky: true,
});
}
})
deleteTemplate;
}

View File

@ -0,0 +1,82 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { trimPath } from '../../../../helpers/trim-path';
export default class JobsRunTemplatesNewController extends Controller {
@service router;
@service store;
@service system;
@tracked templateName = null;
@tracked templateNamespace = 'default';
get namespaceOptions() {
const namespaces = this.store
.peekAll('namespace')
.map(({ name }) => ({ key: name, label: name }));
return namespaces;
}
get isDuplicateTemplate() {
const templates = this.store.peekAll('variable');
const templateName = trimPath([`nomad/job-templates/${this.templateName}`]);
return !!templates
.without(this.model)
.find(
(v) => v.path === templateName && v.namespace === this.templateNamespace
);
}
@action
updateKeyValue(key, value) {
if (this.model.keyValues.find((kv) => kv.key === key)) {
this.model.keyValues.find((kv) => kv.key === key).value = value;
} else {
this.model.keyValues.pushObject({ key, value });
}
}
@action
async save(e, overwrite = false) {
if (e.type === 'submit') {
e.preventDefault();
}
if (this.model?.isNew) {
if (this.namespaceOptions) {
this.model.set('namespace', this.templateNamespace);
} else {
const [namespace] = this.store.peekAll('namespace').toArray();
this.model.set('namespace', namespace.id);
}
}
this.model.set('path', `nomad/job-templates/${this.templateName}`);
this.model.setAndTrimPath();
try {
await this.model.save({ adapterOptions: { overwrite } });
this.flashMessages.add({
title: 'Job template saved',
message: `${this.templateName} successfully saved`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('jobs.run.templates');
} catch (e) {
this.flashMessages.add({
title: 'Job template cannot be saved.',
message: e,
type: 'error',
destroyOnClick: false,
timeout: 5000,
});
}
}
}

View File

@ -0,0 +1,80 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class JobsRunTemplatesController extends Controller {
@service flashMessages;
@service router;
@service system;
@tracked formModalActive = false;
@action
updateKeyValue(key, value) {
if (this.model.keyValues.find((kv) => kv.key === key)) {
this.model.keyValues.find((kv) => kv.key === key).value = value;
} else {
this.model.keyValues.pushObject({ key, value });
}
}
@action
toggleModal() {
this.formModalActive = !this.formModalActive;
}
@action
async save(e, overwrite = false) {
if (e.type === 'submit') {
e.preventDefault();
}
try {
await this.model.save({ adapterOptions: { overwrite } });
this.flashMessages.add({
title: 'Job template saved',
message: `${this.model.path} successfully editted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('jobs.run.templates');
} catch (e) {
this.flashMessages.add({
title: 'Job template cannot be editted.',
message: e,
type: 'error',
destroyOnClick: false,
timeout: 5000,
});
}
}
@task(function* () {
try {
yield this.model.destroyRecord();
this.flashMessages.add({
title: 'Job template deleted',
message: `${this.model.path} successfully deleted`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('jobs.run.templates.manage');
} catch (err) {
this.flashMessages.add({
title: `Job template could not be deleted.`,
message: err,
type: 'error',
destroyOnClick: false,
sticky: true,
});
}
})
deleteTemplate;
}

View File

@ -0,0 +1,14 @@
import { helper } from '@ember/component/helper';
import { capitalize } from '@ember/string';
export default helper(function formatTemplateLabel([path]) {
// Removes the preceeding nomad/job-templates/default/
let label;
const delimiter = path.lastIndexOf('/');
if (delimiter !== -1) {
label = path.slice(delimiter + 1);
} else {
label = path;
}
return capitalize(label).split('-').join(' ');
});

View File

@ -0,0 +1,8 @@
import { helper } from '@ember/component/helper';
function invokeFn([scope, fn]) {
let args = arguments[0].slice(2);
return fn.apply(scope, args);
}
export default helper(invokeFn);

View File

@ -14,7 +14,13 @@ Router.map(function () {
});
this.route('jobs', function () {
this.route('run');
this.route('run', function () {
this.route('templates', function () {
this.route('new');
this.route('manage');
this.route('template', { path: '/:name' });
});
});
this.route('job', { path: '/:job_name' }, function () {
this.route('task-group', { path: '/:name' });
this.route('definition');

View File

@ -1,34 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
@classic
export default class RunRoute extends Route {
@service can;
@service store;
@service system;
beforeModel(transition) {
if (
this.can.cannot('run job', null, {
namespace: transition.to.queryParams.namespace,
})
) {
this.transitionTo('jobs');
}
}
model() {
// When jobs are created with a namespace attribute, it is verified against
// available namespaces to prevent redirecting to a non-existent namespace.
return this.store.findAll('namespace').then(() => {
return this.store.createRecord('job');
});
}
resetController(controller, isExiting) {
if (isExiting) {
controller.model.deleteRecord();
}
}
}

View File

@ -0,0 +1,73 @@
// @ts-check
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
@classic
export default class JobsRunIndexRoute extends Route {
@service can;
@service flashMessages;
@service router;
@service store;
@service system;
queryParams = {
template: {
refreshModel: true,
},
};
beforeModel(transition) {
if (
this.can.cannot('run job', null, {
namespace: transition.to.queryParams.namespace,
})
) {
this.router.transitionTo('jobs');
}
}
async model({ template }) {
try {
// When jobs are created with a namespace attribute, it is verified against
// available namespaces to prevent redirecting to a non-existent namespace.
await this.store.findAll('namespace');
if (template) {
const VariableAdapter = this.store.adapterFor('variable');
const templateRecord = await VariableAdapter.getJobTemplate(template);
return this.store.createRecord('job', {
_newDefinition: templateRecord.items.template,
});
} else {
return this.store.createRecord('job');
}
} catch (e) {
this.handle404(e);
}
}
handle404(e) {
const error404 = e.errors?.find((err) => err.status === 404);
if (error404) {
this.flashMessages.add({
title: `Error loading job template`,
message: error404.detail,
type: 'error',
destroyOnClick: false,
sticky: true,
});
return;
}
notifyForbidden(this)(e);
}
resetController(controller, isExiting) {
if (isExiting) {
controller.model?.deleteRecord();
controller.set('template', null);
}
}
}

View File

@ -0,0 +1,28 @@
// @ts-check
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class JobsRunTemplatesIndexRoute extends Route {
@service can;
@service router;
@service store;
beforeModel() {
const hasPermissions = this.can.can('write variable', null, {
namespace: '*',
path: '*',
});
if (!hasPermissions) {
this.router.transitionTo('jobs');
}
}
model() {
return this.store.adapterFor('variable').getJobTemplates();
}
resetController(controller) {
controller.set('selectedTemplate', null);
}
}

View File

@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class JobsRunTemplatesManageRoute extends Route {
@service can;
@service router;
@service store;
beforeModel() {
const hasPermissions = this.can.can('write variable', null, {
namespace: '*',
path: '*',
});
if (!hasPermissions) {
this.router.transitionTo('jobs');
}
}
model() {
return this.store.adapterFor('variable').getJobTemplates();
}
}

View File

@ -0,0 +1,62 @@
import { getOwner } from '@ember/application';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
export default class JobsRunTemplatesNewRoute extends Route {
@service can;
@service router;
@service store;
@service system;
beforeModel(transition) {
if (
this.can.cannot('write variable', null, {
namespace: transition.to.queryParams.namespace,
})
) {
this.router.transitionTo('jobs.run');
}
}
async model() {
try {
// When variables are created with a namespace attribute, it is verified against
// available namespaces to prevent redirecting to a non-existent namespace.
await Promise.all([
this.store.query('variable', {
prefix: 'nomad/job-templates',
namespace: '*',
}),
this.store.findAll('namespace'),
]);
// When navigating from jobs.run.index using "Save as Template"
const json = getOwner(this).lookup('controller:jobs.run').jsonTemplate;
if (json) {
return this.store.createRecord('variable', {
keyValues: [{ key: 'template', value: json }],
});
}
// Override Default Value
return this.store.createRecord('variable', { keyValues: [] });
} catch (e) {
notifyForbidden(this)(e);
}
}
resetController(controller, isExiting) {
if (
isExiting &&
controller.model.isNew &&
!controller.model.isDestroyed &&
!controller.model.isDestroying
) {
controller.model?.unloadRecord();
}
controller.set('templateName', null);
controller.set('templateNamespace', 'default');
getOwner(this).lookup('controller:jobs.run').jsonTemplate = null;
}
}

View File

@ -0,0 +1,28 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
export default class JobsRunTemplatesTemplateRoute extends Route {
@service can;
@service router;
@service store;
@service system;
beforeModel(transition) {
if (
this.can.cannot('write variable', null, {
namespace: transition.to.queryParams.namespace,
})
) {
this.router.transitionTo('jobs.run');
}
}
async model({ name }) {
try {
return this.store.findRecord('variable', name);
} catch (e) {
notifyForbidden(this)(e);
}
}
}

View File

@ -32,6 +32,14 @@ export default class VariableSerializer extends ApplicationSerializer {
);
}
normalizeDefaultJobTemplate(hash) {
return {
path: hash.id,
isDefaultJobTemplate: true,
...hash,
};
}
// Transform our KeyValues array into an Items object
serialize(snapshot, options) {
const json = super.serialize(snapshot, options);

View File

@ -3,6 +3,7 @@
@import 'ember-power-select';
@import './components';
@import '@hashicorp/design-system-components';
@import './charts';
// Only necessary in dev

View File

@ -1,12 +1,12 @@
@import './components/accordion';
@import './components/badge';
@import './components/badge-nomad-internal';
@import './components/boxed-section';
@import './components/codemirror';
@import './components/copy-button';
@import './components/cli-window';
@import './components/das-interstitial';
@import './components/dashboard-metric';
@import './components/dropdown';
@import './components/dropdown-nomad-internal';
@import './components/ember-power-select';
@import './components/empty-message';
@import './components/error-container';

View File

@ -138,16 +138,21 @@ header.run-job-header {
& > h1 {
grid-column: -1 / 1;
}
}
.job-spec-upload {
.button {
cursor: pointer;
}
input {
width: 100%;
height: 100%;
position: absolute;
display: none;
}
.job-spec-upload {
.button {
cursor: pointer;
}
input {
width: 100%;
height: 100%;
position: absolute;
display: none;
}
}
.buttonset {
display: flex;
justify-content: space-between;
}

View File

@ -169,6 +169,25 @@ table.path-tree {
}
}
.job-template-hint {
margin-top: 0.5rem;
code {
background-color: #eee;
padding: 0.25rem;
}
.copy-button {
display: inline-block;
padding-left: 0;
position: relative;
top: -5px;
button,
.button {
background-color: transparent;
padding-right: 0.25rem;
}
}
}
table.variable-items {
// table-layout: fixed;
td.value-cell {

View File

@ -89,6 +89,42 @@
}
}
.radio-group {
padding: 16px 0px;
}
.button-group {
margin: 16px 0px;
}
.align-right {
margin-right: calc(6.5% - 16px);
}
.new-job-template {
& > div {
margin-bottom: 1rem;
}
.path-input {
height: 2.25em;
&.error {
color: $red;
border-color: $red;
}
+ p {
position: relative;
animation: slide-in 0.3s ease-out;
}
}
.input-dropdown-row {
display: grid;
grid-template-columns: 6fr 1fr;
gap: 0 1rem;
}
}
.mock-sso-provider {
margin: 25vh auto;
@ -119,4 +155,4 @@
}
}
}
}
}

View File

@ -21,11 +21,9 @@
<header class="run-job-header">
<h1 class="title is-3">Run a job</h1>
<p>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted. You can also attach a job spec by uploading a job file or dragging &amp; dropping a file to the editor.</p>
<label class="job-spec-upload">
<input type="file" onchange={{action this.uploadJobSpec}} accept=".hcl,.json,.nomad" />
<span class="button">Upload a job spec file</span>
</label>
<p>
Paste or author HCL or JSON to submit to your cluster, or select from a list of templates. A plan will be requested before the job is submitted. You can also attach a job spec by uploading a job file or dragging &amp; dropping a file to the editor.
</p>
</header>
<div class="boxed-section">
@ -54,14 +52,23 @@
</div>
</div>
<div class="content is-associative">
<button
class="button is-primary {{if this.plan.isRunning 'is-loading'}}"
type="button"
onclick={{perform this.plan}}
<div class="is-associative buttonset">
<Hds::Button
{{on "click" (perform this.plan)}}
disabled={{or this.plan.isRunning (not this.job._newDefinition)}}
data-test-plan
>Plan</button>
@text="Plan"
/>
<Hds::ButtonSet>
<label class="job-spec-upload hds-button hds-button--color-secondary hds-button--size-medium">
<div class="hds-button__text">Upload file</div>
<input type="file" onchange={{action this.uploadJobSpec}} accept=".hcl,.json,.nomad" />
</label>
{{#if (can "write variable" path="*" namespace="*")}}
<Hds::Button @icon="file-plus" @text="Save as template" @color="tertiary" @route="jobs.run.templates.new" {{on "click" this.handleSaveAsTemplate}} data-test-save-as-template />
<Hds::Button @icon="file-text" @text="Choose from a template" @color="tertiary" @route="jobs.run.templates" data-test-choose-template />
{{/if}}
</Hds::ButtonSet>
</div>
{{/if}}
@ -75,12 +82,7 @@
will have on your cluster.</p>
</div>
<div class="column is-centered is-minimum">
<button
class="button is-info"
onclick={{toggle-action "showPlanMessage" this}}
data-test-plan-help-dismiss
type="button"
>Okay</button>
<Hds::Button @text="Okay" {{on "click" (toggle-action "showPlanMessage" this)}} data-test-plan-help-dismiss />
</div>
</div>
</div>
@ -147,19 +149,8 @@
</div>
</div>
{{/if}}
<div class="content is-associative">
<button
class="button is-primary {{if this.submit.isRunning 'is-loading'}}"
type="button"
onclick={{perform this.submit}}
disabled={{this.submit.isRunning}}
data-test-run
>Run</button>
<button
class="button is-light"
type="button"
onclick={{action this.reset}}
data-test-cancel
>Cancel</button>
</div>
<Hds::ButtonSet class="is-associative">
<Hds::Button @size="small" @text="Run" {{on "click" (perform this.submit)}} disabled={{this.submit.isRunning}} data-test-run />
<Hds::Button @size="small" @color="critical" @text="Cancel" {{on "click" this.reset}} disabled={{this.submit.isRunning}} data-test-cancel />
</Hds::ButtonSet>
{{/if}}

View File

@ -1,5 +1,5 @@
<Breadcrumb @crumb={{hash label="Run" args=(array "jobs.run")}} />
{{page-title "Run a job"}}
<section class="section">
<JobEditor @job={{this.model}} @context="new" @onSubmit={{action this.onSubmit}} />
<JobEditor @job={{this.model}} @context="new" @onSubmit={{action this.onSubmit}} @handleSaveAsTemplate={{this.handleSaveAsTemplate}} />
</section>

View File

@ -0,0 +1,34 @@
<section class="section">
<header class="run-job-header">
<h1 class="title is-3">Choose a template</h1>
<p>Select a custom or default job template below. You will have an opportunity to modify and plan your job before it is submitted.</p>
</header>
{{#if (eq this.templates.length 0)}}
<h3 data-test-empty-templates-list-headline class="empty-message-headline">
You have no templates to choose from. Would you like to <Hds::Link::Inline @route="jobs.run.templates.new" data-test-create-inline>create</Hds::Link::Inline> one?
</h3>
<Hds::Button class="button-group" @text="Back to editor" @route="jobs.run" @icon="arrow-left" data-test-cancel />
{{else}}
<main class="radio-group" data-test-template-list>
<Hds::Form::RadioCard::Group as |G|>
<G.Legend>Select a Template</G.Legend>
{{#each this.templates as |card|}}
<G.RadioCard class="form-container" @layout="fixed" @maxWidth="30%" @checked={{eq card.id this.selectedTemplate}} id={{card.id}} data-test-template-card={{format-template-label card.path}} {{on "change" this.onChange}} as |R|>
<R.Label data-test-template-label>{{format-template-label card.path}}</R.Label>
<R.Description data-test-template-description>{{card.items.description}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
</main>
<footer class="buttonset">
<Hds::ButtonSet class="button-group">
<Hds::Button @text="Apply" @route="jobs.run" @query={{hash template=this.selectedTemplate}} disabled={{is-equal this.selectedTemplate null}} data-test-apply />
<Hds::Button @text="Cancel" @route="jobs.run" @color="secondary" data-test-cancel />
</Hds::ButtonSet>
<Hds::ButtonSet class="button-group align-right">
<Hds::Button @text="Manage" @color="tertiary" @icon="edit" @route="jobs.run.templates.manage" data-test-manage-button />
<Hds::Button @text="Create New Template" @color="tertiary" @icon="file-plus" @route="jobs.run.templates.new" data-test-create-new-button />
</Hds::ButtonSet>
</footer>
{{/if}}
</section>

View File

@ -0,0 +1,57 @@
{{page-title "Manage templates"}}
<section class="section">
<header class="run-job-header">
<h1 class="title is-3">Manage Job Templates</h1>
<p>Modify or Delete a job template from the list below. Default job templates cannot be removed.</p>
</header>
{{#if (eq this.model.length 0)}}
<h3 data-test-empty-templates-list-headline class="empty-message-headline">
You have no templates to choose from. Would you like to <Hds::Link::Inline @route="jobs.run.templates.new" data-test-create-inline>create</Hds::Link::Inline> one?
</h3>
<Hds::Button class="button-group" @text="Back to editor" @route="jobs.run" @icon="arrow-left" data-test-cancel />
{{else}}
<main class="radio-group" data-test-template-list>
<Hds::Table @model={{this.templates}} @columns={{this.columns}} @isFixedLayout={{true}}>
<:body as |B|>
<B.Tr>
<B.Td>
{{#if B.data.isDefaultJobTemplate}}
{{format-template-label B.data.path}}
{{else}}
<LinkTo @route="jobs.run.templates.template" @model={{concat B.data.path "@" B.data.namespace}} data-test-edit-template={{B.data.path}}>
{{format-template-label B.data.path}}
</LinkTo>
{{/if}}
</B.Td>
<B.Td>{{B.data.namespace}}</B.Td>
<B.Td>
{{B.data.items.description}}
</B.Td>
<B.Td>
{{#if B.data.isDefaultJobTemplate}}
<em>(Default Job — cannot be deleted)</em>
{{else}}
<TwoStepButton
data-test-delete
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes"
@inlineText={{true}}
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deleteTemplate.isRunning}}
@disabled={{cannot "destroy variable" namespace="*"}}
@onConfirm={{perform this.deleteTemplate B.data}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</main>
<footer class="buttonset">
<Hds::ButtonSet class="button-group">
<Hds::Button @text="Cancel" @color="secondary" @route="jobs.run.templates" data-test-done />
</Hds::ButtonSet>
</footer>
{{/if}}
</section>

View File

@ -0,0 +1,51 @@
{{page-title "Create a custom template"}}
<section class="section">
<header class="run-job-header">
<h1 class="title is-3">Create template</h1>
<p>Provide a job spec that you or others can re-use later. Anytime it is applied to a new job, you will have the opportunity to modify it before that job is run.</p>
</header>
<form class="new-job-template" autocomplete="off">
<div class={{if this.system.shouldShowNamespaces "input-dropdown-row"}}>
<label>
<span>
Template name
</span>
<Input
@type="text"
@value={{mut this.templateName}}
placeholder="your-template-name-here"
class="input path-input {{if this.isDuplicateTemplate "error"}}"
{{autofocus}}
data-test-template-name
/>
{{#if this.isDuplicateTemplate}}
<p class="help is-danger" data-test-duplicate-error>
There is already a templated named {{this.templateName}}.
</p>
{{/if}}
</label>
{{#if this.system.shouldShowNamespaces}}
<label>
<span>
Namespace
</span>
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@options={{this.namespaceOptions}}
@selection={{this.templateNamespace}}
@onSelect={{fn (mut this.templateNamespace)}}
/>
</label>
{{/if}}
</div>
<VariableForm::JobTemplateEditor
@keyValues={{this.model.keyValues}}
@updateKeyValue={{this.updateKeyValue}}
/>
<footer class="button-group">
<Hds::Button @text="Save" disabled={{is-empty this.templateName}} {{on "click" this.save}} data-test-save-template />
<Hds::Button @text="Cancel" @route="jobs.run" @color="secondary" data-test-cancel-template />
</footer>
</form>
</section>

View File

@ -0,0 +1,84 @@
{{page-title "Edit template"}}
<section class="section">
<header class="run-job-header">
<div>
<h1 class="title is-3">Edit template</h1>
</div>
<TwoStepButton
data-test-delete
@alignRight={{true}}
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, Delete Template"
@inlineText={{true}}
@confirmationMessage="Are you sure?"
@awaitingConfirmation={{this.deleteTemplate.isRunning}}
@disabled={{cannot "destroy variable" namespace="*"}}
@onConfirm={{perform this.deleteTemplate}}
/>
</header>
<form class="new-job-template" autocomplete="off">
<div class={{if this.system.shouldShowNamespaces "input-dropdown-row"}}>
<label>
<span>
Template name
</span>
<Input
@type="text"
@value={{this.model.path}}
class="input path-input"
disabled
data-test-template-name
/>
</label>
{{#if this.system.shouldShowNamespaces}}
<label>
<span>
Namespace
</span>
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@selection={{this.model.namespace}}
{{!-- This is a read-only. But, we must follow the Component API for options --}}
@options={{array (hash key=this.model.namespace label=this.model.namespace)}}
@disabled={{true}}
/>
</label>
{{/if}}
</div>
<VariableForm::JobTemplateEditor
@keyValues={{this.model.keyValues}}
@updateKeyValue={{this.updateKeyValue}}
/>
<footer class="button-group">
<Hds::Button @text="Edit" {{on "click" this.save}} data-test-edit-template />
<Hds::Button @text="Cancel" @route="jobs.run.templates" @color="critical" data-test-cancel-template />
</footer>
</form>
</section>
{{#if this.formModalActive}}
<Hds::Modal
id="form-modal"
@onClose={{this.toggleModal}}
@color="critical"
data-test-confirmation-modal
as |M|
>
<M.Header>
Confirm
</M.Header>
<M.Body>
Are you sure you want to delete this template?
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button type="submit" @text="Confirm"
{{on "click" this.deleteTemplateAndClose}}
data-test-delete-template
/>
<Hds::Button type="button" @text="Cancel" @color="secondary" {{on "click" F.close}} />
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@ -0,0 +1,62 @@
import helloWorld from './default_jobs/hello-world';
import parameterized from './default_jobs/parameterized';
import serviceDiscovery from './default_jobs/service-discovery';
import variables from './default_jobs/variables';
export default [
{
id: 'nomad/job-templates/default/hello-world',
keyValues: [
{
key: 'template',
value: helloWorld,
},
{
key: 'description',
value: 'A simple job that runs a single task on a single node',
},
],
},
{
id: 'nomad/job-templates/default/parameterized-job',
keyValues: [
{
key: 'template',
value: parameterized,
},
{
key: 'description',
value:
'A job that can be dispatched multiple times with different payloads and meta values',
},
],
},
{
id: 'nomad/job-templates/default/service-discovery',
keyValues: [
{
key: 'template',
value: serviceDiscovery,
},
{
key: 'description',
value:
'Registers a service in one group, and discovers it in another. Provides a recurring check to ensure the service is healthy',
},
],
},
{
id: 'nomad/job-templates/default/variables',
keyValues: [
{
key: 'template',
value: variables,
},
{
key: 'description',
value:
'Use Nomad Variables to configure the output of a simple HTML page',
},
],
},
];

View File

@ -0,0 +1,61 @@
export default `job "hello-world" {
# Specifies the datacenters within which this job should be run.
# Leave as "dc1" for the default datacenter.
datacenters = ["dc1"]
meta {
# User-defined key/value pairs that can be used in your jobs.
# You can also use this meta block within Group and Task levels.
foo = "bar"
}
# A group defines a series of tasks that should be co-located
# on the same client (host). All tasks within a group will be
# placed on the same host.
group "servers" {
network {
port "www" {
to = 8001
}
}
service {
provider = "nomad"
port = "www"
}
# Tasks are individual units of work that are run by Nomad.
task "web" {
# This particular task starts a simple web server within a Docker container
driver = "docker"
config {
image = "busybox:1"
command = "httpd"
args = ["-v", "-f", "-p", "\${NOMAD_PORT_www}", "-h", "/local"]
ports = ["www"]
}
template {
data = <<EOF
<h1>Hello, Nomad!</h1>
<ul>
<li>Task: {{env "NOMAD_TASK_NAME"}}</li>
<li>Group: {{env "NOMAD_GROUP_NAME"}}</li>
<li>Job: {{env "NOMAD_JOB_NAME"}}</li>
<li>Metadata value for foo: {{env "NOMAD_META_foo"}}</li>
<li>Currently running on port: {{env "NOMAD_PORT_www"}}</li>
</ul>
EOF
destination = "local/index.html"
}
# Specify the maximum resources required to run the task
resources {
cpu = 50
memory = 64
}
}
}
}`;

View File

@ -0,0 +1,41 @@
export default `job "parameterized-job" {
datacenters = ["dc1"]
# Unlike service jobs, Batch jobs are intended to run until they exit successfully.
type = "batch"
# Run the job only on Linux or MacOS.
constraint {
attribute = "\${attr.kernel.name}"
operator = "set_contains_any"
value = "darwin,linux"
}
# Allow the job to be parameterized, and allow any meta key with
# a name starting with "i" to be specified.
parameterized {
meta_optional = ["MY_META_KEY"]
}
group "group" {
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "cat local/template.out", "local/payload.txt"]
}
dispatch_payload {
file = "payload.txt"
}
template {
data = <<EOH
MY_META_KEY: {{env "NOMAD_META_MY_META_KEY"}}
EOH
destination = "local/template.out"
}
}
}
}`;

View File

@ -0,0 +1,80 @@
export default `job "service-discovery-example" {
datacenters = ["dc1"]
group "client" {
task "curl" {
driver = "docker"
config {
image = "curlimages/curl:7.87.0"
command = "/bin/ash"
args = ["local/script.sh"]
}
template {
data = <<EOF
#!/usr/bin/env ash
while true; do
{{range nomadService "nomad-service-discovery-example-server"}}
curl -L -v http://{{.Address}}:{{.Port}}/
{{end}}
sleep 3
done
EOF
destination = "local/script.sh"
}
resources {
cpu = 10
memory = 50
}
}
}
group "server" {
network {
port "www" {
to = 8001
}
}
task "http" {
driver = "docker"
service {
name = "nomad-service-discovery-example-server"
provider = "nomad"
port = "www"
# If you're running Nomad in dev mode, uncomment the following address_mode line to allow this service to be discovered
# address_mode = "driver"
check {
type = "http"
path = "/"
interval = "5s"
timeout = "1s"
}
}
config {
image = "busybox:1"
command = "httpd"
args = ["-v", "-f", "-p", "\${NOMAD_PORT_www}", "-h", "/local"]
ports = ["www"]
}
template {
data = <<EOF
hello world
EOF
destination = "local/index.html"
}
resources {
cpu = 10
memory = 50
}
}
}
}`;

View File

@ -0,0 +1,48 @@
/* eslint-disable */
export default `# Use Nomad Variables to modify this job's output:
# run "nomad var put nomad/jobs/variables-example name=YOUR_NAME" to get started
job "variables-example" {
datacenters = ["dc1"]
group "web" {
network {
# Task group will have an isolated network namespace with
# an interface that is bridged with the host
port "www" {
to = 8001
}
}
service {
provider = "nomad"
port = "www"
}
task "http" {
driver = "docker"
config {
image = "busybox:1"
command = "httpd"
args = ["-v", "-f", "-p", "8001", "-h", "/local"]
ports = ["www"]
}
# Create a template resource that will be used to render the html file
# using the Nomad variable at "nomad/jobs/variables-example"
template {
data = "<html>hello, {{ with nomadVar \\" nomad/jobs/variables-example \\" }}{{ .name }}{{ end }}</html>"
destination = "local/index.html"
}
resources {
cpu = 128
memory = 128
}
}
}
}`;

View File

@ -31,6 +31,12 @@ module.exports = function (defaults) {
},
hinting: isTest,
tests: isTest,
sassOptions: {
precision: 4,
includePaths: [
'./node_modules/@hashicorp/design-system-tokens/dist/products/css',
],
},
});
// Use `app.import` to add additional libraries to the generated

View File

@ -891,7 +891,12 @@ export default function () {
//#region Variables
this.get('/vars', function (schema, { queryParams: { namespace } }) {
this.get('/vars', function (schema, { queryParams: { namespace, prefix } }) {
if (prefix === 'nomad/job-templates') {
return schema.variables
.all()
.filter((v) => v.path.includes('nomad/job-templates'));
}
if (namespace && namespace !== '*') {
return schema.variables.all().filter((v) => v.namespace === namespace);
} else {

View File

@ -1,3 +1,4 @@
import { assign } from '@ember/polyfills';
import config from 'nomad-ui/config/environment';
import * as topoScenarios from './topo';
import * as sysbatchScenarios from './sysbatch';
@ -104,6 +105,54 @@ function smallCluster(server) {
namespace: variableLinkedJob.namespace,
});
const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';
const jsonJob = (overrides) => {
return JSON.stringify(
assign(
{},
{
Name: newJobName,
Namespace: 'default',
Datacenters: ['dc1'],
Priority: 50,
TaskGroups: [
{
Name: newJobTaskGroupName,
Tasks: [
{
Name: 'redis',
Driver: 'docker',
},
],
},
],
},
overrides
),
null,
2
);
};
server.create('variable', {
id: `nomad/job-templates/foo-bar`,
namespace: 'namespace-2',
Items: {
description: 'a description',
template: jsonJob(),
},
});
server.create('variable', {
id: `nomad/job-templates/baz-qud`,
namespace: 'default',
Items: {
description: 'another different description',
template: jsonJob(),
},
});
server.create('variable', {
id: 'Auto-conflicting Variable',
namespace: 'default',

View File

@ -174,6 +174,7 @@
]
},
"dependencies": {
"@hashicorp/design-system-components": "^1.5.0",
"@hashicorp/ember-flight-icons": "^2.0.12",
"@percy/cli": "^1.6.1",
"@percy/ember": "^3.0.0",

View File

@ -1,11 +1,24 @@
import { currentURL } from '@ember/test-helpers';
import AdapterError from '@ember-data/adapter/error';
import {
click,
currentRouteName,
currentURL,
fillIn,
visit,
settled,
} from '@ember/test-helpers';
import { assign } from '@ember/polyfills';
import { module, test } from 'qunit';
import {
selectChoose,
clickTrigger,
} from 'ember-power-select/test-support/helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror';
import JobRun from 'nomad-ui/tests/pages/jobs/run';
import percySnapshot from '@percy/ember';
const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';
@ -143,4 +156,439 @@ module('Acceptance | job run', function (hooks) {
await JobRun.visit({ namespace: newNamespace });
assert.equal(currentURL(), `/jobs/run?namespace=${newNamespace}`);
});
module('job template flow', function () {
test('allows user with the correct permissions to fill in the editor using a job template', async function (assert) {
assert.expect(10);
// Arrange
await JobRun.visit();
assert
.dom('[data-test-choose-template]')
.exists('A button allowing a user to select a template appears.');
server.get('/vars', function (_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
prefix: 'nomad/job-templates',
namespace: '*',
},
'It makes a request to the /vars endpoint with the appropriate query parameters for job templates.'
);
return [
{
ID: 'nomad/job-templates/foo',
Namespace: 'default',
Path: 'nomad/job-templates/foo',
},
];
});
server.get(
'/var/nomad%2Fjob-templates%2Ffoo',
function (_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: 'default',
},
'Dispatches O(n+1) query to retrive items.'
);
return {
ID: 'nomad/job-templates/foo',
Namespace: 'default',
Path: 'nomad/job-templates/foo',
Items: {
template: 'Hello World!',
label: 'foo',
},
};
}
);
// Act
await click('[data-test-choose-template]');
assert.equal(currentRouteName(), 'jobs.run.templates.index');
// Assert
assert
.dom('[data-test-template-list]')
.exists('A list of available job templates is rendered.');
assert
.dom('[data-test-apply]')
.exists('A button to apply the selected templated is displayed.');
assert
.dom('[data-test-cancel]')
.exists('A button to cancel the template selection is displayed.');
await click('[data-test-template-card=Foo]');
await click('[data-test-apply]');
assert.equal(
currentURL(),
'/jobs/run?template=nomad%2Fjob-templates%2Ffoo%40default'
);
assert.dom('[data-test-editor]').containsText('Hello World!');
});
test('a user can create their own job template', async function (assert) {
assert.expect(7);
// Arrange
await JobRun.visit();
await click('[data-test-choose-template]');
// Assert
assert
.dom('[data-test-template-card]')
.exists({ count: 4 }, 'A list of default job templates is rendered.');
await click('[data-test-create-new-button]');
assert.equal(currentRouteName(), 'jobs.run.templates.new');
await fillIn('[data-test-template-name]', 'foo');
await fillIn('[data-test-template-description]', 'foo-bar-baz');
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
codeMirror.setValue(jsonJob());
server.put('/var/:varId', function (_server, fakeRequest) {
assert.deepEqual(
JSON.parse(fakeRequest.requestBody),
{
Path: 'nomad/job-templates/foo',
CreateIndex: null,
ModifyIndex: null,
Namespace: 'default',
ID: 'nomad/job-templates/foo',
Items: { description: 'foo-bar-baz', template: jsonJob() },
},
'It makes a PUT request to the /vars/:varId endpoint with the appropriate request body for job templates.'
);
return {
Items: { description: 'foo-bar-baz', template: jsonJob() },
Namespace: 'default',
Path: 'nomad/job-templates/foo',
};
});
server.get('/vars', function (_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
prefix: 'nomad/job-templates',
namespace: '*',
},
'It makes a request to the /vars endpoint with the appropriate query parameters for job templates.'
);
return [
{
ID: 'nomad/job-templates/foo',
Namespace: 'default',
Path: 'nomad/job-templates/foo',
},
];
});
server.get(
'/var/nomad%2Fjob-templates%2Ffoo',
function (_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: 'default',
},
'Dispatches O(n+1) query to retrive items.'
);
return {
ID: 'nomad/job-templates/foo',
Namespace: 'default',
Path: 'nomad/job-templates/foo',
Items: {
template: 'qud',
label: 'foo',
},
};
}
);
await click('[data-test-save-template]');
assert.equal(currentRouteName(), 'jobs.run.templates.index');
assert
.dom('[data-test-template-card=Foo]')
.exists('The newly created template appears in the list.');
});
test('a toast notification alerts the user if there is an error saving the newly created job template', async function (assert) {
assert.expect(5);
// Arrange
await JobRun.visit();
await click('[data-test-choose-template]');
// Assert
assert
.dom('[data-test-template-card]')
.exists({ count: 4 }, 'A list of default job templates is rendered.');
await click('[data-test-create-new-button]');
assert.equal(currentRouteName(), 'jobs.run.templates.new');
assert
.dom('[data-test-save-template]')
.isDisabled('the save button should be disabled if no path is set');
await fillIn('[data-test-template-name]', 'try@');
await fillIn('[data-test-template-description]', 'foo-bar-baz');
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
codeMirror.setValue(jsonJob());
server.put('/var/:varId?cas=0', function () {
return new AdapterError({
detail: `invalid path "nomad/job-templates/try@"`,
status: 500,
});
});
await click('[data-test-save-template]');
assert.equal(
currentRouteName(),
'jobs.run.templates.new',
'We do not navigate away from the page if an error is returned by the API.'
);
assert
.dom('.flash-message.alert-error')
.exists('A toast error message pops up.');
});
test('a user cannot create a job template if one with the same name and namespace already exists', async function (assert) {
assert.expect(4);
// Arrange
await JobRun.visit();
await click('[data-test-choose-template]');
server.create('variable', {
path: 'nomad/job-templates/foo',
namespace: 'default',
id: 'nomad/job-templates/foo',
});
server.create('namespace', { id: 'test' });
this.system = this.owner.lookup('service:system');
this.system.shouldShowNamespaces = true;
// Assert
assert
.dom('[data-test-template-card]')
.exists({ count: 4 }, 'A list of default job templates is rendered.');
await click('[data-test-create-new-button]');
assert.equal(currentRouteName(), 'jobs.run.templates.new');
await fillIn('[data-test-template-name]', 'foo');
assert
.dom('[data-test-duplicate-error]')
.exists('an error message alerts the user');
await clickTrigger('[data-test-namespace-facet]');
await selectChoose('[data-test-namespace-facet]', 'test');
assert
.dom('[data-test-duplicate-error]')
.doesNotExist(
'an error disappears when name or namespace combination is unique'
);
// Clean-up
this.system.shouldShowNamespaces = false;
});
test('a user can save code from the editor as a template', async function (assert) {
assert.expect(4);
// Arrange
await JobRun.visit();
await JobRun.editor.editor.fillIn(jsonJob());
await click('[data-test-save-as-template]');
assert.equal(
currentRouteName(),
'jobs.run.templates.new',
'We navigate template creation page.'
);
// Assert
assert
.dom('[data-test-template-name]')
.hasNoText('No template name is prefilled.');
assert
.dom('[data-test-template-description]')
.hasNoText('No template description is prefilled.');
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
const json = codeMirror.getValue();
assert.equal(
json,
jsonJob(),
'Template is filled out with text from the editor.'
);
});
test('a user can edit a template', async function (assert) {
assert.expect(5);
// Arrange
server.create('variable', {
path: 'nomad/job-templates/foo',
namespace: 'default',
id: 'nomad/job-templates/foo',
Items: {},
});
await visit('/jobs/run/templates/manage');
assert.equal(currentRouteName(), 'jobs.run.templates.manage');
assert
.dom('[data-test-template-list]')
.exists('A list of templates is visible');
await percySnapshot(assert);
await click('[data-test-edit-template="nomad/job-templates/foo"]');
assert.equal(
currentRouteName(),
'jobs.run.templates.template',
'Navigates to edit template view'
);
server.put('/var/:varId', function (_server, fakeRequest) {
assert.deepEqual(
JSON.parse(fakeRequest.requestBody),
{
Path: 'nomad/job-templates/foo',
CreateIndex: null,
ModifyIndex: null,
Namespace: 'default',
ID: 'nomad/job-templates/foo',
Items: { description: 'baz qud thud' },
},
'It makes a PUT request to the /vars/:varId endpoint with the appropriate request body for job templates.'
);
return {
Items: { description: 'baz qud thud' },
Namespace: 'default',
Path: 'nomad/job-templates/foo',
};
});
await fillIn('[data-test-template-description]', 'baz qud thud');
await click('[data-test-edit-template]');
assert.equal(
currentRouteName(),
'jobs.run.templates.index',
'We navigate back to the templates view.'
);
});
test('a user can delete a template', async function (assert) {
assert.expect(5);
// Arrange
server.create('variable', {
path: 'nomad/job-templates/foo',
namespace: 'default',
id: 'nomad/job-templates/foo',
Items: {},
});
server.create('variable', {
path: 'nomad/job-templates/bar',
namespace: 'default',
id: 'nomad/job-templates/bar',
Items: {},
});
server.create('variable', {
path: 'nomad/job-templates/baz',
namespace: 'default',
id: 'nomad/job-templates/baz',
Items: {},
});
await visit('/jobs/run/templates/manage');
assert.equal(currentRouteName(), 'jobs.run.templates.manage');
assert
.dom('[data-test-template-list]')
.exists('A list of templates is visible');
await click('[data-test-idle-button]');
await click('[data-test-confirm-button]');
assert
.dom('[data-test-edit-template="nomad/job-templates/foo"]')
.doesNotExist('The template is removed from the list.');
await click('[data-test-edit-template="nomad/job-templates/bar"]');
await click('[data-test-idle-button]');
await click('[data-test-confirm-button]');
assert.equal(
currentRouteName(),
'jobs.run.templates.manage',
'We navigate back to the templates manager view.'
);
assert
.dom('[data-test-edit-template="nomad/job-templates/bar"]')
.doesNotExist('The template is removed from the list.');
});
test('a user sees accurate template information', async function (assert) {
assert.expect(3);
// Arrange
server.create('variable', {
path: 'nomad/job-templates/foo',
namespace: 'default',
id: 'nomad/job-templates/foo',
Items: {
template: 'qud',
label: 'foo',
description: 'bar baz',
},
});
await visit('/jobs/run/templates');
assert.equal(currentRouteName(), 'jobs.run.templates.index');
assert.dom('[data-test-template-card="Foo"]').exists();
this.store = this.owner.lookup('service:store');
this.store.unloadAll();
await settled();
assert
.dom('[data-test-template-card="Foo"]')
.doesNotExist(
'The template reactively updates to changes in the Ember Data Store.'
);
});
test('default templates', async function (assert) {
assert.expect(4);
const NUMBER_OF_DEFAULT_TEMPLATES = 4;
await visit('/jobs/run/templates');
assert.equal(currentRouteName(), 'jobs.run.templates.index');
assert
.dom('[data-test-template-card]')
.exists({ count: NUMBER_OF_DEFAULT_TEMPLATES });
await percySnapshot(assert);
await click('[data-test-template-card="Hello world"]');
await click('[data-test-apply]');
assert.equal(
currentURL(),
'/jobs/run?template=nomad%2Fjob-templates%2Fdefault%2Fhello-world'
);
assert.dom('[data-test-editor]').includesText('job "hello-world"');
});
});
});

View File

@ -509,6 +509,59 @@ module('Acceptance | variables', function (hooks) {
'Cannot submit a variable that begins with nomad/<not-jobs>/'
);
document.querySelector('[data-test-path-input]').value = ''; // clear current input
await typeIn('[data-test-path-input]', 'nomad/jobs/');
assert
.dom('[data-test-submit-var]')
.isNotDisabled('Can submit a variable that begins with nomad/jobs/');
document.querySelector('[data-test-path-input]').value = ''; // clear current input
await typeIn('[data-test-path-input]', 'nomad/another-foo/');
assert
.dom('[data-test-submit-var]')
.isDisabled('Disabled state re-evaluated when path input changes');
document.querySelector('[data-test-path-input]').value = ''; // clear current input
await typeIn('[data-test-path-input]', 'nomad/jobs/job-templates/');
assert
.dom('[data-test-submit-var]')
.isNotDisabled(
'Can submit a variable that begins with nomad/job-templates/'
);
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
test('shows a custom editor when editing a job template variable', async function (assert) {
// Arrange Test Set-up
allScenarios.variableTestCluster(server);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
await Variables.visitNew();
// End Test Set-up
assert
.dom('.related-entities-hint')
.exists('Shows a hint about related entities by default');
assert.dom('.CodeMirror').doesNotExist();
await typeIn('[data-test-path-input]', 'nomad/job-templates/hello-world');
assert
.dom('.related-entities-hint')
.doesNotExist('Hides the hint when editing a job template variable');
assert
.dom('.job-template-hint')
.exists('Shows a hint about job templates');
assert
.dom('.CodeMirror')
.exists('Shows a custom editor for job templates');
document.querySelector('[data-test-path-input]').value = ''; // clear current input
await typeIn('[data-test-path-input]', 'hello-world-non-template');
assert
.dom('.related-entities-hint')
.exists('Shows a hint about related entities by default');
assert.dom('.CodeMirror').doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});

View File

@ -2568,6 +2568,15 @@
"@embroider/shared-internals" "^1.5.0"
semver "^7.3.5"
"@embroider/addon-shim@^1.5.0", "@embroider/addon-shim@^1.8.4":
version "1.8.4"
resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.8.4.tgz#0e7f32c5506bf0f3eb0840506e31c36c7053763c"
integrity sha512-sFhfWC0vI18KxVenmswQ/ShIvBg4juL8ubI+Q3NTSdkCTeaPQ/DIOUF6oR5DCQ8eO/TkIaw+kdG3FkTY6yNJqA==
dependencies:
"@embroider/shared-internals" "^2.0.0"
broccoli-funnel "^3.0.8"
semver "^7.3.8"
"@embroider/core@0.36.0":
version "0.36.0"
resolved "https://registry.yarnpkg.com/@embroider/core/-/core-0.36.0.tgz#fbbd60d29c3fcbe02b4e3e63e6043a43de2b9ce3"
@ -2765,6 +2774,20 @@
semver "^7.3.5"
typescript-memoize "^1.0.1"
"@embroider/shared-internals@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@embroider/shared-internals/-/shared-internals-2.0.0.tgz#d8205ec6944362aeecfbb51143db352430ced316"
integrity sha512-qZ2/xky9mWm5YC6noOa6AiAwgISEQ78YTZNv4SNu2PFgEK/H+Ha/3ddngzGSsnXkVnIHZyxIBzhxETonQYHY9g==
dependencies:
babel-import-util "^1.1.0"
ember-rfc176-data "^0.3.17"
fs-extra "^9.1.0"
js-string-escape "^1.0.1"
lodash "^4.17.21"
resolve-package-path "^4.0.1"
semver "^7.3.5"
typescript-memoize "^1.0.1"
"@embroider/util@^0.39.1 || ^0.40.0 || ^0.41.0":
version "0.41.0"
resolved "https://registry.yarnpkg.com/@embroider/util/-/util-0.41.0.tgz#5324cb4742aa4ed8d613c4f88a466f73e4e6acc1"
@ -3095,6 +3118,32 @@
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf"
integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A==
"@hashicorp/design-system-components@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-1.5.0.tgz#f9a61b4067d474883c08249cd2548d68c213510e"
integrity sha512-aSAnJeK6ecBZGiEK8h7TXuDnD4xGJawpCkAQ/18V6yQKnjGnlTckiynSeF5kVdHHc1PenK+O9RXcrdiIuDw2XQ==
dependencies:
"@hashicorp/design-system-tokens" "^1.3.0"
"@hashicorp/ember-flight-icons" "^3.0.2"
dialog-polyfill "^0.5.6"
ember-auto-import "^2.4.2"
ember-cached-decorator-polyfill "^0.1.4"
ember-cli-babel "^7.26.11"
ember-cli-htmlbars "^6.1.0"
ember-cli-sass "^10.0.1"
ember-composable-helpers "^4.4.1"
ember-focus-trap "^1.0.1"
ember-keyboard "^8.1.0"
ember-named-blocks-polyfill "^0.2.5"
ember-style-modifier "^0.8.0"
ember-truth-helpers "^3.0.0"
sass "^1.43.4"
"@hashicorp/design-system-tokens@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.3.0.tgz#0a143f7fb20b72ac003e56c7408e23b0b55d45f9"
integrity sha512-Re16f4tqyAApChk20Eev0xVWijCo8m3YYIo4A84tStTTkXk4d63lbY1MuebWaaAjcjzKGuyPh0w3u0SJWJFCUg==
"@hashicorp/ember-flight-icons@^2.0.12":
version "2.0.12"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-2.0.12.tgz#788adf7a4fedc468d612d35b604255df948f4012"
@ -3104,11 +3153,26 @@
ember-cli-babel "^7.26.11"
ember-cli-htmlbars "^6.0.1"
"@hashicorp/ember-flight-icons@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.0.2.tgz#8d884c07842a6c88df18ca680d7883a59222a2ba"
integrity sha512-WomQg1hw/IHA1N9hC77WbTNazVXqu2RdRoaVCGT99NTXQ4S7Bw7vhHheR4JAgt10ksMZFI3X/bJVHxFfjUCkSQ==
dependencies:
"@hashicorp/flight-icons" "^2.12.0"
ember-auto-import "^2.4.2"
ember-cli-babel "^7.26.11"
ember-cli-htmlbars "^6.1.0"
"@hashicorp/flight-icons@^2.10.0":
version "2.10.0"
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.10.0.tgz#24b03043bacda16e505200e6591dfef896ddacf1"
integrity sha512-jYUA0M6Tz+4RAudil+GW/fHbhZPcKCiIZZAguBDviqbLneMkMgPOBgbXWCGWsEQ1fJzP2cXbUaio8L0aQZPWQw==
"@hashicorp/flight-icons@^2.12.0":
version "2.12.0"
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.12.0.tgz#48bc21f21678668ffe9147b181a2991d8b151fc7"
integrity sha512-PhjTTHCjoq4EJirifbxLxnxXnCRf1NUAYZ1WnFW8i0yOmmax6fgjsJRPlf0VIGsR8R7isFpjuy6gJ5c7mNhE0w==
"@hashicorp/structure-icons@^1.3.0":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c"
@ -8965,6 +9029,11 @@ detect-port@^1.3.0:
address "^1.0.1"
debug "^2.6.0"
dialog-polyfill@^0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a"
integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==
diff@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
@ -9255,7 +9324,7 @@ ember-assign-helper@^0.3.0:
ember-cli-babel "^7.19.0"
ember-cli-htmlbars "^4.3.1"
ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0:
ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2:
version "2.4.0"
resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.4.0.tgz#91c4797f08315728086e35af954cb60bd23c14bc"
integrity sha512-BwF6iTaoSmT2vJ9NEHEGRBCh2+qp+Nlaz/Q7roqNSxl5oL5iMRwenPnHhOoBPTYZvPhcV/KgXR5e+pBQ107plQ==
@ -9309,6 +9378,26 @@ ember-basic-dropdown@^3.0.21:
ember-style-modifier "^0.7.0"
ember-truth-helpers "^2.1.0 || ^3.0.0"
ember-cache-primitive-polyfill@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ember-cache-primitive-polyfill/-/ember-cache-primitive-polyfill-1.0.1.tgz#a27075443bd87e5af286c1cd8a7df24e3b9f6715"
integrity sha512-hSPcvIKarA8wad2/b6jDd/eU+OtKmi6uP+iYQbzi5TQpjsqV6b4QdRqrLk7ClSRRKBAtdTuutx+m+X+WlEd2lw==
dependencies:
ember-cli-babel "^7.22.1"
ember-cli-version-checker "^5.1.1"
ember-compatibility-helpers "^1.2.1"
silent-error "^1.1.1"
ember-cached-decorator-polyfill@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/ember-cached-decorator-polyfill/-/ember-cached-decorator-polyfill-0.1.4.tgz#f1e2c65cc78d0d9c4ac0e047e643af477eb85ace"
integrity sha512-JOK7kBCWsTVCzmCefK4nr9BACDJk0owt9oIUaVt6Q0UtQ4XeAHmoK5kQ/YtDcxQF1ZevHQFdGhsTR3JLaHNJgA==
dependencies:
"@glimmer/tracking" "^1.0.4"
ember-cache-primitive-polyfill "^1.0.1"
ember-cli-babel "^7.21.0"
ember-cli-babel-plugin-helpers "^1.1.1"
ember-can@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-4.1.0.tgz#af3bb7838eab381e56977772dbb75bf5e16e77a9"
@ -9571,6 +9660,26 @@ ember-cli-htmlbars@^6.0.0, ember-cli-htmlbars@^6.0.1:
strip-bom "^4.0.0"
walk-sync "^2.2.0"
ember-cli-htmlbars@^6.1.0:
version "6.1.1"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-6.1.1.tgz#f5b588572a5d18ad087560122b8dabc90145173d"
integrity sha512-DKf2rjzIVw9zWCuFsBGJScrgf5Mz7dSg08Cq+FWFYIxnpssINUbNUoB0NHWnUJK4QqCvaExOyOmjm0kO455CPg==
dependencies:
"@ember/edition-utils" "^1.2.0"
babel-plugin-ember-template-compilation "^1.0.0"
babel-plugin-htmlbars-inline-precompile "^5.3.0"
broccoli-debug "^0.6.5"
broccoli-persistent-filter "^3.1.2"
broccoli-plugin "^4.0.3"
ember-cli-version-checker "^5.1.2"
fs-tree-diff "^2.0.1"
hash-for-dep "^1.5.1"
heimdalljs-logger "^0.1.10"
js-string-escape "^1.0.1"
semver "^7.3.4"
silent-error "^1.1.1"
walk-sync "^2.2.0"
ember-cli-import-polyfill@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ember-cli-import-polyfill/-/ember-cli-import-polyfill-0.2.0.tgz#c1a08a8affb45c97b675926272fe78cf4ca166f2"
@ -9664,7 +9773,7 @@ ember-cli-preprocess-registry@^3.3.0:
debug "^3.0.1"
process-relative-require "^1.0.0"
ember-cli-sass@^10.0.0:
ember-cli-sass@^10.0.0, ember-cli-sass@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/ember-cli-sass/-/ember-cli-sass-10.0.1.tgz#afa91eb7dfe3890be0390639d66976512e7d8edc"
integrity sha512-dWVoX03O2Mot1dEB1AN3ofC8DDZb6iU4Kfkbr3WYi9S9bGVHrpR/ngsR7tuVBuTugTyG53FPtLLqYdqx7XjXdA==
@ -9964,6 +10073,16 @@ ember-compatibility-helpers@^1.2.0, ember-compatibility-helpers@^1.2.1:
ember-cli-version-checker "^5.1.1"
semver "^5.4.1"
ember-composable-helpers@^4.4.1:
version "4.5.0"
resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-4.5.0.tgz#94febbdf4348e64f45f7a6f993f326e32540a61e"
integrity sha512-XjpDLyVPsLCy6kd5dIxZonOECCO6AA5sY5Hr6tYUbJg3s5ghFAiFWaNcYraYC+fL2yPJQAswwpfwGlQORUJZkw==
dependencies:
"@babel/core" "^7.0.0"
broccoli-funnel "2.0.1"
ember-cli-babel "^7.26.3"
resolve "^1.10.0"
ember-composable-helpers@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-5.0.0.tgz#055bab3a3e234ab2917499b1465e968c253ca885"
@ -10120,6 +10239,14 @@ ember-fetch@^8.1.1:
node-fetch "^2.6.1"
whatwg-fetch "^3.6.2"
ember-focus-trap@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.0.1.tgz#a99565f6ce55d500b92a0965e79e3ad04219f157"
integrity sha512-ZUyq5ZkIuXp+ng9rCMkqBh36/V95PltL7iljStkma4+651xlAy3Z84L9WOu/uOJyVpNUxii8RJBbAySHV6c+RQ==
dependencies:
"@embroider/addon-shim" "^1.0.0"
focus-trap "^6.7.1"
ember-get-config@, "ember-get-config@^0.2.4 || ^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/ember-get-config/-/ember-get-config-0.3.0.tgz#a73a1a87b48d9dde4c66a0e52ed5260b8a48cfbd"
@ -10169,6 +10296,16 @@ ember-inline-svg@^1.0.1:
svgo "~1.2.2"
walk-sync "~2.0.2"
ember-keyboard@^8.1.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.0.tgz#d11fa7f0443606b7c1850bbd8253274a00046e11"
integrity sha512-h2kuS2irtIyvNbAMkGDlDTB4TPXwgmC6Nu9bIuGWoCjkGdgJbUg0VegfyRJ1TlxbIHlAelbqVpE8UhfgY5wEag==
dependencies:
"@embroider/addon-shim" "^1.5.0"
ember-destroyable-polyfill "^2.0.3"
ember-modifier "^2.1.2 || ^3.1.0 || ^4.0.0"
ember-modifier-manager-polyfill "^1.2.0"
ember-load-initializers@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ember-load-initializers/-/ember-load-initializers-2.1.2.tgz#8a47a656c1f64f9b10cecdb4e22a9d52ad9c7efa"
@ -10206,7 +10343,7 @@ ember-modifier-manager-polyfill@^1.2.0:
ember-cli-version-checker "^2.1.2"
ember-compatibility-helpers "^1.2.0"
ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0:
ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-modifier@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b"
integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==
@ -10217,6 +10354,15 @@ ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0:
ember-cli-typescript "^5.0.0"
ember-compatibility-helpers "^1.2.5"
"ember-modifier@^2.1.2 || ^3.1.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-4.0.0.tgz#0bb3fae11435fcbe0d3dfa852ce224d81d75ddb2"
integrity sha512-OdconmrqKP2haK4kBwNmtnA2NiC2MFmIJC3LgJ1WhwZ49GaktM+bRIuFxF/S5W0oaegzKs1qH2ZDlqMeO2L3nw==
dependencies:
"@embroider/addon-shim" "^1.8.4"
ember-cli-normalize-entity-name "^1.0.0"
ember-cli-string-utils "^1.1.0"
ember-moment@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/ember-moment/-/ember-moment-9.0.1.tgz#fcf06cb8ef07c8d0108820c1639778590d613b38"
@ -10236,6 +10382,14 @@ ember-named-blocks-polyfill@^0.2.4:
ember-cli-babel "^7.19.0"
ember-cli-version-checker "^5.1.1"
ember-named-blocks-polyfill@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/ember-named-blocks-polyfill/-/ember-named-blocks-polyfill-0.2.5.tgz#d5841406277026a221f479c815cfbac6cdcaeecb"
integrity sha512-OVMxzkfqJrEvmiky7gFzmuTaImCGm7DOudHWTdMBPO7E+dQSunrcRsJMgO9ZZ56suqBIz/yXbEURrmGS+avHxA==
dependencies:
ember-cli-babel "^7.19.0"
ember-cli-version-checker "^5.1.1"
ember-on-resize-modifier@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ember-on-resize-modifier/-/ember-on-resize-modifier-1.0.0.tgz#b4e12dc023b4d608d7b0f4fa0100722fb860cdd4"
@ -10436,6 +10590,14 @@ ember-style-modifier@^0.7.0:
ember-cli-babel "^7.26.6"
ember-modifier "^3.0.0"
ember-style-modifier@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-0.8.0.tgz#ef46b3f288e63e3d850418ea8dc6f7b12edde721"
integrity sha512-I7M+oZ+poYYOP7n521rYv7kkYZbxotL8VbtHYxLQ3tasRZYQJ21qfu3vVjydSjwyE3w7EZRgKngBoMhKSAEZnw==
dependencies:
ember-cli-babel "^7.26.6"
ember-modifier "^3.2.7"
ember-template-lint@^3.15.0:
version "3.16.0"
resolved "https://registry.yarnpkg.com/ember-template-lint/-/ember-template-lint-3.16.0.tgz#7af2ec8d4386f4726be08c14c39ba121c56f0896"
@ -11647,6 +11809,13 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
focus-trap@^6.7.1:
version "6.9.4"
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.9.4.tgz#436da1a1d935c48b97da63cd8f361c6f3aa16444"
integrity sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw==
dependencies:
tabbable "^5.3.3"
follow-redirects@^1.0.0:
version "1.14.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
@ -12893,6 +13062,11 @@ immer@8.0.1:
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
immutable@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -17469,6 +17643,15 @@ sass@^1.17.3:
dependencies:
chokidar ">=3.0.0 <4.0.0"
sass@^1.43.4:
version "1.57.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.57.0.tgz#64c4144ed4e1c0ccb96dc18aef2c424cdbc0c12b"
integrity sha512-IZNEJDTK1cF5B1cGA593TPAV/1S0ysUDxq9XHjX/+SMy0QfUny+nfUsq5ZP7wWSl4eEf7wDJcEZ8ABYFmh3m/w==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
sax@~1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@ -17579,6 +17762,13 @@ semver@^7.2.1, semver@^7.3.2:
dependencies:
lru-cache "^6.0.0"
semver@^7.3.8:
version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
dependencies:
lru-cache "^6.0.0"
send@0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
@ -17898,7 +18088,7 @@ source-list-map@^2.0.0:
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
source-map-js@^1.0.1, source-map-js@^1.0.2:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
@ -18524,6 +18714,11 @@ sync-disk-cache@^2.0.0:
rimraf "^3.0.0"
username-sync "^1.0.2"
tabbable@^5.3.3:
version "5.3.3"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
table@^6.0.9:
version "6.7.5"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.5.tgz#f04478c351ef3d8c7904f0e8be90a1b62417d238"

View File

@ -29,6 +29,8 @@ template.
- `-short`: If set, a minimal jobspec without comments is emitted.
- `-connect`: If set, the jobspec includes Consul Connect integration.
- `-template=<template>`: Specifies a predefined template to emit. Must be a Nomad Variable that lives at `nomad/job-templates/<template>` These are commonly created via the UI, and accessible with the -list-templates flag.
- `-list-templates`: Display a list of possible job templates to pass to -template. Reads from all variables pathed at `nomad/job-templates/<template>`.
## Examples