Merge pull request #10893 from hashicorp/f-ui/namespace-acl-bug

edit ember-can to add additional attribute for namespace
This commit is contained in:
Jai 2021-07-22 12:57:34 -04:00 committed by GitHub
commit 0ccf60444d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 317 additions and 22 deletions

3
.changelog/10893.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:bug
ui: Fixes bug where UI was not detecting namespace-specific capabilities.
```

View file

@ -19,9 +19,14 @@ export default class TaskGroupRow extends Component {
@oneWay('taskGroup.count') count; @oneWay('taskGroup.count') count;
@alias('taskGroup.job.runningDeployment') runningDeployment; @alias('taskGroup.job.runningDeployment') runningDeployment;
@computed('runningDeployment') get namespace() {
return this.get('taskGroup.job.namespace.name');
}
@computed('runningDeployment', 'namespace')
get tooltipText() { get tooltipText() {
if (this.can.cannot('scale job')) return "You aren't allowed to scale task groups"; if (this.can.cannot('scale job', null, { namespace: this.namespace }))
return "You aren't allowed to scale task groups";
if (this.runningDeployment) return 'You cannot scale task groups during a deployment'; if (this.runningDeployment) return 'You cannot scale task groups during a deployment';
return undefined; return undefined;
} }

View file

@ -66,9 +66,10 @@ export default class TaskGroupController extends Controller.extend(
}) })
shouldShowScaleEventTimeline; shouldShowScaleEventTimeline;
@computed('model.job.runningDeployment') @computed('model.job.{namespace,runningDeployment}')
get tooltipText() { get tooltipText() {
if (this.can.cannot('scale job')) return "You aren't allowed to scale task groups"; if (this.can.cannot('scale job', null, { namespace: this.model.job.namespace.get('name') }))
return "You aren't allowed to scale task groups";
if (this.model.job.runningDeployment) return 'You cannot scale task groups during a deployment'; if (this.model.job.runningDeployment) return 'You cannot scale task groups during a deployment';
return undefined; return undefined;
} }

View file

@ -15,8 +15,8 @@ export default class RunRoute extends Route {
}, },
]; ];
beforeModel() { beforeModel(transition) {
if (this.can.cannot('run job')) { if (this.can.cannot('run job', null, { namespace: transition.to.queryParams.namespace })) {
this.transitionTo('jobs'); this.transitionTo('jobs');
} }
} }

View file

@ -10,7 +10,7 @@
</span> </span>
<div class="pull-right"> <div class="pull-right">
{{#unless this.isCurrent}} {{#unless this.isCurrent}}
{{#if (can "run job")}} {{#if (can "run job" namespace=this.version.job.namespace)}}
<TwoStepButton <TwoStepButton
data-test-revert-to data-test-revert-to
@classes={{hash @classes={{hash

View file

@ -8,24 +8,25 @@
{{#if this.taskGroup.scaling}} {{#if this.taskGroup.scaling}}
<div <div
data-test-scale-controls data-test-scale-controls
class="button-bar is-shadowless is-text bumper-left {{if (or this.runningDeployment (cannot "scale job")) "tooltip multiline"}}" class="button-bar is-shadowless is-text bumper-left {{if (or this.runningDeployment (cannot "scale job" namespace=this.namespace)) "tooltip multiline"}}"
aria-label={{this.tooltipText}}> aria-label={{this.tooltipText}}>
<button <button
data-test-scale="decrement" data-test-scale="decrement"
role="button" role="button"
aria-label="decrement" aria-label="decrement"
class="button is-xsmall is-light" class="button is-xsmall is-light"
disabled={{or this.isMinimum this.runningDeployment (cannot "scale job")}} disabled={{or this.isMinimum this.runningDeployment (cannot "scale job" namespace=this.namespace)}}
onclick={{action "countDown"}} onclick={{action "countDown"}}
type="button"> type="button">
{{x-icon "minus-plain" class="is-text"}} {{x-icon "minus-plain" class="is-text"}}
</button> </button>
<button <button
data-test-scale-controls-increment
data-test-scale="increment" data-test-scale="increment"
role="button" role="button"
aria-label="increment" aria-label="increment"
class="button is-xsmall is-light" class="button is-xsmall is-light"
disabled={{or this.isMaximum this.runningDeployment (cannot "scale job")}} disabled={{or this.isMaximum this.runningDeployment (cannot "scale job" namespace=this.namespace)}}
onclick={{action "countUp"}} onclick={{action "countUp"}}
type="button"> type="button">
{{x-icon "plus-plain" class="is-text"}} {{x-icon "plus-plain" class="is-text"}}

View file

@ -12,8 +12,8 @@
</div> </div>
{{#if (media "isMobile")}} {{#if (media "isMobile")}}
<div class="toolbar-item is-right-aligned"> <div class="toolbar-item is-right-aligned">
{{#if (can "run job")}} {{#if (can "run job" namespace=this.qpNamespace)}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo> <LinkTo @route="jobs.run" @query={{hash namespace=this.qpNamespace}} data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}} {{else}}
<button <button
data-test-run-job data-test-run-job
@ -66,8 +66,8 @@
</div> </div>
{{#if (not (media "isMobile"))}} {{#if (not (media "isMobile"))}}
<div class="toolbar-item is-right-aligned"> <div class="toolbar-item is-right-aligned">
{{#if (can "run job")}} {{#if (can "run job" namespace=this.qpNamespace)}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo> <LinkTo @route="jobs.run" @query={{hash namespace=this.qpNamespace}} data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}} {{else}}
<button <button
data-test-run-job data-test-run-job

View file

@ -17,7 +17,7 @@
@max={{this.model.scaling.max}} @max={{this.model.scaling.max}}
@value={{this.model.count}} @value={{this.model.count}}
@class="is-primary is-small" @class="is-primary is-small"
@disabled={{or this.model.job.runningDeployment (cannot "scale job")}} @disabled={{or this.model.job.runningDeployment (cannot "scale job" namespace=this.model.job.namespace.name)}}
@onChange={{action "scaleTaskGroup"}}> @onChange={{action "scaleTaskGroup"}}>
Count Count
</StepperInput> </StepperInput>

View file

@ -253,4 +253,72 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
0 0
); );
}); });
test('when the dynamic autoscaler is applied, you can scale a task within the job detail page', async function(assert) {
const SCALE_AND_WRITE_NAMESPACE = 'scale-and-write-namespace';
const READ_ONLY_NAMESPACE = 'read-only-namespace';
const clientToken = server.create('token');
const namespace = server.create('namespace', { id: SCALE_AND_WRITE_NAMESPACE });
const secondNamespace = server.create('namespace', { id: READ_ONLY_NAMESPACE });
job = server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: SCALE_AND_WRITE_NAMESPACE,
});
const job2 = server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: READ_ONLY_NAMESPACE,
});
const scalingGroup2 = server.create('task-group', {
job: job2,
name: 'scaling',
count: 1,
shallow: true,
withScaling: true,
});
job2.update({ taskGroupIds: [scalingGroup2.id] });
const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: SCALE_AND_WRITE_NAMESPACE,
Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'],
},
{
Name: READ_ONLY_NAMESPACE,
Capabilities: ['list-jobs', 'read-job'],
},
],
},
});
const scalingGroup = server.create('task-group', {
job,
name: 'scaling',
count: 1,
shallow: true,
withScaling: true,
});
job.update({ taskGroupIds: [scalingGroup.id] });
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
await JobDetail.visit({ id: job.id, namespace: namespace.name });
assert.notOk(JobDetail.incrementButton.isDisabled);
await JobDetail.visit({ id: job2.id, namespace: secondNamespace.name });
assert.ok(JobDetail.incrementButton.isDisabled);
});
}); });

View file

@ -106,4 +106,38 @@ module('Acceptance | job run', function(hooks) {
await JobRun.visit(); await JobRun.visit();
assert.equal(currentURL(), '/jobs'); assert.equal(currentURL(), '/jobs');
}); });
test('when using client token user can still go to job page if they have correct permissions', async function(assert) {
const clientTokenWithPolicy = server.create('token');
const newNamespace = 'second-namespace';
server.create('namespace', { id: newNamespace });
server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: newNamespace,
});
const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: newNamespace,
Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'],
},
],
},
});
clientTokenWithPolicy.policyIds = [policy.id];
clientTokenWithPolicy.save();
window.localStorage.nomadTokenSecret = clientTokenWithPolicy.secretId;
await JobRun.visit({ namespace: newNamespace });
assert.equal(currentURL(), `/jobs/run?namespace=${newNamespace}`);
});
}); });

View file

@ -52,7 +52,7 @@ module('Acceptance | job versions', function(hooks) {
test('all versions but the current one have a button to revert to that version', async function(assert) { test('all versions but the current one have a button to revert to that version', async function(assert) {
let versionRowToRevertTo; let versionRowToRevertTo;
Versions.versions.forEach((versionRow) => { Versions.versions.forEach(versionRow => {
if (versionRow.number === job.version) { if (versionRow.number === job.version) {
assert.ok(versionRow.revertToButton.isHidden); assert.ok(versionRow.revertToButton.isHidden);
} else { } else {
@ -67,7 +67,9 @@ module('Acceptance | job versions', function(hooks) {
await versionRowToRevertTo.revertToButton.idle(); await versionRowToRevertTo.revertToButton.idle();
await versionRowToRevertTo.revertToButton.confirm(); await versionRowToRevertTo.revertToButton.confirm();
const revertRequest = this.server.pretender.handledRequests.find(request => request.url.includes('revert')); const revertRequest = this.server.pretender.handledRequests.find(request =>
request.url.includes('revert')
);
assert.equal(revertRequest.url, `/v1/job/${job.id}/revert?namespace=${namespace.id}`); assert.equal(revertRequest.url, `/v1/job/${job.id}/revert?namespace=${namespace.id}`);
@ -81,7 +83,9 @@ module('Acceptance | job versions', function(hooks) {
}); });
test('when reversion fails, the error message from the API is piped through to the alert', async function(assert) { test('when reversion fails, the error message from the API is piped through to the alert', async function(assert) {
const versionRowToRevertTo = Versions.versions.filter(versionRow => versionRow.revertToButton.isPresent)[0]; const versionRowToRevertTo = Versions.versions.filter(
versionRow => versionRow.revertToButton.isPresent
)[0];
if (versionRowToRevertTo) { if (versionRowToRevertTo) {
const message = 'A plaintext error message'; const message = 'A plaintext error message';
@ -104,7 +108,9 @@ module('Acceptance | job versions', function(hooks) {
}); });
test('when reversion has no effect, the error message explains', async function(assert) { test('when reversion has no effect, the error message explains', async function(assert) {
const versionRowToRevertTo = Versions.versions.filter(versionRow => versionRow.revertToButton.isPresent)[0]; const versionRowToRevertTo = Versions.versions.filter(
versionRow => versionRow.revertToButton.isPresent
)[0];
if (versionRowToRevertTo) { if (versionRowToRevertTo) {
// The default Mirage implementation updates the job version as passed in, this does nothing // The default Mirage implementation updates the job version as passed in, this does nothing
@ -116,7 +122,10 @@ module('Acceptance | job versions', function(hooks) {
assert.ok(Layout.inlineError.isShown); assert.ok(Layout.inlineError.isShown);
assert.ok(Layout.inlineError.isWarning); assert.ok(Layout.inlineError.isWarning);
assert.ok(Layout.inlineError.title.includes('Reversion Had No Effect')); assert.ok(Layout.inlineError.title.includes('Reversion Had No Effect'));
assert.equal(Layout.inlineError.message, 'Reverting to an identical older version doesnt produce a new version'); assert.equal(
Layout.inlineError.message,
'Reverting to an identical older version doesnt produce a new version'
);
} else { } else {
assert.expect(0); assert.expect(0);
} }
@ -154,7 +163,9 @@ module('Acceptance | job versions (with client token)', function(hooks) {
}); });
test('reversion buttons are disabled when the token lacks permissions', async function(assert) { test('reversion buttons are disabled when the token lacks permissions', async function(assert) {
const versionRowWithReversion = Versions.versions.filter(versionRow => versionRow.revertToButton.isPresent)[0]; const versionRowWithReversion = Versions.versions.filter(
versionRow => versionRow.revertToButton.isPresent
)[0];
if (versionRowWithReversion) { if (versionRowWithReversion) {
assert.ok(versionRowWithReversion.revertToButtonIsDisabled); assert.ok(versionRowWithReversion.revertToButtonIsDisabled);
@ -164,4 +175,50 @@ module('Acceptance | job versions (with client token)', function(hooks) {
window.localStorage.clear(); window.localStorage.clear();
}); });
test('reversion buttons are available when the client token has permissions', async function(assert) {
const REVERT_NAMESPACE = 'revert-namespace';
window.localStorage.clear();
const clientToken = server.create('token');
server.create('namespace', { id: REVERT_NAMESPACE });
const job = server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: REVERT_NAMESPACE,
});
const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: REVERT_NAMESPACE,
Capabilities: ['submit-job'],
},
],
},
});
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
versions = server.db.jobVersions.where({ jobId: job.id });
await Versions.visit({ id: job.id, namespace: REVERT_NAMESPACE });
const versionRowWithReversion = Versions.versions.filter(
versionRow => versionRow.revertToButton.isPresent
)[0];
if (versionRowWithReversion) {
assert.ok(versionRowWithReversion.revertToButtonIsDisabled);
} else {
assert.expect(0);
}
});
}); });

View file

@ -356,6 +356,42 @@ module('Acceptance | jobs list', function(hooks) {
assert.equal(currentURL(), `/csi/volumes?namespace=${namespace.id}`); assert.equal(currentURL(), `/csi/volumes?namespace=${namespace.id}`);
}); });
test('when the user has a client token that has a namespace with a policy to run a job', async function(assert) {
const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace';
const READ_ONLY_NAMESPACE = 'read-only-namespace';
server.create('namespace', { id: READ_AND_WRITE_NAMESPACE });
server.create('namespace', { id: READ_ONLY_NAMESPACE });
const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: READ_AND_WRITE_NAMESPACE,
Capabilities: ['submit-job'],
},
{
Name: READ_ONLY_NAMESPACE,
Capabilities: ['list-job'],
},
],
},
});
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE });
assert.notOk(JobsList.runJobButton.isDisabled);
await JobsList.visit({ namespace: READ_ONLY_NAMESPACE });
assert.ok(JobsList.runJobButton.isDisabled);
});
pageSizeSelect({ pageSizeSelect({
resourceName: 'job', resourceName: 'job',
pageObject: JobsList, pageObject: JobsList,

View file

@ -82,7 +82,9 @@ module('Acceptance | task group detail', function(hooks) {
test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) { test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) {
const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0); const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0);
const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0); const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0);
const totalMemoryMax = tasks.map(t => t.resources.MemoryMaxMB || t.resources.MemoryMB).reduce(sum, 0); const totalMemoryMax = tasks
.map(t => t.resources.MemoryMaxMB || t.resources.MemoryMB)
.reduce(sum, 0);
const totalDisk = taskGroup.ephemeralDisk.SizeMB; const totalDisk = taskGroup.ephemeralDisk.SizeMB;
await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await TaskGroup.visit({ id: job.id, name: taskGroup.name });
@ -148,6 +150,88 @@ module('Acceptance | task group detail', function(hooks) {
); );
}); });
test('when the user has a client token that has a namespace with a policy to run and scale a job the autoscaler options should be available', async function(assert) {
window.localStorage.clear();
const SCALE_AND_WRITE_NAMESPACE = 'scale-and-write-namespace';
const READ_ONLY_NAMESPACE = 'read-only-namespace';
const clientToken = server.create('token');
server.create('namespace', { id: SCALE_AND_WRITE_NAMESPACE });
const secondNamespace = server.create('namespace', { id: READ_ONLY_NAMESPACE });
job = server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: SCALE_AND_WRITE_NAMESPACE,
});
const scalingGroup = server.create('task-group', {
job,
name: 'scaling',
count: 1,
shallow: true,
withScaling: true,
});
job.update({ taskGroupIds: [scalingGroup.id] });
const job2 = server.create('job', {
groupCount: 0,
createAllocations: false,
shallow: true,
noActiveDeployment: true,
namespaceId: READ_ONLY_NAMESPACE,
});
const scalingGroup2 = server.create('task-group', {
job: job2,
name: 'scaling',
count: 1,
shallow: true,
withScaling: true,
});
job2.update({ taskGroupIds: [scalingGroup2.id] });
const policy = server.create('policy', {
id: 'something',
name: 'something',
rulesJSON: {
Namespaces: [
{
Name: SCALE_AND_WRITE_NAMESPACE,
Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'],
},
{
Name: READ_ONLY_NAMESPACE,
Capabilities: ['list-jobs', 'read-job'],
},
],
},
});
clientToken.policyIds = [policy.id];
clientToken.save();
window.localStorage.nomadTokenSecret = clientToken.secretId;
await TaskGroup.visit({
id: job.id,
name: scalingGroup.name,
namespace: SCALE_AND_WRITE_NAMESPACE,
});
assert.equal(currentURL(), `/jobs/${job.id}/scaling?namespace=${SCALE_AND_WRITE_NAMESPACE}`);
assert.notOk(TaskGroup.countStepper.increment.isDisabled);
await TaskGroup.visit({
id: job2.id,
name: scalingGroup2.name,
namespace: secondNamespace.name,
});
assert.equal(currentURL(), `/jobs/${job2.id}/scaling?namespace=${READ_ONLY_NAMESPACE}`);
assert.ok(TaskGroup.countStepper.increment.isDisabled);
});
test('/jobs/:id/:task-group should list one page of allocations for the task group', async function(assert) { test('/jobs/:id/:task-group should list one page of allocations for the task group', async function(assert) {
server.createList('allocation', TaskGroup.pageSize, { server.createList('allocation', TaskGroup.pageSize, {
jobId: job.id, jobId: job.id,

View file

@ -38,6 +38,11 @@ export default create({
tooltipText: attribute('aria-label'), tooltipText: attribute('aria-label'),
}, },
incrementButton: {
scope: '[data-test-scale-controls-increment]',
isDisabled: property('disabled'),
},
dispatchButton: { dispatchButton: {
scope: '[data-test-dispatch-button]', scope: '[data-test-dispatch-button]',
isDisabled: property('disabled'), isDisabled: property('disabled'),

View file

@ -22,6 +22,7 @@ export default create({
search: fillable('.search-box input'), search: fillable('.search-box input'),
countStepper: stepperInput('[data-test-task-group-count-stepper]'), countStepper: stepperInput('[data-test-task-group-count-stepper]'),
incrementButton: { scope: '[data-test-stepper-increment]' },
tasksCount: text('[data-test-task-group-tasks]'), tasksCount: text('[data-test-task-group-tasks]'),
cpu: text('[data-test-task-group-cpu]'), cpu: text('[data-test-task-group-cpu]'),