* 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:
parent
3b076edf11
commit
e9b6be87e2
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: adds a new Variables page to all job pages
|
||||
```
|
|
@ -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');
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: '',
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ Router.map(function () {
|
|||
this.route('services', function () {
|
||||
this.route('service', { path: '/:name' });
|
||||
});
|
||||
this.route('variables');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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') };
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue