diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js index 1eb36517b..66a42bcdf 100644 --- a/ui/app/abilities/abstract.js +++ b/ui/app/abilities/abstract.js @@ -80,6 +80,7 @@ export default class Abstract extends Ability { } featureIsPresent(featureName) { + // See the hashicorp/nomad-licensing repo for canonical feature names return this.features.includes(featureName); } diff --git a/ui/app/abilities/namespace.js b/ui/app/abilities/namespace.js new file mode 100644 index 000000000..66e8aa69a --- /dev/null +++ b/ui/app/abilities/namespace.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AbstractAbility from './abstract'; +import { alias } from '@ember/object/computed'; + +export default class Namespace extends AbstractAbility { + @alias('selfTokenIsManagement') canList; + @alias('selfTokenIsManagement') canUpdate; + @alias('selfTokenIsManagement') canWrite; + @alias('selfTokenIsManagement') canDestroy; +} diff --git a/ui/app/abilities/node-pool.js b/ui/app/abilities/node-pool.js new file mode 100644 index 000000000..93457ac69 --- /dev/null +++ b/ui/app/abilities/node-pool.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AbstractAbility from './abstract'; +import { alias, and } from '@ember/object/computed'; +import { computed } from '@ember/object'; + +export default class NodePool extends AbstractAbility { + @alias('hasFeatureAndManagement') canConfigureInNamespace; + + @and('nodePoolGovernanceIsPresent', 'selfTokenIsManagement') + hasFeatureAndManagement; + + @computed('features.[]') + get nodePoolGovernanceIsPresent() { + return this.featureIsPresent('Node Pools Governance'); + } +} diff --git a/ui/app/abilities/quota.js b/ui/app/abilities/quota.js new file mode 100644 index 000000000..8db611665 --- /dev/null +++ b/ui/app/abilities/quota.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AbstractAbility from './abstract'; +import { alias, and } from '@ember/object/computed'; +import { computed } from '@ember/object'; + +export default class Quota extends AbstractAbility { + @alias('hasFeatureAndManagement') canConfigureInNamespace; + + @and('quotasIsPresent', 'selfTokenIsManagement') + hasFeatureAndManagement; + + @computed('features.[]') + get quotasIsPresent() { + return this.featureIsPresent('Resource Quotas'); + } +} diff --git a/ui/app/adapters/namespace.js b/ui/app/adapters/namespace.js index 880b4a37d..255fdb3e3 100644 --- a/ui/app/adapters/namespace.js +++ b/ui/app/adapters/namespace.js @@ -17,4 +17,12 @@ export default class NamespaceAdapter extends Watchable { } }); } + + urlForCreateRecord(_modelName, model) { + return this.urlForUpdateRecord(model.attr('name'), 'namespace'); + } + + urlForDeleteRecord(id) { + return this.urlForUpdateRecord(id, 'namespace'); + } } diff --git a/ui/app/components/namespace-editor.hbs b/ui/app/components/namespace-editor.hbs new file mode 100644 index 000000000..afcfa3144 --- /dev/null +++ b/ui/app/components/namespace-editor.hbs @@ -0,0 +1,58 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ {{#if this.namespace.isNew }} + + Name + + {{/if}} + +
+
+ Definition +
+
+
+
+ {{#if this.JSONError}} +

+ {{this.JSONError}} +

+ {{/if}} +
+
+
+ +
+ {{#if (can "update namespace")}} + + {{/if}} +
+ \ No newline at end of file diff --git a/ui/app/components/namespace-editor.js b/ui/app/components/namespace-editor.js new file mode 100644 index 000000000..fd2e84cc0 --- /dev/null +++ b/ui/app/components/namespace-editor.js @@ -0,0 +1,157 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; + +export default class NamespaceEditorComponent extends Component { + @service notifications; + @service router; + @service store; + @service can; + + @alias('args.namespace') namespace; + + @tracked JSONError = null; + @tracked definitionString = this.definitionStringFromNamespace( + this.args.namespace + ); + + @action updateNamespaceName({ target: { value } }) { + this.namespace.set('name', value); + } + + @action updateNamespaceDefinition(value) { + this.JSONError = null; + this.definitionString = value; + + try { + JSON.parse(this.definitionString); + } catch (error) { + this.JSONError = 'Invalid JSON'; + } + } + + @action async save(e) { + if (e instanceof Event) { + e.preventDefault(); // code-mirror "command+enter" submits the form, but doesnt have a preventDefault() + } + try { + this.deserializeDefinitionJson(JSON.parse(this.definitionString)); + + const nameRegex = '^[a-zA-Z0-9-]{1,128}$'; + if (!this.namespace.name?.match(nameRegex)) { + throw new Error( + `Namespace name must be 1-128 characters long and can only contain letters, numbers, and dashes.` + ); + } + + const shouldRedirectAfterSave = this.namespace.isNew; + + if ( + this.namespace.isNew && + this.store + .peekAll('namespace') + .filter((namespace) => namespace !== this.namespace) + .findBy('name', this.namespace.name) + ) { + throw new Error( + `A namespace with name ${this.namespace.name} already exists.` + ); + } + + this.namespace.set('id', this.namespace.name); + await this.namespace.save(); + + this.notifications.add({ + title: 'Namespace Saved', + color: 'success', + }); + + if (shouldRedirectAfterSave) { + this.router.transitionTo( + 'access-control.namespaces.acl-namespace', + this.namespace.name + ); + } + } catch (err) { + let title = `Error ${ + this.namespace.isNew ? 'creating' : 'updating' + } Namespace ${this.namespace.name}`; + + let message = err.errors?.length + ? messageFromAdapterError(err) + : err.message || 'Unknown Error'; + + this.notifications.add({ + title, + message, + color: 'critical', + sticky: true, + }); + } + } + + definitionStringFromNamespace(namespace) { + let definitionHash = {}; + definitionHash['Description'] = namespace.description; + definitionHash['Capabilities'] = namespace.capabilities; + definitionHash['Meta'] = namespace.meta; + + if (this.can.can('configure-in-namespace node-pool')) { + definitionHash['NodePoolConfiguration'] = namespace.nodePoolConfiguration; + } + + if (this.can.can('configure-in-namespace quota')) { + definitionHash['Quota'] = namespace.quota; + } + + return JSON.stringify(definitionHash, null, 4); + } + + deserializeDefinitionJson(definitionHash) { + this.namespace.set('description', definitionHash['Description']); + this.namespace.set('meta', definitionHash['Meta']); + + let capabilities = this.store.createFragment( + 'ns-capabilities', + definitionHash['Capabilities'] + ); + this.namespace.set('capabilities', capabilities); + + if (this.can.can('configure-in-namespace node-pool')) { + let npConfig = definitionHash['NodePoolConfiguration'] || {}; + this.store.create; + + // If we don't manually set this to null, removing + // the keys wont update the data framgment, which we want + if (!('Allowed' in npConfig)) { + npConfig['Allowed'] = null; + } + + if (!('Disallowed' in npConfig)) { + npConfig['Disallowed'] = null; + } + + // Create node pool config fragment + let nodePoolConfiguration = this.store.createFragment( + 'ns-node-pool-configuration', + npConfig + ); + + this.namespace.set('nodePoolConfiguration', nodePoolConfiguration); + } + + if (this.can.can('configure-in-namespace quota')) { + this.namespace.set('quota', definitionHash['Quota']); + } + } +} diff --git a/ui/app/components/policy-editor.js b/ui/app/components/policy-editor.js index 66761e9a4..2f6c5ac90 100644 --- a/ui/app/components/policy-editor.js +++ b/ui/app/components/policy-editor.js @@ -7,6 +7,7 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { alias } from '@ember/object/computed'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; export default class PolicyEditorComponent extends Component { @service notifications; @@ -63,10 +64,14 @@ export default class PolicyEditorComponent extends Component { this.policy.id ); } - } catch (error) { + } catch (err) { + let message = err.errors?.length + ? messageFromAdapterError(err) + : err.message || 'Unknown Error'; + this.notifications.add({ title: `Error creating Policy ${this.policy.name}`, - message: error, + message, color: 'critical', sticky: true, }); diff --git a/ui/app/components/role-editor.js b/ui/app/components/role-editor.js index c80a67ca2..9094700cb 100644 --- a/ui/app/components/role-editor.js +++ b/ui/app/components/role-editor.js @@ -10,6 +10,7 @@ import { inject as service } from '@ember/service'; import { alias } from '@ember/object/computed'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; export default class RoleEditorComponent extends Component { @service notifications; @@ -74,10 +75,14 @@ export default class RoleEditorComponent extends Component { if (shouldRedirectAfterSave) { this.router.transitionTo('access-control.roles.role', this.role.id); } - } catch (error) { + } catch (err) { + let message = err.errors?.length + ? messageFromAdapterError(err) + : err.message || 'Unknown Error'; + this.notifications.add({ title: `Error creating Role ${this.role.name}`, - message: error, + message, color: 'critical', sticky: true, }); diff --git a/ui/app/components/token-editor.js b/ui/app/components/token-editor.js index 5c5fb86b9..7856fbfe1 100644 --- a/ui/app/components/token-editor.js +++ b/ui/app/components/token-editor.js @@ -9,6 +9,7 @@ import { inject as service } from '@ember/service'; import { alias } from '@ember/object/computed'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; export default class TokenEditorComponent extends Component { @service notifications; @@ -108,10 +109,14 @@ export default class TokenEditorComponent extends Component { this.activeToken.id ); } - } catch (error) { + } catch (err) { + let message = err.errors?.length + ? messageFromAdapterError(err) + : err.message; + this.notifications.add({ title: `Error creating Token ${this.activeToken.name}`, - message: error, + message, color: 'critical', sticky: true, }); diff --git a/ui/app/controllers/access-control/namespaces/acl-namespace.js b/ui/app/controllers/access-control/namespaces/acl-namespace.js new file mode 100644 index 000000000..b452b4d35 --- /dev/null +++ b/ui/app/controllers/access-control/namespaces/acl-namespace.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import rollbackWithoutChangedAttrs from 'nomad-ui/utils/rollback-without-changed-attrs'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; + +export default class AccessControlNamespacesAclNamespaceController extends Controller { + @service notifications; + @service router; + @service store; + + @task(function* () { + try { + yield this.model.destroyRecord(); + this.notifications.add({ + title: 'Namespace Deleted', + color: 'success', + type: `success`, + destroyOnClick: false, + }); + this.router.transitionTo('access-control.namespaces'); + } catch (err) { + // A failed delete resulted in errors when you then navigated away and back + // to the show page rollbackWithoutChangedAttrs fixes it, but there might + // be a more idiomatic way + rollbackWithoutChangedAttrs(this.model); + + let message = err.errors?.length + ? messageFromAdapterError(err) + : err.message || 'Unknown Error'; + + this.notifications.add({ + title: `Error deleting Namespace ${this.model.name}`, + message, + color: 'critical', + sticky: true, + }); + } + }) + deleteNamespace; +} diff --git a/ui/app/controllers/access-control/namespaces/index.js b/ui/app/controllers/access-control/namespaces/index.js new file mode 100644 index 000000000..3a78ae297 --- /dev/null +++ b/ui/app/controllers/access-control/namespaces/index.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class AccessControlNamespacesIndexController extends Controller { + @service router; + @service notifications; + @service can; + + @action openNamespace(namespace) { + this.router.transitionTo( + 'access-control.namespaces.acl-namespace', + namespace.name + ); + } + + @action goToNewNamespace() { + this.router.transitionTo('access-control.namespaces.new'); + } + + get columns() { + return [ + { + key: 'name', + label: 'Name', + isSortable: true, + }, + { + key: 'description', + label: 'Description', + }, + ]; + } +} diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 86ca64abe..a095e5154 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -67,6 +67,8 @@ export default class IndexController extends Controller.extend( }, ]; + qpNamespace = '*'; + currentPage = 1; @readOnly('userSettings.pageSize') pageSize; @@ -194,7 +196,10 @@ export default class IndexController extends Controller.extend( }); // Unset the namespace selection if it was server-side deleted - if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { + if ( + this.qpNamespace && + !availableNamespaces.mapBy('key').includes(this.qpNamespace) + ) { scheduleOnce('actions', () => { // eslint-disable-next-line ember/no-side-effects this.set('qpNamespace', '*'); diff --git a/ui/app/initializers/custom-inflector-rules.js b/ui/app/initializers/custom-inflector-rules.js new file mode 100644 index 000000000..4ad2f94e4 --- /dev/null +++ b/ui/app/initializers/custom-inflector-rules.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Inflector from 'ember-inflector'; + +export function initialize() { + const inflector = Inflector.inflector; + + // Tell the inflector that the plural of "quota" is "quotas" + inflector.irregular('quota', 'quotas'); +} + +export default { + name: 'custom-inflector-rules', + initialize, +}; diff --git a/ui/app/models/namespace.js b/ui/app/models/namespace.js index 2709a442c..2c8d29994 100644 --- a/ui/app/models/namespace.js +++ b/ui/app/models/namespace.js @@ -3,12 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { readOnly } from '@ember/object/computed'; import Model from '@ember-data/model'; import { attr } from '@ember-data/model'; +import { fragment } from 'ember-data-model-fragments/attributes'; export default class Namespace extends Model { - @readOnly('id') name; + @attr('string') name; @attr('string') hash; @attr('string') description; + @attr('string') quota; + @attr() meta; + @fragment('ns-capabilities') capabilities; + @fragment('ns-node-pool-configuration') nodePoolConfiguration; } diff --git a/ui/app/models/ns-capabilities.js b/ui/app/models/ns-capabilities.js new file mode 100644 index 000000000..10038f3ff --- /dev/null +++ b/ui/app/models/ns-capabilities.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Fragment from 'ember-data-model-fragments/fragment'; +import { array } from 'ember-data-model-fragments/attributes'; + +export default class NamespaceCapabilities extends Fragment { + @array('string') DisabledTaskDrivers; + @array('string') EnabledTaskDrivers; +} diff --git a/ui/app/models/ns-node-pool-configuration.js b/ui/app/models/ns-node-pool-configuration.js new file mode 100644 index 000000000..1a6bbd57b --- /dev/null +++ b/ui/app/models/ns-node-pool-configuration.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { attr } from '@ember-data/model'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { array } from 'ember-data-model-fragments/attributes'; + +export default class NamespaceNodePoolConfiguration extends Fragment { + @attr('string') Default; + @array('string') Allowed; + @array('string') Disallowed; +} diff --git a/ui/app/router.js b/ui/app/router.js index cb3d93b0d..0d78de67f 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -130,6 +130,14 @@ Router.map(function () { path: '/:id', }); }); + this.route('namespaces', function () { + this.route('new'); + // Note, this needs the "acl-" portion due to + // "namespace" being a magic string in Ember + this.route('acl-namespace', { + path: '/:name', + }); + }); }); // Mirage-only route for testing OIDC flow if (config['ember-cli-mirage']) { diff --git a/ui/app/routes/access-control.js b/ui/app/routes/access-control.js index 6980bdadc..e824871bd 100644 --- a/ui/app/routes/access-control.js +++ b/ui/app/routes/access-control.js @@ -21,7 +21,8 @@ export default class AccessControlRoute extends Route.extend( if ( this.can.cannot('list policies') || this.can.cannot('list roles') || - this.can.cannot('list tokens') + this.can.cannot('list tokens') || + this.can.cannot('list namespaces') ) { this.router.transitionTo('/jobs'); } @@ -33,6 +34,7 @@ export default class AccessControlRoute extends Route.extend( policies: this.store.findAll('policy', { reload: true }), roles: this.store.findAll('role', { reload: true }), tokens: this.store.findAll('token', { reload: true }), + namespaces: this.store.findAll('namespace', { reload: true }), }); } diff --git a/ui/app/routes/access-control/namespaces/acl-namespace.js b/ui/app/routes/access-control/namespaces/acl-namespace.js new file mode 100644 index 000000000..73f7ad855 --- /dev/null +++ b/ui/app/routes/access-control/namespaces/acl-namespace.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Route from '@ember/routing/route'; +import withForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { inject as service } from '@ember/service'; + +export default class AccessControlNamespacesAclNamespaceRoute extends Route.extend( + withForbiddenState, + WithModelErrorHandling +) { + @service store; + + async model(params) { + return await this.store.findRecord( + 'namespace', + decodeURIComponent(params.name), + { + reload: true, + } + ); + } +} diff --git a/ui/app/routes/access-control/namespaces/new.js b/ui/app/routes/access-control/namespaces/new.js new file mode 100644 index 000000000..b82610b5e --- /dev/null +++ b/ui/app/routes/access-control/namespaces/new.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class AccessControlNamespacesNewRoute extends Route { + @service can; + @service router; + + beforeModel() { + if (this.can.cannot('write namespace')) { + this.router.transitionTo('/access-control/namespaces'); + } + } + + async model() { + let defaultMeta = {}; + + let defaultNodePoolConfig = null; + if (this.can.can('configure-in-namespace node-pool')) { + defaultNodePoolConfig = this.store.createFragment( + 'ns-node-pool-configuration', + { + Default: 'default', + Allowed: [], + Disallowed: null, + } + ); + } + + let defaultCapabilities = this.store.createFragment('ns-capabilities', { + DisabledTaskDrivers: ['raw_exec'], + }); + + return await this.store.createRecord('namespace', { + name: '', + description: '', + capabilities: defaultCapabilities, + meta: defaultMeta, + quota: '', + nodePoolConfiguration: defaultNodePoolConfig, + }); + } + + resetController(controller, isExiting) { + if (isExiting) { + // If user didn't save, delete the freshly created model + if (controller.model.isNew) { + controller.model.capabilities?.unloadRecord(); + controller.model.nodePoolConfiguration?.unloadRecord(); + controller.model.unloadRecord(); + } + } + } +} diff --git a/ui/app/serializers/namespace.js b/ui/app/serializers/namespace.js index 4524a0851..7a092867b 100644 --- a/ui/app/serializers/namespace.js +++ b/ui/app/serializers/namespace.js @@ -7,6 +7,17 @@ import ApplicationSerializer from './application'; import classic from 'ember-classic-decorator'; @classic -export default class Namespace extends ApplicationSerializer { +export default class NamespaceSerializer extends ApplicationSerializer { primaryKey = 'Name'; + + normalize(typeHash, hash) { + hash.ID = hash.Name; + return super.normalize(typeHash, hash); + } + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + hash.ID = hash.Name; + return hash; + } } diff --git a/ui/app/styles/components/access-control.scss b/ui/app/styles/components/access-control.scss index 99628c78b..4f5b99242 100644 --- a/ui/app/styles/components/access-control.scss +++ b/ui/app/styles/components/access-control.scss @@ -85,6 +85,26 @@ max-height: 600px; overflow: auto; } + + .namespace-editor-wrapper { + padding: 1rem 0; + &.error { + .CodeMirror { + box-shadow: 0 0 0 3px $red; + } + .help { + padding: 1rem 0; + font-size: 1rem; + } + } + } +} + +.namespace-editor { + .boxed-section { + padding: 0; + margin: 0; + } } .acl-explainer { diff --git a/ui/app/styles/components/codemirror.scss b/ui/app/styles/components/codemirror.scss index c39c99bb6..2541c98b2 100644 --- a/ui/app/styles/components/codemirror.scss +++ b/ui/app/styles/components/codemirror.scss @@ -13,6 +13,12 @@ $dark-bright: lighten($dark, 15%); min-height: 500px; } +.namespace-editor { + .CodeMirror-scroll { + min-height: 204px; + } +} + .cm-s-hashi, .cm-s-hashi-read-only { &.CodeMirror { diff --git a/ui/app/templates/access-control/index.hbs b/ui/app/templates/access-control/index.hbs index e2d3fb531..3ddda57ef 100644 --- a/ui/app/templates/access-control/index.hbs +++ b/ui/app/templates/access-control/index.hbs @@ -39,6 +39,15 @@

Sets of rules defining the capabilities granted to adhering tokens.

+ + + {{this.model.namespaces.length}} {{pluralize "Namespace" this.model.namespaces.length}} + +

Namespaces allow jobs and other objects to be segmented from each other.

+ +
{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/access-control/namespaces.hbs b/ui/app/templates/access-control/namespaces.hbs new file mode 100644 index 000000000..29156e942 --- /dev/null +++ b/ui/app/templates/access-control/namespaces.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Namespaces"}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/access-control/namespaces/acl-namespace.hbs b/ui/app/templates/access-control/namespaces/acl-namespace.hbs new file mode 100644 index 000000000..d58fc1f3c --- /dev/null +++ b/ui/app/templates/access-control/namespaces/acl-namespace.hbs @@ -0,0 +1,37 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Namespace"}} + +
+

+
+ {{this.model.name}} +
+ {{#if (and (not (eq this.model.name "default")) (can "destroy namespace"))}} + + {{/if}} +

+ + + Related Resources + + View this namespace's <jobs + or <variables. + + + + +
diff --git a/ui/app/templates/access-control/namespaces/index.hbs b/ui/app/templates/access-control/namespaces/index.hbs new file mode 100644 index 000000000..dc9180e88 --- /dev/null +++ b/ui/app/templates/access-control/namespaces/index.hbs @@ -0,0 +1,55 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+

+ Namespaces allow jobs and associated objects to be segmented from each other and other users of the cluster. +

+
+ {{#if (can "write namespace")}} + + {{else}} + + {{/if}} +
+
+ + + <:body as |B|> + + + {{B.data.name}} + + {{B.data.description}} + + + +
diff --git a/ui/app/templates/access-control/namespaces/new.hbs b/ui/app/templates/access-control/namespaces/new.hbs new file mode 100644 index 000000000..aa36bfe66 --- /dev/null +++ b/ui/app/templates/access-control/namespaces/new.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + +{{page-title "Create Namespace"}} +
+

+ Create Namespace +

+ +
diff --git a/ui/app/templates/access-control/policies/index.hbs b/ui/app/templates/access-control/policies/index.hbs index 8f985b4d2..b2716fb2c 100644 --- a/ui/app/templates/access-control/policies/index.hbs +++ b/ui/app/templates/access-control/policies/index.hbs @@ -40,15 +40,16 @@ @sortBy="name" > <:body as |B|> - - {{B.data.name}} + {{B.data.name}} + {{B.data.description}} {{#if (can "list token")}} diff --git a/ui/app/templates/access-control/policies/policy.hbs b/ui/app/templates/access-control/policies/policy.hbs index 47c83a34a..a899dda97 100644 --- a/ui/app/templates/access-control/policies/policy.hbs +++ b/ui/app/templates/access-control/policies/policy.hbs @@ -12,9 +12,16 @@ {{#if (can "destroy policy")}}
-
{{/if}} diff --git a/ui/app/templates/access-control/roles/role.hbs b/ui/app/templates/access-control/roles/role.hbs index 777743c66..926acb870 100644 --- a/ui/app/templates/access-control/roles/role.hbs +++ b/ui/app/templates/access-control/roles/role.hbs @@ -10,10 +10,17 @@ Edit Role {{#if (can "destroy role")}} - + {{/if}} {{#if (can "destroy token")}} - + {{/if}} Tokens
  • Roles
  • Policies
  • +
  • Namespaces
  • diff --git a/ui/app/utils/rollback-without-changed-attrs.js b/ui/app/utils/rollback-without-changed-attrs.js new file mode 100644 index 000000000..76370b134 --- /dev/null +++ b/ui/app/utils/rollback-without-changed-attrs.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export default function rollbackWithoutChangedAttrs(model) { + // The purpose of this function was to allow deletes to fail + // and then roll them back without rolling back + // other changed attributes. + + // A failed delete followed by trying to re-view the + // model in question was throwing uncaught Errros + + let forLater = {}; + Object.keys(model.changedAttributes()).forEach((key) => { + forLater[key] = model.get(key); + }); + + model.rollbackAttributes(); + + Object.keys(forLater).forEach((key) => { + model.set(key, forLater[key]); + }); +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 44db51d2d..fa4990377 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -429,20 +429,6 @@ export default function () { return this.serialize(volume); }); - this.get('/namespaces', function ({ namespaces }) { - const records = namespaces.all(); - - if (records.length) { - return this.serialize(records); - } - - return this.serialize([{ Name: 'default' }]); - }); - - this.get('/namespace/:id', function ({ namespaces }, { params }) { - return this.serialize(namespaces.find(params.id)); - }); - this.get('/agent/members', function ({ agents, regions }) { const firstRegion = regions.first(); return { @@ -731,6 +717,48 @@ export default function () { }); }); + this.get('/namespaces', function ({ namespaces }) { + const records = namespaces.all(); + + if (records.length) { + return this.serialize(records); + } + + return this.serialize([{ Name: 'default' }]); + }); + + this.get('/namespace/:id', function ({ namespaces }, { params }) { + return this.serialize(namespaces.find(params.id)); + }); + + this.post('/namespace/:id', function (schema, request) { + const { Name, Description } = JSON.parse(request.requestBody); + + return server.create('namespace', { + id: Name, + name: Name, + description: Description, + }); + }); + + this.put('/namespace/:id', function () { + return new Response(200, {}, {}); + }); + + this.delete('/namespace/:id', function (schema, request) { + const { id } = request.params; + + // If any variables exist for the namespace, error + const variables = + server.db.variables.where((v) => v.namespace === id) || []; + if (variables.length) { + return new Response(403, {}, 'Namespace has variables'); + } + + server.db.namespaces.remove(id); + return ''; + }); + this.get('/regions', function ({ regions }) { return this.serialize(regions.all()); }); diff --git a/ui/mirage/factories/csi-plugin.js b/ui/mirage/factories/csi-plugin.js index d6c33de08..26e7e15b2 100644 --- a/ui/mirage/factories/csi-plugin.js +++ b/ui/mirage/factories/csi-plugin.js @@ -53,9 +53,13 @@ export default Factory.extend({ afterCreate(plugin, server) { let storageNodes; let storageControllers; + server.create('namespace', { id: 'default' }); if (plugin.isMonolith) { - const pluginJob = server.create('job', { type: 'service', createAllocations: false }); + const pluginJob = server.create('job', { + type: 'service', + createAllocations: false, + }); const count = plugin.nodesExpected; storageNodes = server.createList('storage-node', count, { job: pluginJob, diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index 50f74bafb..51feb1063 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -209,9 +209,14 @@ export default Factory.extend({ ); if (!job.namespaceId) { + console.log( + 'NO NAMESPACE', + server.db.namespaces.length, + server.schema.namespaces.all().models + ); const namespace = server.db.namespaces.length ? pickOne(server.db.namespaces).id - : null; + : 'default'; job.update({ namespace, namespaceId: namespace, diff --git a/ui/mirage/factories/namespace.js b/ui/mirage/factories/namespace.js index f65b35db5..9be4bc3b5 100644 --- a/ui/mirage/factories/namespace.js +++ b/ui/mirage/factories/namespace.js @@ -8,7 +8,7 @@ import { Factory } from 'ember-cli-mirage'; import faker from 'nomad-ui/mirage/faker'; export default Factory.extend({ - id: i => (i === 0 ? 'default' : `namespace-${i}`), + id: (i) => (i === 0 ? 'default' : `namespace-${i}`), name() { return this.id; diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 6c94ff75c..fc70d7154 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -27,6 +27,7 @@ export const allScenarios = { servicesTestCluster, policiesTestCluster, rolesTestCluster, + namespacesTestCluster, ...topoScenarios, ...sysbatchScenarios, }; @@ -581,6 +582,11 @@ function policiesTestCluster(server) { function rolesTestCluster(server) { faker.seed(1); + server.create('namespace', { + id: 'default', + name: 'default', + }); + server.createList('namespace', 4); server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); server.createList('node-pool', 2); server.createList('node', 5); @@ -790,6 +796,30 @@ function rolesTestCluster(server) { server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); } +function namespacesTestCluster(server, opts = { enterprise: true }) { + faker.seed(1); + createTokens(server); + + if (opts.enterprise) { + server.create('feature', { name: 'Node Pools Governance' }); + server.create('feature', { name: 'Resource Quotas' }); + } + + createNamespaces(server); + + let nsWithVariable = server.create('namespace', { + name: 'with-variables', + id: 'with-variables', + }); + + server.create('variable', { + id: `some/variable/path`, + namespace: nsWithVariable.id, + }); + + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); +} + function servicesTestCluster(server) { faker.seed(1); server.create('feature', { name: 'Dynamic Application Sizing' }); diff --git a/ui/tests/acceptance/access-control-test.js b/ui/tests/acceptance/access-control-test.js index 82e00eb5f..4e8418379 100644 --- a/ui/tests/acceptance/access-control-test.js +++ b/ui/tests/acceptance/access-control-test.js @@ -83,10 +83,12 @@ module('Acceptance | access control', function (hooks) { assert.dom('[data-test-tokens-card]').exists(); assert.dom('[data-test-roles-card]').exists(); assert.dom('[data-test-policies-card]').exists(); + assert.dom('[data-test-namespaces-card]').exists(); const numberOfTokens = server.db.tokens.length; const numberOfRoles = server.db.roles.length; const numberOfPolicies = server.db.policies.length; + const numberOfNamespaces = server.db.namespaces.length; assert .dom('[data-test-tokens-card] a') @@ -97,6 +99,9 @@ module('Acceptance | access control', function (hooks) { assert .dom('[data-test-policies-card] a') .includesText(`${numberOfPolicies} Policies`); + assert + .dom('[data-test-namespaces-card] a') + .includesText(`${numberOfNamespaces} Namespaces`); }); test('Access control subnav', async function (assert) { @@ -138,6 +143,15 @@ module('Acceptance | access control', function (hooks) { 'Shift+ArrowRight takes you to the next tab (Policies)' ); + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/access-control/namespaces`, + 'Shift+ArrowRight takes you to the next tab (Namespaces)' + ); + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { shiftKey: true, }); diff --git a/ui/tests/acceptance/actions-test.js b/ui/tests/acceptance/actions-test.js index 2bef8ab08..0b861da8f 100644 --- a/ui/tests/acceptance/actions-test.js +++ b/ui/tests/acceptance/actions-test.js @@ -309,7 +309,6 @@ module('Acceptance | actions', function (hooks) { // Global button should be present assert.ok(Actions.globalButton.isPresent, 'Global button is present'); - // click it await Actions.globalButton.click(); diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 8cea2149f..d6ba81039 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -56,14 +56,16 @@ moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () => moduleForJobWithClientStatus( 'Acceptance | job detail with client status (sysbatch)', - () => - server.create('job', { + () => { + server.create('namespace', { id: 'test' }); + return server.create('job', { status: 'running', datacenters: ['dc1'], type: 'sysbatch', createAllocations: false, noActiveDeployment: true, - }) + }); + } ); moduleForJobWithClientStatus( @@ -113,6 +115,7 @@ moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => { moduleForJobWithClientStatus( 'Acceptance | job detail with client status (sysbatch child)', () => { + server.create('namespace', { id: 'test' }); const parent = server.create('job', 'periodicSysbatch', { childrenCount: 1, shallow: true, @@ -278,6 +281,8 @@ moduleForJob( allocStatusDistribution: { running: 1, }, + // Child's gotta be non-queued to be able to run + status: 'running', // TODO: TEMP }); return server.db.jobs.where({ parentId: parent.id })[0]; } @@ -645,7 +650,7 @@ module('Acceptance | job detail (with namespaces)', function (hooks) { await settled(); // User should be booted off the page - assert.equal(currentURL(), '/jobs?namespace=*'); + assert.equal(currentURL(), '/jobs'); // A notification should be present assert @@ -684,7 +689,7 @@ module('Acceptance | job detail (with namespaces)', function (hooks) { await settled(); // User should be booted off the page - assert.equal(currentURL(), '/jobs?namespace=*'); + assert.equal(currentURL(), '/jobs'); // A notification should be present assert diff --git a/ui/tests/acceptance/namespaces-test.js b/ui/tests/acceptance/namespaces-test.js new file mode 100644 index 000000000..d226475c1 --- /dev/null +++ b/ui/tests/acceptance/namespaces-test.js @@ -0,0 +1,252 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { + visit, + currentURL, + click, + typeIn, + findAll, + find, +} from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { allScenarios } from '../../mirage/scenarios/default'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import percySnapshot from '@percy/ember'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; + +module('Acceptance | namespaces', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + test('Namespaces index, general', async function (assert) { + assert.expect(4); + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + assert.dom('[data-test-gutter-link="access-control"]').exists(); + assert.equal(currentURL(), '/access-control/namespaces'); + assert + .dom('[data-test-namespace-row]') + .exists({ count: server.db.namespaces.length }); + await a11yAudit(assert); + await percySnapshot(assert); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Prevents namespaes access if you lack a management token', async function (assert) { + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[1].secretId; + await visit('/access-control/namespaces'); + assert.equal(currentURL(), '/jobs'); + assert.dom('[data-test-gutter-link="access-control"]').doesNotExist(); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Creating a new namespace', async function (assert) { + assert.expect(7); + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + await click('[data-test-create-namespace]'); + assert.equal(currentURL(), '/access-control/namespaces/new'); + await typeIn('[data-test-namespace-name-input]', 'My New Namespace'); + await click('button[data-test-save-namespace]'); + assert + .dom('.flash-message.alert-critical') + .exists('Doesnt let you save a bad name'); + assert.equal(currentURL(), '/access-control/namespaces/new'); + document.querySelector('[data-test-namespace-name-input]').value = ''; // clear + await typeIn('[data-test-namespace-name-input]', 'My-New-Namespace'); + await click('button[data-test-save-namespace]'); + assert.dom('.flash-message.alert-success').exists(); + + assert.equal( + currentURL(), + '/access-control/namespaces/My-New-Namespace', + 'redirected to the now-created namespace' + ); + await visit('/access-control/namespaces'); + const newNs = [...findAll('[data-test-namespace-name]')].filter((a) => + a.textContent.includes('My-New-Namespace') + )[0]; + assert.ok(newNs, 'Namespace is in the list'); + await click(newNs); + assert.equal(currentURL(), '/access-control/namespaces/My-New-Namespace'); + await percySnapshot(assert); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('New namespaces have quotas and node_pool properties if Ent', async function (assert) { + assert.expect(2); + allScenarios.namespacesTestCluster(server, { enterprise: true }); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + await click('[data-test-create-namespace]'); + + // Get the dom node text for the description + const descriptionText = document.querySelector( + '[data-test-namespace-editor]' + ).textContent; + + assert.ok( + descriptionText.includes('Quota'), + 'Includes Quotas in namespace description' + ); + assert.ok( + descriptionText.includes( + 'NodePoolConfiguration', + 'Includes NodePoolConfiguration in namespace description' + ) + ); + + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('New namespaces hide quotas and node_pool properties if CE', async function (assert) { + assert.expect(2); + allScenarios.namespacesTestCluster(server, { enterprise: false }); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + await click('[data-test-create-namespace]'); + + // Get the dom node text for the description + const descriptionText = document.querySelector( + '[data-test-namespace-editor]' + ).textContent; + + assert.notOk(descriptionText.includes('Quotas')); + assert.notOk(descriptionText.includes('NodePoolConfiguration')); + + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Modifying an existing namespace', async function (assert) { + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + await click('[data-test-namespace-row]:first-child a'); + // Table sorts by name by default + let firstNamespace = server.db.namespaces.sort((a, b) => { + return a.name.localeCompare(b.name); + })[0]; + assert.equal( + currentURL(), + `/access-control/namespaces/${firstNamespace.name}` + ); + assert.dom('[data-test-namespace-editor]').exists(); + assert.dom('[data-test-title]').includesText(firstNamespace.name); + await click('button[data-test-save-namespace]'); + assert.dom('.flash-message.alert-success').exists(); + assert.equal( + currentURL(), + `/access-control/namespaces/${firstNamespace.name}`, + 'remain on page after save' + ); + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Deleting a namespace', async function (assert) { + assert.expect(11); + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + await visit('/access-control/namespaces'); + + // Default namespace hides delete button + const defaultNamespaceLink = [ + ...findAll('[data-test-namespace-name]'), + ].filter((row) => row.textContent.includes('default'))[0]; + await click(defaultNamespaceLink); + + assert.equal(currentURL(), `/access-control/namespaces/default`); + let deleteButton = find('[data-test-delete-namespace] button'); + assert + .dom(deleteButton) + .doesNotExist('delete button is not present for default'); + + // Standard namespace properly deletes + await visit('/access-control/namespaces'); + + let nonDefaultNamespace = server.db.namespaces.findBy( + (ns) => ns.name != 'default' + ); + const nonDefaultNsLink = [...findAll('[data-test-namespace-name]')].filter( + (row) => row.textContent.includes(nonDefaultNamespace.name) + )[0]; + await click(nonDefaultNsLink); + assert.equal( + currentURL(), + `/access-control/namespaces/${nonDefaultNamespace.name}` + ); + deleteButton = find('[data-test-delete-namespace] button'); + assert.dom(deleteButton).exists('delete button is present for non-default'); + await click(deleteButton); + assert + .dom('[data-test-confirmation-message]') + .exists('confirmation message is present'); + await click(find('[data-test-confirm-button]')); + assert.dom('.flash-message.alert-success').exists(); + assert.equal(currentURL(), '/access-control/namespaces'); + assert + .dom(`[data-test-namespace-name="${nonDefaultNamespace.name}"]`) + .doesNotExist(); + + // Namespace with variables errors properly + // "with-variables" hard-coded into scenario to be a NS with variables attached + await visit('/access-control/namespaces/with-variables'); + assert.equal(currentURL(), '/access-control/namespaces/with-variables'); + deleteButton = find('[data-test-delete-namespace] button'); + await click(deleteButton); + await click(find('[data-test-confirm-button]')); + assert + .dom('.flash-message.alert-critical') + .exists('Doesnt let you delete a namespace with variables'); + + assert.equal(currentURL(), '/access-control/namespaces/with-variables'); + + // Reset Token + window.localStorage.nomadTokenSecret = null; + }); + + test('Deleting a namespace failure and return', async function (assert) { + // This is an indirect test of rollbackWithoutChangedAttrs + // which allows deletes to fail and rolls back attributes + // It was added because this path was throwing an error when + // reloading the Ember model that was attempted to be deleted + + assert.expect(3); + allScenarios.namespacesTestCluster(server); + window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId; + + // Attempt a delete on an un-deletable namespace + await visit('/access-control/namespaces/with-variables'); + let deleteButton = find('[data-test-delete-namespace] button'); + await click(deleteButton); + await click(find('[data-test-confirm-button]')); + + assert + .dom('.flash-message.alert-critical') + .exists('Doesnt let you delete a namespace with variables'); + assert.equal(currentURL(), '/access-control/namespaces/with-variables'); + + // Navigate back to the page via the index + await visit('/access-control/namespaces'); + + // Default namespace hides delete button + const notDeletedNSLink = [...findAll('[data-test-namespace-name]')].filter( + (row) => row.textContent.includes('with-variables') + )[0]; + await click(notDeletedNSLink); + + assert.equal(currentURL(), `/access-control/namespaces/with-variables`); + }); +}); diff --git a/ui/tests/acceptance/optimize-test.js b/ui/tests/acceptance/optimize-test.js index 595eb97ce..577e2b74b 100644 --- a/ui/tests/acceptance/optimize-test.js +++ b/ui/tests/acceptance/optimize-test.js @@ -422,7 +422,7 @@ module('Acceptance | optimize', function (hooks) { window.localStorage.nomadTokenSecret = clientToken.secretId; await Optimize.visit(); - assert.equal(currentURL(), '/jobs?namespace=*'); + assert.equal(currentURL(), '/jobs'); assert.ok(Layout.gutter.optimize.isHidden); }); diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js index 99804c932..e04d01ec0 100644 --- a/ui/tests/acceptance/policies-test.js +++ b/ui/tests/acceptance/policies-test.js @@ -4,7 +4,14 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, click, typeIn, findAll } from '@ember/test-helpers'; +import { + visit, + currentURL, + click, + typeIn, + findAll, + find, +} from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import { allScenarios } from '../../mirage/scenarios/default'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -137,7 +144,15 @@ module('Acceptance | policies', function (hooks) { )[0]; await click(firstPolicyLink); assert.equal(currentURL(), `/access-control/policies/${firstPolicyName}`); - await click('[data-test-delete-policy]'); + + const deleteButton = find('[data-test-delete-policy] button'); + assert.dom(deleteButton).exists('delete button is present'); + await click(deleteButton); + assert + .dom('[data-test-confirmation-message]') + .exists('confirmation message is present'); + await click(find('[data-test-confirm-button]')); + assert.dom('.flash-message.alert-success').exists(); assert.equal(currentURL(), '/access-control/policies'); assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist(); diff --git a/ui/tests/acceptance/roles-test.js b/ui/tests/acceptance/roles-test.js index 5a62990a9..e08e4a29f 100644 --- a/ui/tests/acceptance/roles-test.js +++ b/ui/tests/acceptance/roles-test.js @@ -255,7 +255,13 @@ module('Acceptance | roles', function (hooks) { const role = server.db.roles.findBy((r) => r.name === 'reader'); await click('[data-test-role-name="reader"] a'); assert.equal(currentURL(), `/access-control/roles/${role.id}`); - await click('[data-test-delete-role]'); + const deleteButton = find('[data-test-delete-role] button'); + assert.dom(deleteButton).exists('delete button is present'); + await click(deleteButton); + assert + .dom('[data-test-confirmation-message]') + .exists('confirmation message is present'); + await click(find('[data-test-confirm-button]')); assert.dom('.flash-message.alert-success').exists(); assert.equal(currentURL(), '/access-control/roles'); assert.dom('[data-test-role-row="reader"]').doesNotExist(); diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index ff2c4c7a1..ba6e77b30 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -377,11 +377,7 @@ module('Acceptance | task detail (different namespace)', function (hooks) { const job = server.db.jobs.find(jobId); await Layout.breadcrumbFor('jobs.index').visit(); - assert.equal( - currentURL(), - '/jobs?namespace=*', - 'Jobs breadcrumb links correctly' - ); + assert.equal(currentURL(), '/jobs', 'Jobs breadcrumb links correctly'); await Task.visit({ id: allocation.id, name: task.name }); await Layout.breadcrumbFor('jobs.job.index').visit(); diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index 258d4b7e3..e73a444dd 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -48,6 +48,7 @@ module('Acceptance | tokens', function (hooks) { server.create('agent'); server.create('node-pool'); + server.create('namespace'); node = server.create('node'); job = server.create('job'); managementToken = server.create('token'); @@ -856,7 +857,8 @@ module('Acceptance | tokens', function (hooks) { await visit('/jobs'); // Expect the Run button/link to work now assert.dom('[data-test-run-job]').hasTagName('a'); - assert.dom('[data-test-run-job]').hasAttribute('href', '/ui/jobs/run'); + let runJobLink = find('[data-test-run-job]'); + assert.ok(runJobLink.href.includes('/ui/jobs/run')); }); }); @@ -1208,7 +1210,15 @@ module('Acceptance | tokens', function (hooks) { test('Token can be deleted', async function (assert) { const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n'); await visit(`/access-control/tokens/${token.id}`); - await click('[data-test-delete-token]'); + + const deleteButton = find('[data-test-delete-token] button'); + assert.dom(deleteButton).exists('delete button is present'); + await click(deleteButton); + assert + .dom('[data-test-confirmation-message]') + .exists('confirmation message is present'); + await click(find('[data-test-confirm-button]')); + assert.dom('.flash-message.alert-success').exists(); await AccessControl.visitTokens(); assert.dom('[data-test-token-name="cl4y-t0k3n"]').doesNotExist(); diff --git a/ui/tests/acceptance/variables-test.js b/ui/tests/acceptance/variables-test.js index 9b8634730..f66cd8784 100644 --- a/ui/tests/acceptance/variables-test.js +++ b/ui/tests/acceptance/variables-test.js @@ -703,7 +703,7 @@ module('Acceptance | variables', function (hooks) { assert.ok(confirmFired, 'Confirm fired when leaving with unsaved form'); assert.equal( currentURL(), - '/jobs?namespace=*', + '/jobs', 'Opted to leave, ended up on desired page' ); @@ -991,7 +991,7 @@ module('Acceptance | variables', function (hooks) { await visit( `/jobs/${server.db.jobs[0].id}@${server.db.jobs[0].namespace}/variables` ); - assert.equal(currentURL(), '/jobs?namespace=*'); + assert.equal(currentURL(), '/jobs'); window.localStorage.nomadTokenSecret = null; // Reset Token }); diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js index 323a5c5fd..5a73348a6 100644 --- a/ui/tests/helpers/module-for-job.js +++ b/ui/tests/helpers/module-for-job.js @@ -44,7 +44,7 @@ export default function moduleForJob( server.create('node-pool'); server.create('node'); job = jobFactory(); - if (!job.namespace || job.namespace === 'default') { + if (!job.namespace) { await JobDetail.visit({ id: job.id }); } else { await JobDetail.visit({ id: `${job.id}@${job.namespace}` }); @@ -199,9 +199,9 @@ export default function moduleForJob( const encodedStatus = encodeURIComponent(JSON.stringify([status])); const expectedURL = new URL( urlWithNamespace( - `/jobs/${encodeURIComponent( - job.name - )}/allocations?status=${encodedStatus}`, + `/jobs/${encodeURIComponent(job.name)}@${ + job.namespace + }/allocations?status=${encodedStatus}`, job.namespace ), window.location diff --git a/ui/tests/integration/components/task-group-row-test.js b/ui/tests/integration/components/task-group-row-test.js index ac2118cdd..25018eae2 100644 --- a/ui/tests/integration/components/task-group-row-test.js +++ b/ui/tests/integration/components/task-group-row-test.js @@ -25,6 +25,9 @@ let clientToken; const makeJob = (server, props = {}) => { // These tests require a job with particular task groups. This requires // mild Mirage surgery. + server.create('namespace', { + id: 'default', + }); const job = server.create('job', { id: jobName, groupCount: 0, diff --git a/ui/tests/pages/access-control.js b/ui/tests/pages/access-control.js index 932127cc1..9db1bd3cd 100644 --- a/ui/tests/pages/access-control.js +++ b/ui/tests/pages/access-control.js @@ -10,4 +10,5 @@ export default create({ visitTokens: visitable('/access-control/tokens'), visitPolicies: visitable('/access-control/policies'), visitRoles: visitable('/access-control/roles'), + visitNamespaces: visitable('/access-control/namespaces'), });