From e9b6be87e235cd97334411f283a46a3455f81812 Mon Sep 17 00:00:00 2001 From: hc-github-team-nomad-core <82989552+hc-github-team-nomad-core@users.noreply.github.com> Date: Tue, 1 Aug 2023 08:59:39 -0500 Subject: [PATCH] [ui] Job Variables page (#17964) (#18106) * Bones of a component that has job variable awareness * Got vars listed woo * Variables as its own subnav and some pathLinkedVariable perf fixes * Automatic Access to Variables alerter * Helper and component to conditionally render the right link * A bit of cleanup post-template stuff * testfix for looping right-arrow keynav bc we have a new subnav section * A very roundabout way of ensuring that, if a job exists when saving a variable with a pathLinkedEntity of that job, its saved right through to the job itself * hacky but an async version of pathLinkedVariable * model-driven and async fetcher driven with cleanup * Only run the update-job func if jobname is detected in var path * Test cases begun * Management token for variables to appear in tests * Its a management token so it gets to see the clients tab under system jobs * Pre-review cleanup * More tests * Number of requests test and small fix to groups-by-way-or-resource-arrays elsewhere * Variable intro text tests * Variable name re-use * Simplifying our wording a bit * parse json vs plainId * Addressed PR feedback, including de-waterfalling Co-authored-by: Phil Renaud --- .changelog/17964.txt | 3 + ui/app/abilities/job.js | 21 ++ ui/app/components/editable-variable-link.hbs | 17 ++ ui/app/components/variable-form.hbs | 4 +- ui/app/components/variable-form.js | 30 +++ ui/app/components/variable-paths.hbs | 2 +- ui/app/controllers/jobs/job/variables.js | 58 ++++ ui/app/helpers/editable-variable-link.js | 47 ++++ ui/app/models/job.js | 15 +- ui/app/models/task-group.js | 18 +- ui/app/models/task.js | 29 +- ui/app/router.js | 1 + ui/app/routes/jobs/job/variables.js | 63 +++++ ui/app/styles/components/variables.scss | 18 ++ ui/app/templates/components/job-subnav.hbs | 12 + ui/app/templates/jobs/job/variables.hbs | 80 ++++++ ui/app/templates/variables/variable/index.hbs | 2 +- ui/mirage/config.js | 6 +- ui/mirage/scenarios/default.js | 8 +- ui/tests/acceptance/keyboard-test.js | 22 ++ ui/tests/acceptance/variables-test.js | 250 ++++++++++++++++++ .../components/job-status-panel-test.js | 6 +- 22 files changed, 698 insertions(+), 14 deletions(-) create mode 100644 .changelog/17964.txt create mode 100644 ui/app/components/editable-variable-link.hbs create mode 100644 ui/app/controllers/jobs/job/variables.js create mode 100644 ui/app/helpers/editable-variable-link.js create mode 100644 ui/app/routes/jobs/job/variables.js create mode 100644 ui/app/templates/jobs/job/variables.hbs 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, });