[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 <phil.renaud@hashicorp.com>
This commit is contained in:
hc-github-team-nomad-core 2023-08-01 08:59:39 -05:00 committed by GitHub
parent 3b076edf11
commit e9b6be87e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 698 additions and 14 deletions

3
.changelog/17964.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: adds a new Variables page to all job pages
```

View File

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

View File

@ -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}}
<LinkTo @route={{link.route}} @model={{link.model}} @query={{link.query}}>{{@path}}</LinkTo>
{{else}}
<LinkTo @route={{link.route}} @query={{link.query}}>{{@path}}</LinkTo>
{{/if}}
{{/with}}
{{else}}
@path
{{/if}}

View File

@ -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
<LinkTo
@route="variables.variable.edit"
@model={{this.duplicatePathWarning.path}}
@model={{concat this.duplicatePathWarning.path "@" (or this.variableNamespace "default")}}
>
edit the existing variable
</LinkTo>

View File

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

View File

@ -36,7 +36,7 @@
{{#each this.files as |file|}}
<tr
data-test-file-row
data-test-file-row="{{file.name}}"
{{on "click" (fn this.handleFileClick file)}}
class={{if (can "read variable" path=file.absoluteFilePath namespace=file.variable.namespace) "" "inaccessible"}}
{{keyboard-shortcut

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
// @ts-check
import Controller from '@ember/controller';
import { alias } from '@ember/object/computed';
// eslint-disable-next-line no-unused-vars
import VariableModel from '../../../models/variable';
// eslint-disable-next-line no-unused-vars
import JobModel from '../../../models/job';
// eslint-disable-next-line no-unused-vars
import MutableArray from '@ember/array/mutable';
export default class JobsJobVariablesController extends Controller {
/** @type {JobModel} */
@alias('model.job') job;
/** @type {MutableArray<VariableModel>} */
@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: '',
};
}
}

View File

@ -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<string>} positional
* @param {{ existingPaths: MutableArray<VariableModel>, 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);

View File

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

View File

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

View File

@ -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, {
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}`
);
}
}

View File

@ -38,6 +38,7 @@ Router.map(function () {
this.route('services', function () {
this.route('service', { path: '/:name' });
});
this.route('variables');
});
});

View File

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

View File

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

View File

@ -84,5 +84,17 @@
</LinkTo>
</li>
{{/unless}}
{{#if (can "list variables")}}
<li data-test-tab="variables">
<LinkTo
@route="jobs.job.variables"
@model={{@job}}
@activeClass="is-active"
>
Variables
</LinkTo>
</li>
{{/if}}
</ul>
</div>

View File

@ -0,0 +1,80 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
~}}
{{page-title "Job " @model.job.name " variables"}}
<JobSubnav @job={{@model.job}} />
<section class="section">
<header class="job-variables-intro">
<Hds::Alert @type="inline" @color="highlight" as |A|>
<A.Title>Automatic Access to Variables</A.Title>
<A.Description>
<p>Tasks in this job can have <a href="https://developer.hashicorp.com/nomad/docs/concepts/variables#task-access-to-variables" target="_blank" rel="noopener noreferrer">automatic access to Nomad Variables</a>.</p>
<ul>
<li data-test-variables-intro-all-jobs>Use
<code>
<EditableVariableLink @path="nomad/jobs" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} />
</code>
for access in all tasks in all jobs</li>
<li data-test-variables-intro-job>
Use
<code>
<EditableVariableLink @path="nomad/jobs/{{this.model.job.name}}" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} />
</code>
for access from all tasks in this job
</li>
<li data-test-variables-intro-groups>
Use
{{#if (gt this.firstFewTaskGroupNames.length 1)}}
{{#each this.firstFewTaskGroupNames as |name|}}
<code><EditableVariableLink @path="nomad/jobs/{{this.model.job.name}}/{{name}}" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} /></code>,
{{/each}}
etc. for access from all tasks in a specific task group
{{else}}
<code>
<EditableVariableLink @path="nomad/jobs/{{this.model.job.name}}/{{object-at 0 this.firstFewTaskGroupNames}}" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} />
</code>
for access from all tasks in a specific task group
{{/if}}
</li>
<li data-test-variables-intro-tasks>
Use
{{#if (gt this.firstFewTaskNames.length 1)}}
{{#each this.firstFewTaskNames as |name|}}
<code><EditableVariableLink @path="nomad/jobs/{{this.model.job.name}}/{{name}}" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} /></code>,
{{/each}}
etc. for access from a specific task
{{else}}
<code>
<EditableVariableLink @path="nomad/jobs/{{this.model.job.name}}/{{object-at 0 this.firstFewTaskNames}}" @existingPaths={{this.jobRelevantVariables.files}} @namespace={{this.model.job.namespace.name}} />
</code> for access from a specific task
{{/if}}
</li>
</ul>
</A.Description>
<A.Link::Standalone @color="secondary" @icon="arrow-right" @iconPosition="trailing" @text="Learn more about Nomad Variables" @href="https://developer.hashicorp.com/nomad/tutorials/variables" />
</Hds::Alert>
</header>
{{#if this.jobRelevantVariables.files.length}}
<VariablePaths
@branch={{this.jobRelevantVariables}}
/>
{{else}}
<section class="job-variables-message">
<p data-test-no-auto-vars-message>
Job <strong>{{this.model.job.name}}</strong> 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 <a href="https://developer.hashicorp.com/nomad/docs/concepts/workload-identity#workload-associated-acl-policies" target="_blank" rel="noopener noreferrer">Workload-Associated ACL Policies</a> for more information.
</p>
{{#if (can "write variable")}}
<Hds::Button data-test-create-variable-button @text="Create a Variable" @size="large" @route="variables.new" @query={{hash path=(concat "nomad/jobs/" this.model.job.name)}} />
{{/if}}
</section>
{{/if}}
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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