diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js index 3b0cc3fa5..2c0e810c6 100644 --- a/ui/app/abilities/abstract.js +++ b/ui/app/abilities/abstract.js @@ -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: // https://learn.hashicorp.com/tutorials/nomad/access-control-policies?in=nomad/access-control#namespace-rules _findMatchingNamespace(policyNamespaces, activeNamespace) { diff --git a/ui/app/abilities/recommendation.js b/ui/app/abilities/recommendation.js index d3cdf6f85..11decdb43 100644 --- a/ui/app/abilities/recommendation.js +++ b/ui/app/abilities/recommendation.js @@ -1,13 +1,21 @@ import AbstractAbility from './abstract'; import { computed } from '@ember/object'; -import { or } from '@ember/object/computed'; +import { and, or } from '@ember/object/computed'; export default class Recommendation extends AbstractAbility { - @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportAcceptingOnAnyNamespace') + @and('dynamicApplicationSizingIsPresent', 'hasPermissions') canAccept; + @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportAcceptingOnAnyNamespace') + hasPermissions; + @computed('capabilitiesForAllNamespaces.[]') get policiesSupportAcceptingOnAnyNamespace() { return this.capabilitiesForAllNamespaces.includes('submit-job'); } + + @computed('features.[]') + get dynamicApplicationSizingIsPresent() { + return this.featureIsPresent('Dynamic Application Sizing'); + } } diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 1e7500924..d89ab9445 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -30,9 +30,14 @@ export default class ApplicationRoute extends Route { .perform() .catch(); + const fetchLicense = this.get('system.fetchLicense') + .perform() + .catch(); + return RSVP.all([ this.get('system.regions'), this.get('system.defaultRegion'), + fetchLicense, fetchSelfTokenAndPolicies, ]).then(promises => { if (!this.get('system.shouldShowRegions')) return promises; diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 7c3c60f4a..61daf1248 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -7,6 +7,7 @@ import classic from 'ember-classic-decorator'; @classic export default class JobRoute extends Route { + @service can; @service store; @service token; @@ -20,14 +21,20 @@ export default class JobRoute extends Route { const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id'); const name = params.job_name; const fullId = JSON.stringify([name, namespace || 'default']); + return this.store .findRecord('job', fullId, { reload: true }) .then(job => { - return RSVP.all([ + const relatedModelsQueries = [ job.get('allocations'), 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)); } diff --git a/ui/app/services/system.js b/ui/app/services/system.js index 359c85f62..7b32bb12d 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -1,10 +1,12 @@ import Service, { inject as service } from '@ember/service'; import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import PromiseObject from '../utils/classes/promise-object'; import PromiseArray from '../utils/classes/promise-array'; import { namespace } from '../adapters/application'; import jsonWithDefault from '../utils/json-with-default'; import classic from 'ember-classic-decorator'; +import { task } from 'ember-concurrency'; @classic export default class SystemService extends Service { @@ -144,4 +146,24 @@ export default class SystemService extends Service { this.set('activeNamespace', null); 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') || []; + } } diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index cafa412a4..d8180f244 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -13,9 +13,11 @@ - {{#each this.job.recommendationSummaries as |summary|}} - - {{/each}} + {{#if (can "accept recommendations")}} + {{#each this.job.recommendationSummaries as |summary|}} + + {{/each}} + {{/if}} diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs index 602d59042..5e0abbea2 100644 --- a/ui/app/templates/components/job-page/system.hbs +++ b/ui/app/templates/components/job-page/system.hbs @@ -13,9 +13,11 @@ - {{#each this.job.recommendationSummaries as |summary|}} - - {{/each}} + {{#if (can "accept recommendations")}} + {{#each this.job.recommendationSummaries as |summary|}} + + {{/each}} + {{/if}} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 9ad6c07c0..eec2126b4 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -410,6 +410,20 @@ export default function() { 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 }) { return this.serialize(clientAllocationStats.find(params.id)); }; diff --git a/ui/mirage/models/feature.js b/ui/mirage/models/feature.js new file mode 100644 index 000000000..ddb04151d --- /dev/null +++ b/ui/mirage/models/feature.js @@ -0,0 +1,3 @@ +import { Model } from 'ember-cli-mirage'; + +export default Model.extend(); diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index d2d624ffd..449ffef95 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -96,7 +96,7 @@ module('Acceptance | job detail (with namespaces)', function(hooks) { setupApplicationTest(hooks); setupMirage(hooks); - let job, clientToken; + let job, managementToken, clientToken; hooks.beforeEach(function() { server.createList('namespace', 2); @@ -112,7 +112,7 @@ module('Acceptance | job detail (with namespaces)', function(hooks) { createRecommendations: true, }); - server.create('token'); + managementToken = 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) { + server.create('feature', { name: 'Dynamic Application Sizing' }); + + window.localStorage.nomadTokenSecret = managementToken.secretId; await JobDetail.visit({ id: job.id, namespace: server.db.namespaces[1].name }); 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); }); + + 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 + ); + }); }); diff --git a/ui/tests/acceptance/optimize-test.js b/ui/tests/acceptance/optimize-test.js index 2cc0b01ec..096f364e9 100644 --- a/ui/tests/acceptance/optimize-test.js +++ b/ui/tests/acceptance/optimize-test.js @@ -28,6 +28,8 @@ module('Acceptance | optimize', function(hooks) { setupMirage(hooks); hooks.beforeEach(async function() { + server.create('feature', { name: 'Dynamic Application Sizing' }); + server.create('node'); server.createList('namespace', 2); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index 36ac55138..59702bc15 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -162,7 +162,8 @@ module('Acceptance | regions (many)', function(hooks) { await PageLayout.gutter.visitClients(); await PageLayout.gutter.visitServers(); const [ - , + , // License request + , // Token/policies request regionsRequest, defaultRegionRequest, ...appRequests diff --git a/ui/tests/unit/abilities/recommendation-test.js b/ui/tests/unit/abilities/recommendation-test.js index a80db7496..3d62fe0d1 100644 --- a/ui/tests/unit/abilities/recommendation-test.js +++ b/ui/tests/unit/abilities/recommendation-test.js @@ -8,48 +8,74 @@ module('Unit | Ability | recommendation', function(hooks) { setupTest(hooks); setupAbility('recommendation')(hooks); - test('it permits accepting recommendations when ACLs are disabled', function(assert) { - const mockToken = Service.extend({ - aclEnabled: false, + module('when the Dynamic Application Sizing feature is present', function(hooks) { + hooks.beforeEach(function() { + 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) { - const mockSystem = Service.extend({ - aclEnabled: true, - activeNamespace: { - name: 'anotherNamespace', - }, + module('when the Dynamic Application Sizing feature is not present', function(hooks) { + hooks.beforeEach(function() { + const mockSystem = Service.extend({ + features: [], + }); + + this.owner.register('service:system', mockSystem); }); - const mockToken = Service.extend({ - aclEnabled: true, - selfToken: { type: 'client' }, - selfTokenPolicies: [ - { - rulesJSON: { - Namespaces: [ - { - Name: 'aNamespace', - Capabilities: [], - }, - { - Name: 'bNamespace', - Capabilities: ['submit-job'], - }, - ], - }, - }, - ], + test('it does not permit accepting recommendations regardless of ACL status', function(assert) { + const mockToken = Service.extend({ + aclEnabled: false, + }); + + this.owner.register('service:token', mockToken); + + assert.notOk(this.ability.canAccept); }); - - this.owner.register('service:system', mockSystem); - this.owner.register('service:token', mockToken); - - assert.ok(this.ability.canAccept); }); });