Fix ACL requirements for job details UI (#11672)

This commit is contained in:
Luiz Aoqui 2022-01-12 21:26:02 -05:00 committed by GitHub
parent 7e6acf0e68
commit c7ae13a1f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 396 additions and 163 deletions

3
.changelog/11672.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:bug
ui: Fix the ACL requirements for displaying the job details page
```

View File

@ -7,18 +7,30 @@ import classic from 'ember-classic-decorator';
export default class Client extends AbstractAbility {
// Map abilities to policy options (which are coarse for nodes)
// instead of specific behaviors.
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeRead')
canRead;
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeWrite')
canWrite;
@computed('token.selfTokenPolicies.[]')
get policiesIncludeNodeRead() {
return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['read', 'write']);
}
@computed('token.selfTokenPolicies.[]')
get policiesIncludeNodeWrite() {
return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['write']);
}
}
function policiesIncludePermissions(policies = [], permissions = []) {
// For each policy record, extract the Node policy
const policies = (this.get('token.selfTokenPolicies') || [])
const nodePolicies = policies
.toArray()
.map(policy => get(policy, 'rulesJSON.Node.Policy'))
.compact();
// Node write is allowed if any policy allows it
return policies.some(policy => policy === 'write');
}
// Check for requested permissions
return nodePolicies.some(policy => permissions.includes(policy));
}

View File

@ -5,6 +5,7 @@ import classic from 'ember-classic-decorator';
@classic
export default class Abstract extends Component {
@service can;
@service system;
job = null;
@ -20,6 +21,10 @@ export default class Abstract extends Component {
// Set to a { title, description } to surface an error
errorMessage = null;
get shouldDisplayClientInformation() {
return this.can.can('read client') && this.job.hasClientStatus;
}
@action
clearErrorMessage() {
this.set('errorMessage', null);

View File

@ -1,14 +1,11 @@
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import PeriodicChildJobPage from './periodic-child';
import classic from 'ember-classic-decorator';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class ParameterizedChild extends PeriodicChildJobPage {
@alias('job.decodedPayload') payload;
@service store;
@computed('payload')
get payloadJSON() {
@ -20,10 +17,4 @@ export default class ParameterizedChild extends PeriodicChildJobPage {
}
return json;
}
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@ -2,20 +2,26 @@ import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
@classNames('boxed-section')
export default class JobClientStatusSummary extends Component {
job = null;
jobClientStatus = null;
nodes = null;
forceCollapsed = false;
gotoClients() {}
@computed
@computed('forceCollapsed')
get isExpanded() {
if (this.forceCollapsed) return false;
const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}
@jobClientStatus('nodes', 'job') jobClientStatus;
@action
onSliceClick(ev, slice) {
this.gotoClients([slice.className.camelize()]);

View File

@ -1,13 +1,9 @@
import AbstractJobPage from './abstract';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class PeriodicChild extends AbstractJobPage {
@service store;
@computed('job.{name,id}', 'job.parent.{name,id}')
get breadcrumbs() {
const job = this.job;
@ -25,10 +21,4 @@ export default class PeriodicChild extends AbstractJobPage {
},
];
}
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@ -1,15 +1,5 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class Sysbatch extends AbstractJobPage {
@service store;
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}
export default class Sysbatch extends AbstractJobPage {}

View File

@ -1,15 +1,5 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class System extends AbstractJobPage {
@service store;
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}
export default class System extends AbstractJobPage {}

View File

@ -9,8 +9,10 @@ export default class AccordionHead extends Component {
'data-test-accordion-head' = true;
buttonLabel = 'toggle';
tooltip = '';
isOpen = false;
isExpandable = true;
isDisabled = false;
item = null;
onClose() {}

View File

@ -1,5 +1,5 @@
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { computed } from '@ember/object';
import Controller from '@ember/controller';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
import { action } from '@ember/object';
@ -23,7 +23,15 @@ export default class IndexController extends Controller.extend(WithNamespaceRese
currentPage = 1;
@alias('model') job;
@computed('model.job')
get job() {
return this.model.job;
}
@computed('model.nodes.[]')
get nodes() {
return this.model.nodes;
}
sortProperty = 'name';
sortDescending = false;

View File

@ -30,6 +30,16 @@ export default class Allocation extends Model {
@fragment('resources') allocatedResources;
@attr('number') jobVersion;
// Store basic node information returned by the API to avoid the need for
// node:read ACL permission.
@attr('string') nodeName;
@computed
get shortNodeId() {
return this.belongsTo('node')
.id()
.split('-')[0];
}
@attr('number') modifyIndex;
@attr('date') modifyTime;

View File

@ -1,4 +1,5 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { collect } from '@ember/object/computed';
import {
watchRecord,
@ -9,34 +10,49 @@ import {
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default class IndexRoute extends Route.extend(WithWatchers) {
@service can;
@service store;
async model() {
// Optimizing future node look ups by preemptively loading everything
const job = this.modelFor('jobs.job');
if (!job) {
return { job, nodes: [] };
}
// Optimizing future node look ups by preemptively loading all nodes if
// necessary and allowed.
if (this.can.can('read client') && job.get('hasClientStatus')) {
await this.store.findAll('node');
return this.modelFor('jobs.job');
}
const nodes = this.store.peekAll('node');
return { job, nodes };
}
startWatchers(controller, model) {
if (!model) {
if (!model.job) {
return;
}
controller.set('watchers', {
model: this.watch.perform(model),
summary: this.watchSummary.perform(model.get('summary')),
allocations: this.watchAllocations.perform(model),
evaluations: this.watchEvaluations.perform(model),
model: this.watch.perform(model.job),
summary: this.watchSummary.perform(model.job.get('summary')),
allocations: this.watchAllocations.perform(model.job),
evaluations: this.watchEvaluations.perform(model.job),
latestDeployment:
model.get('supportsDeployments') && this.watchLatestDeployment.perform(model),
model.job.get('supportsDeployments') && this.watchLatestDeployment.perform(model.job),
list:
model.get('hasChildren') &&
this.watchAllJobs.perform({ namespace: model.namespace.get('name') }),
nodes: model.get('hasClientStatus') && this.watchNodes.perform(),
model.job.get('hasChildren') &&
this.watchAllJobs.perform({ namespace: model.job.namespace.get('name') }),
nodes:
this.can.can('read client') &&
model.job.get('hasClientStatus') &&
this.watchNodes.perform(),
});
}
setupController(controller, model) {
// Parameterized and periodic detail pages, which list children jobs,
// should sort by submit time.
if (model && ['periodic', 'parameterized'].includes(model.templateType)) {
if (model.job && ['periodic', 'parameterized'].includes(model.job.templateType)) {
controller.setProperties({
sortProperty: 'submitTime',
sortDescending: true,

View File

@ -1,9 +1,11 @@
<td data-test-indicators class="is-narrow">
{{#if (can "read client")}}
{{#if this.allocation.unhealthyDrivers.length}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" role="tooltip" aria-label="Allocation depends on unhealthy drivers">
{{x-icon "alert-triangle" class="is-warning"}}
</span>
{{/if}}
{{/if}}
{{#if this.allocation.nextAllocation}}
<span data-test-icon="reschedule" class="tooltip text-center" role="tooltip" aria-label="Allocation was rescheduled">
{{x-icon "history" class="is-faded"}}
@ -38,20 +40,28 @@
</td>
{{#if (eq this.context "volume")}}
<td data-test-client>
<Tooltip @text={{this.allocation.node.name}}>
<Tooltip @text={{this.allocation.nodeName}}>
{{#if (can "read client")}}
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
{{this.allocation.node.shortId}}
{{this.allocation.shortNodeId}}
</LinkTo>
{{else}}
{{this.allocation.shortNodeId}}
{{/if}}
</Tooltip>
</td>
{{/if}}
{{#if (or (eq this.context "taskGroup") (eq this.context "job"))}}
<td data-test-job-version>{{this.allocation.jobVersion}}</td>
<td data-test-client>
<Tooltip @text={{this.allocation.node.name}}>
<Tooltip @text={{this.allocation.nodeName}}>
{{#if (can "read client")}}
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
{{this.allocation.node.shortId}}
{{this.allocation.shortNodeId}}
</LinkTo>
{{else}}
{{this.allocation.shortNodeId}}
{{/if}}
</Tooltip>
</td>
{{else if (or (eq this.context "node") (eq this.context "volume"))}}

View File

@ -16,13 +16,13 @@
{{#if this.job.hasClientStatus}}
<JobPage::Parts::JobClientStatusSummary
@gotoClients={{this.gotoClients}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
/>
@nodes={{this.nodes}}
@forceCollapsed={{not this.shouldDisplayClientInformation}}
@gotoClients={{this.gotoClients}} />
{{/if}}
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -1,22 +1,28 @@
<ListAccordion
data-test-job-summary
data-test-job-client-summary
@source={{array this.job}}
@key="id"
@startExpanded={{this.isExpanded}}
@onToggle={{action this.persist}} as |a|
>
<a.head @buttonLabel={{if a.isOpen "collapse" "expand"}}>
<a.head
@buttonLabel={{if a.isOpen "collapse" "expand"}}
@tooltip={{if (cannot "read client") "You dont have permission to read clients"}}
@isDisabled={{cannot "read client"}}
>
<div class="columns">
<div class="column is-minimum nowrap">
Job Status in Client
{{#if this.jobClientStatus}}
<span class="badge {{if a.isOpen "is-white" "is-light"}}">
{{this.jobClientStatus.totalNodes}}
</span>
{{/if}}
<span class="tooltip multiline" aria-label="Aggreate status of job's allocations in each client.">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</div>
{{#unless a.isOpen}}
{{#if (and this.jobClientStatus (not a.isOpen))}}
<div class="column">
<div class="inline-chart bumper-left">
<JobClientStatusBar
@ -27,10 +33,11 @@
/>
</div>
</div>
{{/unless}}
{{/if}}
</div>
</a.head>
<a.body>
{{#if this.jobClientStatus}}
<JobClientStatusBar
@onSliceClick={{action this.onSliceClick}}
@job={{this.job}}
@ -51,5 +58,6 @@
{{/each}}
</ol>
</JobClientStatusBar>
{{/if}}
</a.body>
</ListAccordion>

View File

@ -16,13 +16,13 @@
{{#if this.job.hasClientStatus}}
<JobPage::Parts::JobClientStatusSummary
@gotoClients={{this.gotoClients}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
/>
@nodes={{this.nodes}}
@forceCollapsed={{not this.shouldDisplayClientInformation}}
@gotoClients={{this.gotoClients}} />
{{/if}}
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -7,10 +7,11 @@
<JobPage::Parts::JobClientStatusSummary
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
@nodes={{this.nodes}}
@forceCollapsed={{not this.shouldDisplayClientInformation}}
@gotoClients={{this.gotoClients}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -13,10 +13,11 @@
<JobPage::Parts::JobClientStatusSummary
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
@nodes={{this.nodes}}
@forceCollapsed={{not this.shouldDisplayClientInformation}}
@gotoClients={{this.gotoClients}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@ -8,7 +8,7 @@
{{/if}}
<li data-test-tab="allocations"><LinkTo @route="jobs.job.allocations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Allocations</LinkTo></li>
<li data-test-tab="evaluations"><LinkTo @route="jobs.job.evaluations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Evaluations</LinkTo></li>
{{#if (and this.job.hasClientStatus (not this.job.hasChildren))}}
{{#if (and (can "read client") (and this.job.hasClientStatus (not this.job.hasChildren)))}}
<li data-test-tab="clients"><LinkTo @route="jobs.job.clients" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Clients</LinkTo></li>
{{/if}}
</ul>

View File

@ -3,7 +3,9 @@
</div>
<button
data-test-accordion-toggle
class="button is-light is-compact pull-right accordion-toggle {{unless this.isExpandable "is-invisible"}}"
class="button is-light is-compact pull-right accordion-toggle {{unless this.isExpandable "is-invisible"}} {{if this.tooltip "tooltip multiline"}}"
disabled={{if this.isDisabled "disabled"}}
aria-label={{this.tooltip}}
onclick={{action (if this.isOpen this.onClose this.onOpen) this.item}}
type="button">
{{this.buttonLabel}}

View File

@ -1,9 +1,11 @@
<td class="is-narrow">
{{#if (can "read client")}}
{{#unless this.task.driverStatus.healthy}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="{{this.task.driver}} is unhealthy">
{{x-icon "alert-triangle" class="is-warning"}}
</span>
{{/unless}}
{{/if}}
</td>
<td data-test-name class="nowrap">
<LinkTo @route="allocations.allocation.task" @models={{array this.task.allocation this.task}} class="is-primary">

View File

@ -1,6 +1,7 @@
{{page-title "Job " this.model.name}}
{{component (concat "job-page/" this.model.templateType)
job=this.model
{{page-title "Job " this.job.name}}
{{component (concat "job-page/" this.job.templateType)
job=this.job
nodes=this.nodes
sortProperty=this.sortProperty
sortDescending=this.sortDescending
currentPage=this.currentPage

View File

@ -35,7 +35,7 @@ export default function jobClientStatus(nodesKey, jobKey) {
// Group the job allocations by the ID of the client that is running them.
const allocsByNodeID = {};
job.allocations.forEach(a => {
const nodeId = a.node.get('id');
const nodeId = a.belongsTo('node').id();
if (!allocsByNodeID[nodeId]) {
allocsByNodeID[nodeId] = [];
}

View File

@ -175,6 +175,7 @@ export default Factory.extend({
namespace,
jobId: job.id,
nodeId: node.id,
nodeName: node.name,
taskStateIds: [],
taskResourceIds: [],
taskGroup: taskGroup.name,

View File

@ -6,6 +6,7 @@ import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import Allocation from 'nomad-ui/tests/pages/allocations/detail';
import Tokens from 'nomad-ui/tests/pages/settings/tokens';
import moment from 'moment';
import formatHost from 'nomad-ui/utils/format-host';
@ -45,6 +46,8 @@ module('Acceptance | allocation detail', function(hooks) {
driver: 'docker',
});
window.localStorage.clear();
await Allocation.visit({ id: allocation.id });
});
@ -177,6 +180,26 @@ module('Acceptance | allocation detail', function(hooks) {
});
test('tasks with an unhealthy driver have a warning icon', async function(assert) {
// Driver health status require node:read permission.
const policy = server.create('policy', {
id: 'node-read',
name: 'node-read',
rulesJSON: {
Node: {
Policy: 'read',
},
},
});
const clientToken = server.create('token', { type: 'client' });
clientToken.policyIds = [policy.id];
clientToken.save();
// Since the page is already visited in the beforeEach hook, setting the
// localStorage directly is not enough.
await Tokens.visit();
await Tokens.secret(clientToken.secretId).submit();
await Allocation.visit({ id: allocation.id });
assert.ok(Allocation.firstUnhealthyTask().hasUnhealthyDriver, 'Warning is shown');
});
@ -271,7 +294,9 @@ module('Acceptance | allocation detail', function(hooks) {
await Allocation.stop.confirm();
assert.equal(
server.pretender.handledRequests.reject(request => request.url.includes('fuzzy')).findBy('method', 'POST').url,
server.pretender.handledRequests
.reject(request => request.url.includes('fuzzy'))
.findBy('method', 'POST').url,
`/v1/allocation/${allocation.id}/stop`,
'Stop request is made for the allocation'
);
@ -381,6 +406,7 @@ module('Acceptance | allocation detail (preemptions)', function(hooks) {
server.create('agent');
node = server.create('node');
job = server.create('job', { createAllocations: false });
window.localStorage.clear();
});
test('shows a dedicated section to the allocation that preempted this allocation', async function(assert) {
@ -463,6 +489,32 @@ module('Acceptance | allocation detail (preemptions)', function(hooks) {
server.db.nodes.find(preemption.nodeId).id.split('-')[0],
'Node ID'
);
});
test('clicking the client ID in the preempted allocation row naviates to the client page', async function(assert) {
// Navigating to the client page requires node:read permission.
const policy = server.create('policy', {
id: 'node-read',
name: 'node-read',
rulesJSON: {
Node: {
Policy: 'read',
},
},
});
const clientToken = server.create('token', { type: 'client' });
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
allocation = server.create('allocation', 'preempter');
await Allocation.visit({ id: allocation.id });
const preemption = allocation.preemptedAllocations
.map(id => server.schema.find('allocation', id))
.sortBy('modifyIndex')
.reverse()[0];
const preemptionRow = Allocation.preemptions.objectAt(0);
await preemptionRow.visitClient();
assert.equal(currentURL(), `/clients/${preemption.nodeId}`, 'Node links to node page');

View File

@ -282,9 +282,30 @@ module('Acceptance | task group detail', function(hooks) {
Object.keys(taskGroup.volumes).length ? 'Yes' : '',
'Volumes'
);
});
test('clicking the client ID in the allocation row naviates to the client page', async function(assert) {
// Navigating to the client page requires node:read permission.
const policy = server.create('policy', {
id: 'node-read',
name: 'node-read',
rulesJSON: {
Node: {
Policy: 'read',
},
},
});
const clientToken = server.create('token', { type: 'client' });
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
await TaskGroup.visit({ id: job.id, name: taskGroup.name });
const allocation = allocations.sortBy('modifyIndex').reverse()[0];
const allocationRow = TaskGroup.allocations.objectAt(0);
await allocationRow.visitClient();
assert.equal(currentURL(), `/clients/${allocation.nodeId}`, 'Node links to node page');
});

View File

@ -3,6 +3,7 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
import Tokens from 'nomad-ui/tests/pages/settings/tokens';
// eslint-disable-next-line ember/no-test-module-for
export default function moduleForJob(title, context, jobFactory, additionalTests) {
@ -30,19 +31,28 @@ export default function moduleForJob(title, context, jobFactory, additionalTests
});
test('visiting /jobs/:job_id', async function(assert) {
assert.equal(
currentURL(),
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace)
const expectedURL = new URL(
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace),
window.location
);
const gotURL = new URL(currentURL(), window.location);
assert.deepEqual(gotURL.path, expectedURL.path);
assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
assert.equal(document.title, `Job ${job.name} - Nomad`);
});
test('the subnav links to overview', async function(assert) {
await JobDetail.tabFor('overview').visit();
assert.equal(
currentURL(),
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace)
const expectedURL = new URL(
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace),
window.location
);
const gotURL = new URL(currentURL(), window.location);
assert.deepEqual(gotURL.path, expectedURL.path);
assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
});
test('the subnav links to definition', async function(assert) {
@ -128,6 +138,23 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
setupMirage(hooks);
hooks.beforeEach(async function() {
// Displaying the job status in client requires node:read permission.
const policy = server.create('policy', {
id: 'node-read',
name: 'node-read',
rulesJSON: {
Node: {
Policy: 'read',
},
},
});
const clientToken = server.create('token', { type: 'client' });
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.clear();
window.localStorage.nomadTokenSecret = clientToken.secretId;
const clients = server.createList('node', 3, {
datacenter: 'dc1',
status: 'ready',
@ -143,6 +170,23 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
}
});
test('job status summary is collapsed when not authorized', async function(assert) {
const clientToken = server.create('token', { type: 'client' });
await Tokens.visit();
await Tokens.secret(clientToken.secretId).submit();
await JobDetail.visit({ id: job.id, namespace: job.namespace });
assert.ok(
JobDetail.jobClientStatusSummary.toggle.isDisabled,
'Job client status summar is disabled'
);
assert.equal(
JobDetail.jobClientStatusSummary.toggle.tooltip,
'You dont have permission to read clients'
);
});
test('the subnav links to clients', async function(assert) {
await JobDetail.tabFor('clients').visit();
assert.equal(
@ -153,13 +197,13 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
test('job status summary is shown in the overview', async function(assert) {
assert.ok(
JobDetail.jobClientStatusSummary.isPresent,
JobDetail.jobClientStatusSummary.statusBar.isPresent,
'Summary bar is displayed in the Job Status in Client summary section'
);
});
test('clicking legend item navigates to a pre-filtered clients table', async function(assert) {
const legendItem = JobDetail.jobClientStatusSummary.legend.clickableItems[0];
const legendItem = JobDetail.jobClientStatusSummary.statusBar.legend.clickableItems[0];
const status = legendItem.label;
await legendItem.click();
@ -174,7 +218,7 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
});
test('clicking in a slice takes you to a pre-filtered clients table', async function(assert) {
const slice = JobDetail.jobClientStatusSummary.slices[0];
const slice = JobDetail.jobClientStatusSummary.statusBar.slices[0];
const status = slice.label;
await slice.click();

View File

@ -14,10 +14,12 @@ module('Integration | Component | allocation row', function(hooks) {
hooks.beforeEach(function() {
fragmentSerializerInitializer(this.owner);
this.store = this.owner.lookup('service:store');
this.token = this.owner.lookup('service:token');
this.server = startMirage();
this.server.create('namespace');
this.server.create('node');
this.server.create('job', { createAllocations: false });
window.localStorage.clear();
});
hooks.afterEach(function() {
@ -79,6 +81,24 @@ module('Integration | Component | allocation row', function(hooks) {
});
test('Allocation row shows warning when it requires drivers that are unhealthy on the node it is running on', async function(assert) {
// Driver health status require node:read permission.
const policy = server.create('policy', {
id: 'node-read',
name: 'node-read',
rulesJSON: {
Node: {
Policy: 'read',
},
},
});
const clientToken = server.create('token', { type: 'client' });
clientToken.policyIds = [policy.id];
clientToken.save();
// Set and fetch ACL token.
window.localStorage.nomadTokenSecret = clientToken.secretId;
this.token.fetchSelfTokenAndPolicies.perform();
const node = this.server.schema.nodes.first();
const drivers = node.drivers;
Object.values(drivers).forEach(driver => {

View File

@ -72,7 +72,17 @@ export default create({
return this.packStats.toArray().findBy('id', id);
},
jobClientStatusSummary: jobClientStatusBar('[data-test-job-client-status-bar]'),
jobClientStatusSummary: {
scope: '[data-test-job-client-summary]',
statusBar: jobClientStatusBar('[data-test-job-client-status-bar]'),
toggle: {
scope: '[data-test-accordion-head] [data-test-accordion-toggle]',
click: clickable(),
isDisabled: attribute('disabled'),
tooltip: attribute('aria-label'),
},
},
childrenSummary: isPresent('[data-test-job-summary] [data-test-children-status-bar]'),
allocationsSummary: isPresent('[data-test-job-summary] [data-test-allocation-status-bar]'),

View File

@ -8,26 +8,28 @@ module('Unit | Ability | client', function(hooks) {
setupTest(hooks);
setupAbility('client')(hooks);
test('it permits client write when ACLs are disabled', function(assert) {
test('it permits client read and write when ACLs are disabled', function(assert) {
const mockToken = Service.extend({
aclEnabled: false,
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRead);
assert.ok(this.ability.canWrite);
});
test('it permits client write for management tokens', function(assert) {
test('it permits client read and write for management tokens', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'management' },
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRead);
assert.ok(this.ability.canWrite);
});
test('it permits client write for tokens with a policy that has node-write', function(assert) {
test('it permits client read and write for tokens with a policy that has node-write', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
@ -43,10 +45,11 @@ module('Unit | Ability | client', function(hooks) {
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRead);
assert.ok(this.ability.canWrite);
});
test('it permits client write for tokens with a policy that allows write and another policy that disallows it', function(assert) {
test('it permits client read and write for tokens with a policy that allows write and another policy that disallows it', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
@ -69,10 +72,11 @@ module('Unit | Ability | client', function(hooks) {
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRead);
assert.ok(this.ability.canWrite);
});
test('it blocks client write for tokens with a policy that does not allow node-write', function(assert) {
test('it permits client read and blocks client write for tokens with a policy that does not allow node-write', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
@ -88,6 +92,23 @@ module('Unit | Ability | client', function(hooks) {
});
this.owner.register('service:token', mockToken);
assert.ok(this.ability.canRead);
assert.notOk(this.ability.canWrite);
});
test('it blocks client read and write for tokens without a node policy', function(assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
selfTokenPolicies: [
{
rulesJSON: {},
},
],
});
this.owner.register('service:token', mockToken);
assert.notOk(this.ability.canRead);
assert.notOk(this.ability.canWrite);
});
});

View File

@ -35,6 +35,22 @@ class NodeMock {
}
}
class AllocationMock {
constructor(node, clientStatus) {
this.node = node;
this.clientStatus = clientStatus;
}
belongsTo() {
const self = this;
return {
id() {
return self.node.id;
},
};
}
}
module('Unit | Util | JobClientStatus', function() {
test('it handles the case where all nodes are running', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
@ -42,7 +58,7 @@ module('Unit | Util | JobClientStatus', function() {
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [{ node, clientStatus: 'running' }],
allocations: [new AllocationMock(node, 'running')],
taskGroups: [{}],
};
const expected = {
@ -75,9 +91,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'running' },
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'running' },
new AllocationMock(node, 'running'),
new AllocationMock(node, 'failed'),
new AllocationMock(node, 'running'),
],
taskGroups: [{}, {}, {}],
};
@ -111,9 +127,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'lost' },
{ node, clientStatus: 'lost' },
{ node, clientStatus: 'lost' },
new AllocationMock(node, 'lost'),
new AllocationMock(node, 'lost'),
new AllocationMock(node, 'lost'),
],
taskGroups: [{}, {}, {}],
};
@ -147,9 +163,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'failed' },
new AllocationMock(node, 'failed'),
new AllocationMock(node, 'failed'),
new AllocationMock(node, 'failed'),
],
taskGroups: [{}, {}, {}],
};
@ -183,9 +199,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'running' },
{ node, clientStatus: 'running' },
{ node, clientStatus: 'running' },
new AllocationMock(node, 'running'),
new AllocationMock(node, 'running'),
new AllocationMock(node, 'running'),
],
taskGroups: [{}, {}, {}, {}],
};
@ -251,9 +267,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'pending',
allocations: [
{ node, clientStatus: 'starting' },
{ node, clientStatus: 'starting' },
{ node, clientStatus: 'starting' },
new AllocationMock(node, 'starting'),
new AllocationMock(node, 'starting'),
new AllocationMock(node, 'starting'),
],
taskGroups: [{}, {}, {}, {}],
};
@ -288,9 +304,9 @@ module('Unit | Util | JobClientStatus', function() {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node: node1, clientStatus: 'running' },
{ node: node2, clientStatus: 'failed' },
{ node: node1, clientStatus: 'running' },
new AllocationMock(node1, 'running'),
new AllocationMock(node2, 'failed'),
new AllocationMock(node1, 'running'),
],
taskGroups: [{}, {}],
};