Fix job detail crash when recommendations off (#9269)
Without this, visiting any job detail page on Nomad OSS would crash with an error like this: Error: Ember Data Request GET /v1/recommendations?job=ping%F0%9F%A5%B3&namespace=default returned a 404 Payload (text/xml) The problem was twofold. 1. The recommendation ability didn’t include anything about checking whether the feature was present. This adds a request to /v1/operator/license on application load to determine which features are present and store them in the system service. The ability now looks for 'Dynamic Application Sizing' in that feature list. 2. Second, I didn’t check permissions at all in the job-fetching or job detail templates.
This commit is contained in:
parent
36ac4ba840
commit
8b5b2116ec
|
@ -53,6 +53,15 @@ export default class Abstract extends Ability {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed('system.features.[]')
|
||||||
|
get features() {
|
||||||
|
return this.system.features;
|
||||||
|
}
|
||||||
|
|
||||||
|
featureIsPresent(featureName) {
|
||||||
|
return this.features.includes(featureName);
|
||||||
|
}
|
||||||
|
|
||||||
// Chooses the closest namespace as described at the bottom here:
|
// Chooses the closest namespace as described at the bottom here:
|
||||||
// https://learn.hashicorp.com/tutorials/nomad/access-control-policies?in=nomad/access-control#namespace-rules
|
// https://learn.hashicorp.com/tutorials/nomad/access-control-policies?in=nomad/access-control#namespace-rules
|
||||||
_findMatchingNamespace(policyNamespaces, activeNamespace) {
|
_findMatchingNamespace(policyNamespaces, activeNamespace) {
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
import AbstractAbility from './abstract';
|
import AbstractAbility from './abstract';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { or } from '@ember/object/computed';
|
import { and, or } from '@ember/object/computed';
|
||||||
|
|
||||||
export default class Recommendation extends AbstractAbility {
|
export default class Recommendation extends AbstractAbility {
|
||||||
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportAcceptingOnAnyNamespace')
|
@and('dynamicApplicationSizingIsPresent', 'hasPermissions')
|
||||||
canAccept;
|
canAccept;
|
||||||
|
|
||||||
|
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportAcceptingOnAnyNamespace')
|
||||||
|
hasPermissions;
|
||||||
|
|
||||||
@computed('capabilitiesForAllNamespaces.[]')
|
@computed('capabilitiesForAllNamespaces.[]')
|
||||||
get policiesSupportAcceptingOnAnyNamespace() {
|
get policiesSupportAcceptingOnAnyNamespace() {
|
||||||
return this.capabilitiesForAllNamespaces.includes('submit-job');
|
return this.capabilitiesForAllNamespaces.includes('submit-job');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed('features.[]')
|
||||||
|
get dynamicApplicationSizingIsPresent() {
|
||||||
|
return this.featureIsPresent('Dynamic Application Sizing');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,9 +30,14 @@ export default class ApplicationRoute extends Route {
|
||||||
.perform()
|
.perform()
|
||||||
.catch();
|
.catch();
|
||||||
|
|
||||||
|
const fetchLicense = this.get('system.fetchLicense')
|
||||||
|
.perform()
|
||||||
|
.catch();
|
||||||
|
|
||||||
return RSVP.all([
|
return RSVP.all([
|
||||||
this.get('system.regions'),
|
this.get('system.regions'),
|
||||||
this.get('system.defaultRegion'),
|
this.get('system.defaultRegion'),
|
||||||
|
fetchLicense,
|
||||||
fetchSelfTokenAndPolicies,
|
fetchSelfTokenAndPolicies,
|
||||||
]).then(promises => {
|
]).then(promises => {
|
||||||
if (!this.get('system.shouldShowRegions')) return promises;
|
if (!this.get('system.shouldShowRegions')) return promises;
|
||||||
|
|
|
@ -7,6 +7,7 @@ import classic from 'ember-classic-decorator';
|
||||||
|
|
||||||
@classic
|
@classic
|
||||||
export default class JobRoute extends Route {
|
export default class JobRoute extends Route {
|
||||||
|
@service can;
|
||||||
@service store;
|
@service store;
|
||||||
@service token;
|
@service token;
|
||||||
|
|
||||||
|
@ -20,14 +21,20 @@ export default class JobRoute extends Route {
|
||||||
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
|
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
|
||||||
const name = params.job_name;
|
const name = params.job_name;
|
||||||
const fullId = JSON.stringify([name, namespace || 'default']);
|
const fullId = JSON.stringify([name, namespace || 'default']);
|
||||||
|
|
||||||
return this.store
|
return this.store
|
||||||
.findRecord('job', fullId, { reload: true })
|
.findRecord('job', fullId, { reload: true })
|
||||||
.then(job => {
|
.then(job => {
|
||||||
return RSVP.all([
|
const relatedModelsQueries = [
|
||||||
job.get('allocations'),
|
job.get('allocations'),
|
||||||
job.get('evaluations'),
|
job.get('evaluations'),
|
||||||
job.get('recommendationSummaries'),
|
];
|
||||||
]).then(() => job);
|
|
||||||
|
if (this.can.can('accept recommendation')) {
|
||||||
|
relatedModelsQueries.push(job.get('recommendationSummaries'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RSVP.all(relatedModelsQueries).then(() => job);
|
||||||
})
|
})
|
||||||
.catch(notifyError(this));
|
.catch(notifyError(this));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import Service, { inject as service } from '@ember/service';
|
import Service, { inject as service } from '@ember/service';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
|
import { alias } from '@ember/object/computed';
|
||||||
import PromiseObject from '../utils/classes/promise-object';
|
import PromiseObject from '../utils/classes/promise-object';
|
||||||
import PromiseArray from '../utils/classes/promise-array';
|
import PromiseArray from '../utils/classes/promise-array';
|
||||||
import { namespace } from '../adapters/application';
|
import { namespace } from '../adapters/application';
|
||||||
import jsonWithDefault from '../utils/json-with-default';
|
import jsonWithDefault from '../utils/json-with-default';
|
||||||
import classic from 'ember-classic-decorator';
|
import classic from 'ember-classic-decorator';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
@classic
|
@classic
|
||||||
export default class SystemService extends Service {
|
export default class SystemService extends Service {
|
||||||
|
@ -144,4 +146,24 @@ export default class SystemService extends Service {
|
||||||
this.set('activeNamespace', null);
|
this.set('activeNamespace', null);
|
||||||
this.notifyPropertyChange('namespaces');
|
this.notifyPropertyChange('namespaces');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@task(function*() {
|
||||||
|
const emptyLicense = { License: { Features: [] } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
return yield this.token
|
||||||
|
.authorizedRawRequest(`/${namespace}/operator/license`)
|
||||||
|
.then(jsonWithDefault(emptyLicense));
|
||||||
|
} catch (e) {
|
||||||
|
return emptyLicense;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fetchLicense;
|
||||||
|
|
||||||
|
@alias('fetchLicense.lastSuccessful.value') license;
|
||||||
|
|
||||||
|
@computed('license.License.Features.[]')
|
||||||
|
get features() {
|
||||||
|
return this.get('license.License.Features') || [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,9 +13,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#each this.job.recommendationSummaries as |summary|}}
|
{{#if (can "accept recommendations")}}
|
||||||
<Das::RecommendationAccordion @summary={{summary}} />
|
{{#each this.job.recommendationSummaries as |summary|}}
|
||||||
{{/each}}
|
<Das::RecommendationAccordion @summary={{summary}} />
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<JobPage::Parts::Summary @job={{this.job}} />
|
<JobPage::Parts::Summary @job={{this.job}} />
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#each this.job.recommendationSummaries as |summary|}}
|
{{#if (can "accept recommendations")}}
|
||||||
<Das::RecommendationAccordion @summary={{summary}} />
|
{{#each this.job.recommendationSummaries as |summary|}}
|
||||||
{{/each}}
|
<Das::RecommendationAccordion @summary={{summary}} />
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<JobPage::Parts::Summary @job={{this.job}} />
|
<JobPage::Parts::Summary @job={{this.job}} />
|
||||||
|
|
||||||
|
|
|
@ -410,6 +410,20 @@ export default function() {
|
||||||
return this.serialize(regions.all());
|
return this.serialize(regions.all());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.get('/operator/license', function({ features }) {
|
||||||
|
const records = features.all();
|
||||||
|
|
||||||
|
if (records.length) {
|
||||||
|
return {
|
||||||
|
License: {
|
||||||
|
Features: records.models.mapBy('name'),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(501, {}, null);
|
||||||
|
});
|
||||||
|
|
||||||
const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) {
|
const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) {
|
||||||
return this.serialize(clientAllocationStats.find(params.id));
|
return this.serialize(clientAllocationStats.find(params.id));
|
||||||
};
|
};
|
||||||
|
|
3
ui/mirage/models/feature.js
Normal file
3
ui/mirage/models/feature.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { Model } from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Model.extend();
|
|
@ -96,7 +96,7 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
|
||||||
setupApplicationTest(hooks);
|
setupApplicationTest(hooks);
|
||||||
setupMirage(hooks);
|
setupMirage(hooks);
|
||||||
|
|
||||||
let job, clientToken;
|
let job, managementToken, clientToken;
|
||||||
|
|
||||||
hooks.beforeEach(function() {
|
hooks.beforeEach(function() {
|
||||||
server.createList('namespace', 2);
|
server.createList('namespace', 2);
|
||||||
|
@ -112,7 +112,7 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
|
||||||
createRecommendations: true,
|
createRecommendations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
server.create('token');
|
managementToken = server.create('token');
|
||||||
clientToken = server.create('token');
|
clientToken = server.create('token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -212,6 +212,9 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resource recommendations show when they exist and can be expanded, collapsed, and processed', async function(assert) {
|
test('resource recommendations show when they exist and can be expanded, collapsed, and processed', async function(assert) {
|
||||||
|
server.create('feature', { name: 'Dynamic Application Sizing' });
|
||||||
|
|
||||||
|
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||||
await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name });
|
await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name });
|
||||||
|
|
||||||
assert.equal(JobDetail.recommendations.length, job.taskGroups.length);
|
assert.equal(JobDetail.recommendations.length, job.taskGroups.length);
|
||||||
|
@ -247,4 +250,18 @@ module('Acceptance | job detail (with namespaces)', function(hooks) {
|
||||||
|
|
||||||
assert.equal(JobDetail.recommendations.length, job.taskGroups.length - 1);
|
assert.equal(JobDetail.recommendations.length, job.taskGroups.length - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resource recommendations are not fetched when the feature doesn’t exist', async function(assert) {
|
||||||
|
window.localStorage.nomadTokenSecret = managementToken.secretId;
|
||||||
|
await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name });
|
||||||
|
|
||||||
|
assert.equal(JobDetail.recommendations.length, 0);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
server.pretender.handledRequests
|
||||||
|
.filter(request => request.url.includes('recommendations'))
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -28,6 +28,8 @@ module('Acceptance | optimize', function(hooks) {
|
||||||
setupMirage(hooks);
|
setupMirage(hooks);
|
||||||
|
|
||||||
hooks.beforeEach(async function() {
|
hooks.beforeEach(async function() {
|
||||||
|
server.create('feature', { name: 'Dynamic Application Sizing' });
|
||||||
|
|
||||||
server.create('node');
|
server.create('node');
|
||||||
|
|
||||||
server.createList('namespace', 2);
|
server.createList('namespace', 2);
|
||||||
|
|
|
@ -162,7 +162,8 @@ module('Acceptance | regions (many)', function(hooks) {
|
||||||
await PageLayout.gutter.visitClients();
|
await PageLayout.gutter.visitClients();
|
||||||
await PageLayout.gutter.visitServers();
|
await PageLayout.gutter.visitServers();
|
||||||
const [
|
const [
|
||||||
,
|
, // License request
|
||||||
|
, // Token/policies request
|
||||||
regionsRequest,
|
regionsRequest,
|
||||||
defaultRegionRequest,
|
defaultRegionRequest,
|
||||||
...appRequests
|
...appRequests
|
||||||
|
|
|
@ -8,48 +8,74 @@ module('Unit | Ability | recommendation', function(hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
setupAbility('recommendation')(hooks);
|
setupAbility('recommendation')(hooks);
|
||||||
|
|
||||||
test('it permits accepting recommendations when ACLs are disabled', function(assert) {
|
module('when the Dynamic Application Sizing feature is present', function(hooks) {
|
||||||
const mockToken = Service.extend({
|
hooks.beforeEach(function() {
|
||||||
aclEnabled: false,
|
const mockSystem = Service.extend({
|
||||||
|
features: ['Dynamic Application Sizing'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.owner.register('service:system', mockSystem);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.owner.register('service:token', mockToken);
|
test('it permits accepting recommendations when ACLs are disabled', function(assert) {
|
||||||
|
const mockToken = Service.extend({
|
||||||
|
aclEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
assert.ok(this.ability.canAccept);
|
this.owner.register('service:token', mockToken);
|
||||||
|
|
||||||
|
assert.ok(this.ability.canAccept);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it permits accepting recommendations for client tokens where any namespace has submit-job capabilities', function(assert) {
|
||||||
|
this.owner.lookup('service:system').set('activeNamespace', {
|
||||||
|
name: 'anotherNamespace',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockToken = Service.extend({
|
||||||
|
aclEnabled: true,
|
||||||
|
selfToken: { type: 'client' },
|
||||||
|
selfTokenPolicies: [
|
||||||
|
{
|
||||||
|
rulesJSON: {
|
||||||
|
Namespaces: [
|
||||||
|
{
|
||||||
|
Name: 'aNamespace',
|
||||||
|
Capabilities: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: 'bNamespace',
|
||||||
|
Capabilities: ['submit-job'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.owner.register('service:token', mockToken);
|
||||||
|
|
||||||
|
assert.ok(this.ability.canAccept);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it permits accepting recommendations for client tokens where any namespace has submit-job capabilities', function(assert) {
|
module('when the Dynamic Application Sizing feature is not present', function(hooks) {
|
||||||
const mockSystem = Service.extend({
|
hooks.beforeEach(function() {
|
||||||
aclEnabled: true,
|
const mockSystem = Service.extend({
|
||||||
activeNamespace: {
|
features: [],
|
||||||
name: 'anotherNamespace',
|
});
|
||||||
},
|
|
||||||
|
this.owner.register('service:system', mockSystem);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockToken = Service.extend({
|
test('it does not permit accepting recommendations regardless of ACL status', function(assert) {
|
||||||
aclEnabled: true,
|
const mockToken = Service.extend({
|
||||||
selfToken: { type: 'client' },
|
aclEnabled: false,
|
||||||
selfTokenPolicies: [
|
});
|
||||||
{
|
|
||||||
rulesJSON: {
|
this.owner.register('service:token', mockToken);
|
||||||
Namespaces: [
|
|
||||||
{
|
assert.notOk(this.ability.canAccept);
|
||||||
Name: 'aNamespace',
|
|
||||||
Capabilities: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: 'bNamespace',
|
|
||||||
Capabilities: ['submit-job'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.owner.register('service:system', mockSystem);
|
|
||||||
this.owner.register('service:token', mockToken);
|
|
||||||
|
|
||||||
assert.ok(this.ability.canAccept);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue