diff --git a/.changelog/17964.txt b/.changelog/17964.txt
new file mode 100644
index 000000000..15705fac2
--- /dev/null
+++ b/.changelog/17964.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: adds a new Variables page to all job pages
+```
diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js
index d0a181ea1..f5db29f61 100644
--- a/ui/app/abilities/job.js
+++ b/ui/app/abilities/job.js
@@ -19,6 +19,14 @@ export default class Job extends AbstractAbility {
)
canScale;
+ @or(
+ 'bypassAuthorization',
+ 'selfTokenIsManagement',
+ 'specificNamespaceSupportsReading',
+ 'policiesSupportReading'
+ )
+ canRead;
+
// TODO: A person can also see all jobs if their token grants read access to all namespaces,
// but given the complexity of namespaces and policy precedence, there isn't a good quick way
// to confirm this.
@@ -59,11 +67,24 @@ export default class Job extends AbstractAbility {
);
}
+ @computed('token.selfTokenPolicies.[]')
+ get policiesSupportReading() {
+ return this.policyNamespacesIncludePermissions(
+ this.token.selfTokenPolicies,
+ ['read-job']
+ );
+ }
+
@computed('rulesForNamespace.@each.capabilities')
get specificNamespaceSupportsRunning() {
return this.namespaceIncludesCapability('submit-job');
}
+ @computed('rulesForNamespace.@each.capabilities')
+ get specificNamespaceSupportsReading() {
+ return this.namespaceIncludesCapability('read-job');
+ }
+
@computed('rulesForNamespace.@each.capabilities')
get policiesSupportScaling() {
return this.namespaceIncludesCapability('scale-job');
diff --git a/ui/app/components/editable-variable-link.hbs b/ui/app/components/editable-variable-link.hbs
new file mode 100644
index 000000000..3a6938040
--- /dev/null
+++ b/ui/app/components/editable-variable-link.hbs
@@ -0,0 +1,17 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+~}}
+
+{{!-- Either link to a new variable with a pre-filled path, or the existing variable in edit mode, depending if it exists --}}
+{{#if (can "write variable")}}
+ {{#with (editable-variable-link @path existingPaths=@existingPaths namespace=@namespace) as |link|}}
+ {{#if link.model}}
+ {{@path}}
+ {{else}}
+ {{@path}}
+ {{/if}}
+ {{/with}}
+{{else}}
+ @path
+{{/if}}
diff --git a/ui/app/components/variable-form.hbs b/ui/app/components/variable-form.hbs
index d8ca1666b..8b991d751 100644
--- a/ui/app/components/variable-form.hbs
+++ b/ui/app/components/variable-form.hbs
@@ -22,7 +22,7 @@
@job={{@model.pathLinkedEntities.job}}
@group={{@model.pathLinkedEntities.group}}
@task={{@model.pathLinkedEntities.task}}
- @namespace={{this.variableNamespace}}
+ @namespace={{or this.variableNamespace "default"}}
/>
{{/if}}
@@ -73,7 +73,7 @@
Please choose a different path, or
edit the existing variable
diff --git a/ui/app/components/variable-form.js b/ui/app/components/variable-form.js
index 889f8ec6c..e2b1912c3 100644
--- a/ui/app/components/variable-form.js
+++ b/ui/app/components/variable-form.js
@@ -32,6 +32,7 @@ export default class VariableFormComponent extends Component {
@service notifications;
@service router;
@service store;
+ @service can;
@tracked variableNamespace = null;
@tracked namespaceOptions = null;
@@ -255,6 +256,15 @@ export default class VariableFormComponent extends Component {
message: `${this.path} successfully saved`,
color: 'success',
});
+
+ if (
+ this.can.can('read job', null, {
+ namespace: this.variableNamespace || 'default',
+ })
+ ) {
+ this.updateJobVariables(this.args.model.pathLinkedEntities.job);
+ }
+
this.removeExitHandler();
this.router.transitionTo('variables.variable', this.args.model.id);
} catch (error) {
@@ -275,6 +285,26 @@ export default class VariableFormComponent extends Component {
}
}
+ /**
+ * A job, its task groups, and tasks, all have a getter called pathLinkedVariable.
+ * These are dependent on a variables list that may already be established. If a variable
+ * is added or removed, this function will update job.variables[] list to reflect the change.
+ * and force an update to the job's pathLinkedVariable getter.
+ */
+ async updateJobVariables(jobName) {
+ if (!jobName) {
+ return;
+ }
+ const fullJobId = JSON.stringify([
+ jobName,
+ this.variableNamespace || 'default',
+ ]);
+ let job = await this.store.findRecord('job', fullJobId, { reload: true });
+ if (job) {
+ job.variables.pushObject(this.args.model);
+ }
+ }
+
//#region JSON Editing
view = this.args.view;
diff --git a/ui/app/components/variable-paths.hbs b/ui/app/components/variable-paths.hbs
index d08127621..5a51ff25a 100644
--- a/ui/app/components/variable-paths.hbs
+++ b/ui/app/components/variable-paths.hbs
@@ -36,7 +36,7 @@
{{#each this.files as |file|}}
} */
+ @alias('model.variables') variables;
+
+ get firstFewTaskGroupNames() {
+ return this.job.taskGroups.slice(0, 2).mapBy('name');
+ }
+
+ get firstFewTaskNames() {
+ return this.job.taskGroups
+ .map((tg) => tg.tasks.map((task) => `${tg.name}/${task.name}`))
+ .flat()
+ .slice(0, 2);
+ }
+
+ /**
+ * Structures the flattened variables in a "path tree" like we use in the main variables routes
+ * @returns {import("../../../utils/path-tree").VariableFolder}
+ */
+ get jobRelevantVariables() {
+ /**
+ * @type {import("../../../utils/path-tree").VariableFile[]}
+ */
+ let variableFiles = this.variables.map((v) => {
+ return {
+ name: v.path,
+ path: v.path,
+ absoluteFilePath: v.path,
+ variable: v,
+ };
+ });
+
+ return {
+ files: variableFiles,
+ children: {},
+ absolutePath: '',
+ };
+ }
+}
diff --git a/ui/app/helpers/editable-variable-link.js b/ui/app/helpers/editable-variable-link.js
new file mode 100644
index 000000000..6662b8856
--- /dev/null
+++ b/ui/app/helpers/editable-variable-link.js
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+// @ts-check
+// eslint-disable-next-line no-unused-vars
+import VariableModel from '../models/variable';
+// eslint-disable-next-line no-unused-vars
+import MutableArray from '@ember/array/mutable';
+
+/**
+ * @typedef LinkToParams
+ * @property {string} route
+ * @property {string} model
+ * @property {Object} query
+ */
+
+import Helper from '@ember/component/helper';
+
+/**
+ * Either generates a link to edit an existing variable, or else create a new one with a pre-filled path, depending on whether a variable with the given path already exists.
+ * Returns an object with route, model, and query; all strings.
+ * @param {Array} positional
+ * @param {{ existingPaths: MutableArray, namespace: string }} named
+ * @returns {LinkToParams}
+ */
+export function editableVariableLink(
+ [path],
+ { existingPaths, namespace = 'default' }
+) {
+ if (existingPaths.findBy('path', path)) {
+ return {
+ route: 'variables.variable.edit',
+ model: `${path}@${namespace}`,
+ query: {},
+ };
+ } else {
+ return {
+ route: 'variables.new',
+ model: '',
+ query: { path },
+ };
+ }
+}
+
+export default Helper.helper(editableVariableLink);
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index 18c8f8541..cc5628fd4 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -355,7 +355,7 @@ export default class Job extends Model {
// that will be submitted to the create job endpoint, another prop is necessary.
@attr('string') _newDefinitionJSON;
- @computed('variables', 'parent', 'plainId')
+ @computed('variables.[]', 'parent', 'plainId')
get pathLinkedVariable() {
if (this.parent.get('id')) {
return this.variables?.findBy(
@@ -366,4 +366,17 @@ export default class Job extends Model {
return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`);
}
}
+
+ // TODO: This async fetcher seems like a better fit for most of our use-cases than the above getter (which cannot do async/await)
+ async getPathLinkedVariable() {
+ await this.variables;
+ if (this.parent.get('id')) {
+ return this.variables?.findBy(
+ 'path',
+ `nomad/jobs/${JSON.parse(this.parent.get('id'))[0]}`
+ );
+ } else {
+ return this.variables?.findBy('path', `nomad/jobs/${this.plainId}`);
+ }
+ }
}
diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js
index 618062da2..d775c388a 100644
--- a/ui/app/models/task-group.js
+++ b/ui/app/models/task-group.js
@@ -28,7 +28,7 @@ export default class TaskGroup extends Fragment {
if (this.job.parent.get('id')) {
return this.job.variables?.findBy(
'path',
- `nomad/jobs/${JSON.parse(this.job.parent.get('id'))[0]}/${this.name}`
+ `nomad/jobs/${this.job.parent.get('plainId')}/${this.name}`
);
} else {
return this.job.variables?.findBy(
@@ -38,6 +38,22 @@ export default class TaskGroup extends Fragment {
}
}
+ // TODO: This async fetcher seems like a better fit for most of our use-cases than the above getter (which cannot do async/await)
+ async getPathLinkedVariable() {
+ await this.job.variables;
+ if (this.job.parent.get('id')) {
+ return await this.job.variables?.findBy(
+ 'path',
+ `nomad/jobs/${this.job.parent.get('plainId')}/${this.name}`
+ );
+ } else {
+ return await this.job.variables?.findBy(
+ 'path',
+ `nomad/jobs/${this.job.plainId}/${this.name}`
+ );
+ }
+ }
+
@fragmentArray('task') tasks;
@fragmentArray('service-fragment') services;
diff --git a/ui/app/models/task.js b/ui/app/models/task.js
index aada4ef43..0796cad30 100644
--- a/ui/app/models/task.js
+++ b/ui/app/models/task.js
@@ -58,9 +58,12 @@ export default class Task extends Fragment {
@fragmentArray('volume-mount', { defaultValue: () => [] }) volumeMounts;
async _fetchParentJob() {
- let job = await this.store.findRecord('job', this.taskGroup.job.id, {
- reload: true,
- });
+ let job = this.store.peekRecord('job', this.taskGroup.job.id);
+ if (!job) {
+ job = await this.store.findRecord('job', this.taskGroup.job.id, {
+ reload: true,
+ });
+ }
this._job = job;
}
@@ -79,4 +82,24 @@ export default class Task extends Fragment {
);
}
}
+
+ // TODO: This async fetcher seems like a better fit for most of our use-cases than the above getter (which cannot do async/await)
+ async getPathLinkedVariable() {
+ if (!this._job) {
+ await this._fetchParentJob();
+ }
+ await this._job.variables;
+ let jobID = this._job.plainId;
+ // not getting plainID because we dont know the resolution status of the task's job's parent yet
+ let parentID = this._job.belongsTo('parent').id()
+ ? JSON.parse(this._job.belongsTo('parent').id())[0]
+ : null;
+ if (parentID) {
+ jobID = parentID;
+ }
+ return await this._job.variables?.findBy(
+ 'path',
+ `nomad/jobs/${jobID}/${this.taskGroup.name}/${this.name}`
+ );
+ }
}
diff --git a/ui/app/router.js b/ui/app/router.js
index 4a3c36742..e9cc9fe9e 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -38,6 +38,7 @@ Router.map(function () {
this.route('services', function () {
this.route('service', { path: '/:name' });
});
+ this.route('variables');
});
});
diff --git a/ui/app/routes/jobs/job/variables.js b/ui/app/routes/jobs/job/variables.js
new file mode 100644
index 000000000..6ebbf9ff9
--- /dev/null
+++ b/ui/app/routes/jobs/job/variables.js
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+// @ts-check
+
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+// eslint-disable-next-line no-unused-vars
+import JobModel from '../../../models/job';
+import { A } from '@ember/array';
+
+export default class JobsJobVariablesRoute extends Route {
+ @service can;
+ @service router;
+ @service store;
+
+ beforeModel() {
+ if (this.can.cannot('list variables')) {
+ this.router.transitionTo(`/jobs`);
+ }
+ }
+ async model() {
+ /** @type {JobModel} */
+ let job = this.modelFor('jobs.job');
+ let taskGroups = job.taskGroups;
+ let tasks = taskGroups.map((tg) => tg.tasks.toArray()).flat();
+
+ let jobVariablePromise = job.getPathLinkedVariable();
+ let groupVariablesPromises = taskGroups.map((tg) =>
+ tg.getPathLinkedVariable()
+ );
+ let taskVariablesPromises = tasks.map((task) =>
+ task.getPathLinkedVariable()
+ );
+
+ let allJobsVariablePromise = this.store
+ .query('variable', {
+ path: 'nomad/jobs',
+ })
+ .then((variables) => {
+ return variables.findBy('path', 'nomad/jobs');
+ })
+ .catch((e) => {
+ if (e.errors?.findBy('status', 404)) {
+ return null;
+ }
+ throw e;
+ });
+
+ const variables = A(
+ await Promise.all([
+ allJobsVariablePromise,
+ jobVariablePromise,
+ ...groupVariablesPromises,
+ ...taskVariablesPromises,
+ ])
+ ).compact();
+
+ return { variables, job: this.modelFor('jobs.job') };
+ }
+}
diff --git a/ui/app/styles/components/variables.scss b/ui/app/styles/components/variables.scss
index 9d80d3eb2..9d7a3e831 100644
--- a/ui/app/styles/components/variables.scss
+++ b/ui/app/styles/components/variables.scss
@@ -212,6 +212,24 @@ table.variable-items {
}
}
+.job-variables-intro {
+ margin-bottom: 1rem;
+ ul li {
+ list-style-type: disc;
+ margin-left: 2rem;
+ code {
+ white-space-collapse: preserve-breaks;
+ display: inline-flex;
+ }
+ }
+}
+
+.job-variables-message {
+ p {
+ margin-bottom: 1rem;
+ }
+}
+
@keyframes slide-in {
0% {
top: 10px;
diff --git a/ui/app/templates/components/job-subnav.hbs b/ui/app/templates/components/job-subnav.hbs
index db100fb5b..0072eac28 100644
--- a/ui/app/templates/components/job-subnav.hbs
+++ b/ui/app/templates/components/job-subnav.hbs
@@ -84,5 +84,17 @@
{{/unless}}
+ {{#if (can "list variables")}}
+
+
+ Variables
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/ui/app/templates/jobs/job/variables.hbs b/ui/app/templates/jobs/job/variables.hbs
new file mode 100644
index 000000000..4cd6a35ee
--- /dev/null
+++ b/ui/app/templates/jobs/job/variables.hbs
@@ -0,0 +1,80 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+~}}
+
+{{page-title "Job " @model.job.name " variables"}}
+
+
+
+
+
+
+ Automatic Access to Variables
+
+ Tasks in this job can have automatic access to Nomad Variables.
+
+ - Use
+
+
+
+ for access in all tasks in all jobs
+ -
+ Use
+
+
+
+ for access from all tasks in this job
+
+ -
+ Use
+ {{#if (gt this.firstFewTaskGroupNames.length 1)}}
+ {{#each this.firstFewTaskGroupNames as |name|}}
+
,
+ {{/each}}
+ etc. for access from all tasks in a specific task group
+ {{else}}
+
+
+
+ for access from all tasks in a specific task group
+ {{/if}}
+
+ -
+ Use
+ {{#if (gt this.firstFewTaskNames.length 1)}}
+ {{#each this.firstFewTaskNames as |name|}}
+
,
+ {{/each}}
+ etc. for access from a specific task
+ {{else}}
+
+
+
for access from a specific task
+ {{/if}}
+
+
+
+
+
+
+
+{{#if this.jobRelevantVariables.files.length}}
+
+{{else}}
+
+
+ Job {{this.model.job.name}} does not have automatic access to any variables, but may have access by virtue of policies associated with this job's tasks' workload identities. See Workload-Associated ACL Policies for more information.
+
+ {{#if (can "write variable")}}
+
+ {{/if}}
+
+{{/if}}
+
+
+
+
+
diff --git a/ui/app/templates/variables/variable/index.hbs b/ui/app/templates/variables/variable/index.hbs
index e958fbf9e..547c0bd16 100644
--- a/ui/app/templates/variables/variable/index.hbs
+++ b/ui/app/templates/variables/variable/index.hbs
@@ -64,7 +64,7 @@
@job={{this.model.pathLinkedEntities.job}}
@group={{this.model.pathLinkedEntities.group}}
@task={{this.model.pathLinkedEntities.task}}
- @namespace={{this.model.namespace}}
+ @namespace={{or this.model.namespace "default"}}
/>
{{/if}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index f7f3bf291..0555f118a 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -960,7 +960,11 @@ export default function () {
});
this.get('/var/:id', function ({ variables }, { params }) {
- return variables.find(params.id);
+ let variable = variables.find(params.id);
+ if (!variable) {
+ return new Response(404, {}, {});
+ }
+ return variable;
});
this.put('/var/:id', function (schema, request) {
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index f55ffbad4..58c0ed14b 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -105,7 +105,7 @@ function smallCluster(server) {
createAllocations: true,
groupTaskCount: activelyDeployingTasksPerGroup,
shallow: true,
- resourceSpec: Array(activelyDeployingJobGroups).fill(['M: 257, C: 500']),
+ resourceSpec: Array(activelyDeployingJobGroups).fill('M: 257, C: 500'),
noDeployments: true, // manually created below
activeDeployment: true,
allocStatusDistribution: {
@@ -204,6 +204,7 @@ function smallCluster(server) {
'just some arbitrary file',
'another arbitrary file',
'another arbitrary file again',
+ 'nomad/jobs',
].forEach((path) => server.create('variable', { id: path }));
server.create('variable', {
@@ -359,6 +360,11 @@ function mediumCluster(server) {
function variableTestCluster(server) {
faker.seed(1);
createTokens(server);
+ server.create('token', {
+ name: 'Novars Murphy',
+ id: 'n0-v4r5-4cc355',
+ type: 'client',
+ });
createNamespaces(server);
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
server.createList('node-pool', 3);
diff --git a/ui/tests/acceptance/keyboard-test.js b/ui/tests/acceptance/keyboard-test.js
index ab42c40a2..880d9455e 100644
--- a/ui/tests/acceptance/keyboard-test.js
+++ b/ui/tests/acceptance/keyboard-test.js
@@ -305,6 +305,9 @@ module('Acceptance | keyboard', function (hooks) {
});
test('Dynamic nav arrows and looping', async function (assert) {
+ // Make sure user is a management token so Variables appears, etc.
+ let token = server.create('token', { type: 'management' });
+ window.localStorage.nomadTokenSecret = token.secretId;
server.createList('job', 3, { createAllocations: true, type: 'system' });
const jobID = server.db.jobs.sortBy('modifyIndex').reverse()[0].id;
await visit(`/jobs/${jobID}@default`);
@@ -344,6 +347,15 @@ module('Acceptance | keyboard', function (hooks) {
'Shift+ArrowRight takes you to the next tab (Evaluations)'
);
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/jobs/${jobID}@default/clients`,
+ 'Shift+ArrowRight takes you to the next tab (Clients)'
+ );
+
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
shiftKey: true,
});
@@ -353,6 +365,15 @@ module('Acceptance | keyboard', function (hooks) {
'Shift+ArrowRight takes you to the next tab (Services)'
);
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/jobs/${jobID}@default/variables`,
+ 'Shift+ArrowRight takes you to the next tab (Variables)'
+ );
+
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
shiftKey: true,
});
@@ -361,6 +382,7 @@ module('Acceptance | keyboard', function (hooks) {
`/jobs/${jobID}@default`,
'Shift+ArrowRight takes you to the first tab in the loop'
);
+ window.localStorage.nomadTokenSecret = null; // Reset Token
});
test('Region switching', async function (assert) {
diff --git a/ui/tests/acceptance/variables-test.js b/ui/tests/acceptance/variables-test.js
index 87b119523..fb38aade0 100644
--- a/ui/tests/acceptance/variables-test.js
+++ b/ui/tests/acceptance/variables-test.js
@@ -983,4 +983,254 @@ module('Acceptance | variables', function (hooks) {
});
});
});
+
+ module('Job Variables Page', function () {
+ test('If the user has no variable read access, no subnav exists', async function (assert) {
+ allScenarios.variableTestCluster(server);
+ const variablesToken = server.db.tokens.find('n0-v4r5-4cc355');
+ window.localStorage.nomadTokenSecret = variablesToken.secretId;
+ await visit(
+ `/jobs/${server.db.jobs[0].id}@${server.db.jobs[0].namespace}`
+ );
+ // Variables tab isn't in subnav
+ assert.dom('[data-test-tab="variables"]').doesNotExist();
+
+ // Attempting to access it directly will boot you to /jobs
+ await visit(
+ `/jobs/${server.db.jobs[0].id}@${server.db.jobs[0].namespace}/variables`
+ );
+ assert.equal(currentURL(), '/jobs?namespace=*');
+
+ window.localStorage.nomadTokenSecret = null; // Reset Token
+ });
+
+ test('If the user has variable read access, but no variables, the subnav exists but contains only a message', async function (assert) {
+ allScenarios.variableTestCluster(server);
+ const variablesToken = server.db.tokens.find(LIMITED_VARIABLE_TOKEN_ID);
+ window.localStorage.nomadTokenSecret = variablesToken.secretId;
+ await visit(
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}`
+ );
+ assert.dom('[data-test-tab="variables"]').exists();
+ await click('[data-test-tab="variables"] a');
+ assert.equal(
+ currentURL(),
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}/variables`
+ );
+ assert.dom('[data-test-no-auto-vars-message]').exists();
+ assert.dom('[data-test-create-variable-button]').doesNotExist();
+
+ window.localStorage.nomadTokenSecret = null; // Reset Token
+ });
+
+ test('If the user has variable write access, but no variables, the subnav exists but contains only a message and a create button', async function (assert) {
+ assert.expect(4);
+ allScenarios.variableTestCluster(server);
+ const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
+ window.localStorage.nomadTokenSecret = variablesToken.secretId;
+ await visit(
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}`
+ );
+ assert.dom('[data-test-tab="variables"]').exists();
+ await click('[data-test-tab="variables"] a');
+ assert.equal(
+ currentURL(),
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}/variables`
+ );
+ assert.dom('[data-test-no-auto-vars-message]').exists();
+ assert.dom('[data-test-create-variable-button]').exists();
+
+ await percySnapshot(assert);
+ window.localStorage.nomadTokenSecret = null; // Reset Token
+ });
+
+ test('If the user has variable read access, and variables, the subnav exists and contains a list of variables', async function (assert) {
+ allScenarios.variableTestCluster(server);
+ const variablesToken = server.db.tokens.find(LIMITED_VARIABLE_TOKEN_ID);
+ window.localStorage.nomadTokenSecret = variablesToken.secretId;
+
+ // in variablesTestCluster, job0 has path-linked variables, others do not.
+ await visit(
+ `/jobs/${server.db.jobs[0].id}@${server.db.jobs[0].namespace}`
+ );
+ assert.dom('[data-test-tab="variables"]').exists();
+ await click('[data-test-tab="variables"] a');
+ assert.equal(
+ currentURL(),
+ `/jobs/${server.db.jobs[0].id}@${server.db.jobs[0].namespace}/variables`
+ );
+ assert.dom('[data-test-file-row]').exists({ count: 3 });
+ window.localStorage.nomadTokenSecret = null; // Reset Token
+ });
+
+ test('The nomad/jobs variable is always included, if it exists', async function (assert) {
+ allScenarios.variableTestCluster(server);
+ const variablesToken = server.db.tokens.find(LIMITED_VARIABLE_TOKEN_ID);
+ window.localStorage.nomadTokenSecret = variablesToken.secretId;
+
+ server.create('variable', {
+ id: 'nomad/jobs',
+ keyValues: [],
+ });
+
+ // in variablesTestCluster, job0 has path-linked variables, others do not.
+ await visit(
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}`
+ );
+ assert.dom('[data-test-tab="variables"]').exists();
+ await click('[data-test-tab="variables"] a');
+ assert.equal(
+ currentURL(),
+ `/jobs/${server.db.jobs[1].id}@${server.db.jobs[1].namespace}/variables`
+ );
+ assert.dom('[data-test-file-row]').exists({ count: 1 });
+ assert.dom('[data-test-file-row="nomad/jobs"]').exists();
+ });
+
+ test('Multiple task variables are included, and make a maximum of 1 API request', async function (assert) {
+ //#region setup
+ server.create('node-pool');
+ server.create('node');
+ let token = server.create('token', { type: 'management' });
+ let job = server.create('job', {
+ createAllocations: true,
+ groupTaskCount: 10,
+ resourceSpec: Array(3).fill('M: 257, C: 500'), // 3 groups
+ shallow: false,
+ name: 'test-job',
+ id: 'test-job',
+ type: 'service',
+ activeDeployment: false,
+ namespaceId: 'default',
+ });
+
+ server.create('variable', {
+ id: 'nomad/jobs',
+ keyValues: [],
+ });
+ server.create('variable', {
+ id: 'nomad/jobs/test-job',
+ keyValues: [],
+ });
+ // Create a variable for each task
+
+ server.db.tasks.forEach((task) => {
+ let groupName = server.db.taskGroups.findBy(
+ (group) => group.id === task.taskGroupId
+ ).name;
+ server.create('variable', {
+ id: `nomad/jobs/test-job/${groupName}/${task.name}`,
+ keyValues: [],
+ });
+ });
+ window.localStorage.nomadTokenSecret = token.secretId;
+
+ //#endregion setup
+
+ //#region operation
+ await visit(`/jobs/${job.id}@${job.namespace}/variables`);
+
+ // 2 requests: one for the main nomad/vars variable, and one for a prefix of job name
+ let requests = server.pretender.handledRequests.filter(
+ (request) =>
+ request.url === '/v1/vars?path=nomad%2Fjobs' ||
+ request.url === `/v1/vars?prefix=nomad%2Fjobs%2F${job.name}`
+ );
+ assert.equal(requests.length, 2);
+
+ // Should see 32 rows: nomad/jobs, job-name, and 30 task variables
+ assert.dom('[data-test-file-row]').exists({ count: 32 });
+ //#endregion operation
+
+ window.localStorage.nomadTokenSecret = null; // Reset Token
+ });
+
+ // Test: Intro text shows examples of variables at groups and tasks
+ test('The intro text shows examples of variables at groups and tasks', async function (assert) {
+ //#region setup
+ server.create('node-pool');
+ server.create('node');
+ let token = server.create('token', { type: 'management' });
+ let job = server.create('job', {
+ createAllocations: true,
+ groupTaskCount: 2,
+ resourceSpec: Array(1).fill('M: 257, C: 500'), // 1 group
+ shallow: false,
+ name: 'test-job',
+ id: 'test-job',
+ type: 'service',
+ activeDeployment: false,
+ namespaceId: 'default',
+ });
+ server.create('variable', {
+ id: 'nomad/jobs/test-job',
+ keyValues: [],
+ });
+ // Create a variable for each taskGroup
+ server.db.taskGroups.forEach((group) => {
+ server.create('variable', {
+ id: `nomad/jobs/test-job/${group.name}`,
+ keyValues: [],
+ });
+ });
+
+ window.localStorage.nomadTokenSecret = token.secretId;
+
+ //#endregion setup
+
+ await visit(`/jobs/${job.id}@${job.namespace}`);
+ assert.dom('[data-test-tab="variables"]').exists();
+ await click('[data-test-tab="variables"] a');
+ assert.equal(currentURL(), `/jobs/${job.id}@${job.namespace}/variables`);
+
+ assert.dom('.job-variables-intro').exists();
+
+ // All-jobs reminder is there, link is to create a new variable
+ assert.dom('[data-test-variables-intro-all-jobs]').exists();
+ assert.dom('[data-test-variables-intro-all-jobs] a').exists();
+ assert
+ .dom('[data-test-variables-intro-all-jobs] a')
+ .hasAttribute('href', '/ui/variables/new?path=nomad%2Fjobs');
+
+ // This-job reminder is there, and since the variable exists, link is to edit it
+ assert.dom('[data-test-variables-intro-job]').exists();
+ assert.dom('[data-test-variables-intro-job] a').exists();
+ assert
+ .dom('[data-test-variables-intro-job] a')
+ .hasAttribute(
+ 'href',
+ `/ui/variables/var/nomad/jobs/${job.id}@${job.namespace}/edit`
+ );
+
+ // Group reminder is there, and since the variable exists, link is to edit it
+ assert.dom('[data-test-variables-intro-groups]').exists();
+ assert.dom('[data-test-variables-intro-groups] a').exists({ count: 1 });
+ assert
+ .dom('[data-test-variables-intro-groups]')
+ .doesNotContainText('etc.');
+ assert
+ .dom('[data-test-variables-intro-groups] a')
+ .hasAttribute(
+ 'href',
+ `/ui/variables/var/nomad/jobs/${job.id}/${server.db.taskGroups[0].name}@${job.namespace}/edit`
+ );
+
+ // Task reminder is there, and variables don't exist, so link is to create them, plus etc. reminder text
+ assert.dom('[data-test-variables-intro-tasks]').exists();
+ assert.dom('[data-test-variables-intro-tasks] a').exists({ count: 2 });
+ assert.dom('[data-test-variables-intro-tasks]').containsText('etc.');
+ assert
+ .dom('[data-test-variables-intro-tasks] code:nth-of-type(1) a')
+ .hasAttribute(
+ 'href',
+ `/ui/variables/new?path=nomad%2Fjobs%2F${job.id}%2F${server.db.taskGroups[0].name}%2F${server.db.tasks[0].name}`
+ );
+ assert
+ .dom('[data-test-variables-intro-tasks] code:nth-of-type(2) a')
+ .hasAttribute(
+ 'href',
+ `/ui/variables/new?path=nomad%2Fjobs%2F${job.id}%2F${server.db.taskGroups[0].name}%2F${server.db.tasks[1].name}`
+ );
+ });
+ });
});
diff --git a/ui/tests/integration/components/job-status-panel-test.js b/ui/tests/integration/components/job-status-panel-test.js
index 0c272e46d..13d01fea3 100644
--- a/ui/tests/integration/components/job-status-panel-test.js
+++ b/ui/tests/integration/components/job-status-panel-test.js
@@ -71,7 +71,7 @@ module(
activeDeployment: true,
groupTaskCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill(['M: 257, C: 500']), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
allocStatusDistribution,
});
@@ -406,7 +406,7 @@ module(
activeDeployment: true,
groupTaskCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill(['M: 257, C: 500']), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
allocStatusDistribution,
});
@@ -488,7 +488,7 @@ module(
activeDeployment: true,
groupTaskCount: ALLOCS_PER_GROUP,
shallow: true,
- resourceSpec: Array(NUMBER_OF_GROUPS).fill(['M: 257, C: 500']), // length of this array determines number of groups
+ resourceSpec: Array(NUMBER_OF_GROUPS).fill('M: 257, C: 500'), // length of this array determines number of groups
allocStatusDistribution,
});