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
+~}}
+
+
\ 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'),
});