From 44a8e1bb2be2a5d10904ccd080b412b849251d33 Mon Sep 17 00:00:00 2001
From: Jordan Reimer
Date: Wed, 18 Jan 2023 14:02:41 -0700
Subject: [PATCH] Kubernetes Secrets Engine (#17893)
* Ember Engine for Kubernetes Secrets Engine (#17881)
* adds in-repo ember engine for kubernetes secrets engine
* updates kubernetes engine class name
* Kubernetes route plumbing (#17895)
* kubernetes route plumbing
* adds kubernetes role index route with redirect to details
* adds kubernetes as mountable and supported secrets engine (#17891)
* adds models, adapters and serializers for kubernetes secrets engine (#18010)
* adds mirage factories and handlers for kubernetes (#17943)
* Kubernetes Secrets Engine Configuration (#18093)
* moves RadioCard component to core addon
* adds kubernetes configuration view
* fixes tests using RadioCard after label for and input id changes
* adds confirm modal when editing kubernetes config
* addresses review comments
* Kubernetes Configuration View (#18147)
* removes configuration edit and index routes
* adds kubernetes configuration view
* Kubernetes Roles List (#18211)
* removes configuration edit and index routes
* adds kubernetes configuration view
* adds kubernetes secrets engine roles list view
* updates role details disabled state to explicitly check for false
* VAULT-9863 Kubernetes Overview Page (#18232)
* Add overview page view
* Add overview page tests
* Address feedback to update tests and minor changes
* Use template built in helper for conditionally showing num roles
* Set up roleOptions in constructor
* Set up models in tests and fix minor bug
* Kubernetes Secrets Engine Create/Edit Views (#18271)
* moves kv-object-editor to core addon
* moves json-editor to core addon
* adds kubernetes secrets engine create/edit views
* updates kubernetes/role adapter test
* addresses feedback
* fixes issue with overview route showing 404 page (#18303)
* Kubernetes Role Details View (#18294)
* moves format-duration helper to core addon
* adds kubernetes secrets engine role details view
* adds tests for role details page component
* adds capabilities checks for toolbar actions
* fixes list link for secrets in an ember engine (#18313)
* Manual Testing: Bug Fixes and Improvements (#18333)
* updates overview, configuration and roles components to pass args for individual model properties
* bug fixes and improvements
* adds top level index route to redirect to overview
* VAULT-9877 Kubernetes Credential Generate/View Pages (#18270)
* Add credentials route with create and view components
* Update mirage response for creds and add ajax post call for creds in adapter
* Move credentials create and view into one component
* Add test classes
* Remove files and update backend property name
* Code cleanup and add tests
* Put test helper in helper function
* Add one more test!
* Add code optimizations
* Fix model in route and add form
* Add onSubmit to form and preventDefault
* Fix tests
* Update mock data for test to be strong rather than record
* adds acceptance tests for kubernetes secrets engine roles (#18360)
* VAULT-11862 Kubernetes acceptance tests (#18431)
* VAULT-12185 overview acceptance tests
* VAULT-12298 credentials acceptance tests
* VAULT-12186 configuration acceptance tests
* VAULT-12127 Refactor breadcrumbs to use breadcrumb component (#18489)
* VAULT-12127 Refactor breadcrumbs to use Page::Breadcrumbs component
* Fix failing tests by adding breadcrumbs properties
* VAULT-12166 add jsdocs to kubernetes secrets engine pages (#18509)
* fixes incorrect merge conflict resolution
* updates kubernetes check env vars endpoint (#18588)
* hides kubernetes ca cert field if not defined in configuration view
* fixes loading substate handling issue (#18592)
* adds changelog entry
Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
---
changelog/17893.txt | 3 +
ui/app/adapters/kubernetes/config.js | 38 +++
ui/app/adapters/kubernetes/role.js | 46 +++
ui/app/app.js | 8 +
ui/app/helpers/mountable-secret-engines.js | 8 +
ui/app/helpers/supported-secret-backends.js | 1 +
ui/app/models/kubernetes/config.js | 27 ++
ui/app/models/kubernetes/role.js | 153 ++++++++++
ui/app/router.js | 1 +
ui/app/routes/vault/cluster.js | 3 +-
ui/app/serializers/kubernetes/config.js | 12 +
ui/app/serializers/kubernetes/role.js | 12 +
ui/app/styles/core/helpers.scss | 33 +++
.../core/addon}/components/json-editor.hbs | 0
.../core/addon}/components/json-editor.js | 0
.../addon}/components/kv-object-editor.hbs | 0
.../addon}/components/kv-object-editor.js | 0
.../core/addon/components/navigate-input.js | 8 +-
.../core/addon}/helpers/format-duration.js | 0
.../core/addon}/modifiers/code-mirror.js | 0
.../templates/components/empty-state.hbs | 4 +-
ui/lib/core/app/components/json-editor.js | 1 +
.../core/app/components/kv-object-editor.js | 1 +
ui/lib/core/app/helpers/format-duration.js | 1 +
ui/lib/core/app/modifiers/code-mirror.js | 1 +
ui/lib/core/package.json | 4 +-
.../addon/components/config-cta.hbs | 9 +
.../addon/components/page/configuration.hbs | 51 ++++
.../addon/components/page/configure.hbs | 117 ++++++++
.../addon/components/page/configure.js | 83 ++++++
.../addon/components/page/credentials.hbs | 119 ++++++++
.../addon/components/page/credentials.js | 68 +++++
.../addon/components/page/overview.hbs | 54 ++++
.../addon/components/page/overview.js | 41 +++
.../components/page/role/create-and-edit.hbs | 139 +++++++++
.../components/page/role/create-and-edit.js | 163 +++++++++++
.../addon/components/page/role/details.hbs | 64 +++++
.../addon/components/page/role/details.js | 39 +++
.../addon/components/page/roles.hbs | 76 +++++
.../kubernetes/addon/components/page/roles.js | 35 +++
.../addon/components/tab-page-header.hbs | 36 +++
.../addon/controllers/roles/index.js | 5 +
ui/lib/kubernetes/addon/engine.js | 19 ++
ui/lib/kubernetes/addon/routes.js | 15 +
.../kubernetes/addon/routes/configuration.js | 19 ++
ui/lib/kubernetes/addon/routes/configure.js | 8 +
.../kubernetes/addon/routes/fetch-config.js | 31 ++
ui/lib/kubernetes/addon/routes/index.js | 10 +
ui/lib/kubernetes/addon/routes/overview.js | 22 ++
.../kubernetes/addon/routes/roles/create.js | 12 +
ui/lib/kubernetes/addon/routes/roles/index.js | 31 ++
.../addon/routes/roles/role/credentials.js | 23 ++
.../addon/routes/roles/role/details.js | 23 ++
.../addon/routes/roles/role/edit.js | 13 +
.../addon/routes/roles/role/index.js | 10 +
.../addon/templates/configuration.hbs | 1 +
.../kubernetes/addon/templates/configure.hbs | 1 +
.../kubernetes/addon/templates/overview.hbs | 6 +
.../addon/templates/roles/create.hbs | 1 +
.../addon/templates/roles/index.hbs | 7 +
.../templates/roles/role/credentials.hbs | 1 +
.../addon/templates/roles/role/details.hbs | 1 +
.../addon/templates/roles/role/edit.hbs | 1 +
.../addon/utils/generated-role-rules.js | 150 ++++++++++
ui/lib/kubernetes/config/environment.js | 11 +
ui/lib/kubernetes/index.js | 15 +
ui/lib/kubernetes/package.json | 19 ++
ui/mirage/factories/kubernetes-config.js | 11 +
ui/mirage/factories/kubernetes-role.js | 54 ++++
ui/mirage/handlers/index.js | 3 +-
ui/mirage/handlers/kubernetes.js | 100 +++++++
ui/mirage/scenarios/default.js | 8 +
ui/mirage/scenarios/kubernetes.js | 8 +
ui/package.json | 1 +
.../backend/kubernetes/configuration-test.js | 51 ++++
.../backend/kubernetes/credentials-test.js | 77 +++++
.../backend/kubernetes/overview-test.js | 64 +++++
.../secrets/backend/kubernetes/roles-test.js | 124 ++++++++
.../components/kubernetes/config-cta-test.js | 24 ++
.../kubernetes/page/configuration-test.js | 95 +++++++
.../kubernetes/page/configure-test.js | 191 +++++++++++++
.../kubernetes/page/credentials-test.js | 133 +++++++++
.../kubernetes/page/overview-test.js | 109 +++++++
.../page/role/create-and-edit-test.js | 268 ++++++++++++++++++
.../kubernetes/page/role/details-test.js | 140 +++++++++
.../components/kubernetes/page/roles-test.js | 101 +++++++
.../kubernetes/tab-page-header-test.js | 80 ++++++
.../unit/adapters/kubernetes/config-test.js | 56 ++++
.../unit/adapters/kubernetes/role-test.js | 71 +++++
89 files changed, 3676 insertions(+), 7 deletions(-)
create mode 100644 changelog/17893.txt
create mode 100644 ui/app/adapters/kubernetes/config.js
create mode 100644 ui/app/adapters/kubernetes/role.js
create mode 100644 ui/app/models/kubernetes/config.js
create mode 100644 ui/app/models/kubernetes/role.js
create mode 100644 ui/app/serializers/kubernetes/config.js
create mode 100644 ui/app/serializers/kubernetes/role.js
rename ui/{app/templates => lib/core/addon}/components/json-editor.hbs (100%)
rename ui/{app => lib/core/addon}/components/json-editor.js (100%)
rename ui/{app/templates => lib/core/addon}/components/kv-object-editor.hbs (100%)
rename ui/{app => lib/core/addon}/components/kv-object-editor.js (100%)
rename ui/{app => lib/core/addon}/helpers/format-duration.js (100%)
rename ui/{app => lib/core/addon}/modifiers/code-mirror.js (100%)
create mode 100644 ui/lib/core/app/components/json-editor.js
create mode 100644 ui/lib/core/app/components/kv-object-editor.js
create mode 100644 ui/lib/core/app/helpers/format-duration.js
create mode 100644 ui/lib/core/app/modifiers/code-mirror.js
create mode 100644 ui/lib/kubernetes/addon/components/config-cta.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/configuration.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/configure.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/configure.js
create mode 100644 ui/lib/kubernetes/addon/components/page/credentials.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/credentials.js
create mode 100644 ui/lib/kubernetes/addon/components/page/overview.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/overview.js
create mode 100644 ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/role/create-and-edit.js
create mode 100644 ui/lib/kubernetes/addon/components/page/role/details.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/role/details.js
create mode 100644 ui/lib/kubernetes/addon/components/page/roles.hbs
create mode 100644 ui/lib/kubernetes/addon/components/page/roles.js
create mode 100644 ui/lib/kubernetes/addon/components/tab-page-header.hbs
create mode 100644 ui/lib/kubernetes/addon/controllers/roles/index.js
create mode 100644 ui/lib/kubernetes/addon/engine.js
create mode 100644 ui/lib/kubernetes/addon/routes.js
create mode 100644 ui/lib/kubernetes/addon/routes/configuration.js
create mode 100644 ui/lib/kubernetes/addon/routes/configure.js
create mode 100644 ui/lib/kubernetes/addon/routes/fetch-config.js
create mode 100644 ui/lib/kubernetes/addon/routes/index.js
create mode 100644 ui/lib/kubernetes/addon/routes/overview.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/create.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/index.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/role/credentials.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/role/details.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/role/edit.js
create mode 100644 ui/lib/kubernetes/addon/routes/roles/role/index.js
create mode 100644 ui/lib/kubernetes/addon/templates/configuration.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/configure.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/overview.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/roles/create.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/roles/index.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/roles/role/credentials.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/roles/role/details.hbs
create mode 100644 ui/lib/kubernetes/addon/templates/roles/role/edit.hbs
create mode 100644 ui/lib/kubernetes/addon/utils/generated-role-rules.js
create mode 100644 ui/lib/kubernetes/config/environment.js
create mode 100644 ui/lib/kubernetes/index.js
create mode 100644 ui/lib/kubernetes/package.json
create mode 100644 ui/mirage/factories/kubernetes-config.js
create mode 100644 ui/mirage/factories/kubernetes-role.js
create mode 100644 ui/mirage/handlers/kubernetes.js
create mode 100644 ui/mirage/scenarios/kubernetes.js
create mode 100644 ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js
create mode 100644 ui/tests/acceptance/secrets/backend/kubernetes/credentials-test.js
create mode 100644 ui/tests/acceptance/secrets/backend/kubernetes/overview-test.js
create mode 100644 ui/tests/acceptance/secrets/backend/kubernetes/roles-test.js
create mode 100644 ui/tests/integration/components/kubernetes/config-cta-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/configuration-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/configure-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/credentials-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/overview-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/role/details-test.js
create mode 100644 ui/tests/integration/components/kubernetes/page/roles-test.js
create mode 100644 ui/tests/integration/components/kubernetes/tab-page-header-test.js
create mode 100644 ui/tests/unit/adapters/kubernetes/config-test.js
create mode 100644 ui/tests/unit/adapters/kubernetes/role-test.js
diff --git a/changelog/17893.txt b/changelog/17893.txt
new file mode 100644
index 0000000000..bb89e5bfd3
--- /dev/null
+++ b/changelog/17893.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+ui: Adds Kubernetes secrets engine
+```
\ No newline at end of file
diff --git a/ui/app/adapters/kubernetes/config.js b/ui/app/adapters/kubernetes/config.js
new file mode 100644
index 0000000000..9bf4585f9f
--- /dev/null
+++ b/ui/app/adapters/kubernetes/config.js
@@ -0,0 +1,38 @@
+import ApplicationAdapter from 'vault/adapters/application';
+import { encodePath } from 'vault/utils/path-encoding-helpers';
+
+export default class KubernetesConfigAdapter extends ApplicationAdapter {
+ namespace = 'v1';
+
+ getURL(backend, path = 'config') {
+ return `${this.buildURL()}/${encodePath(backend)}/${path}`;
+ }
+ urlForUpdateRecord(name, modelName, snapshot) {
+ return this.getURL(snapshot.attr('backend'));
+ }
+ urlForDeleteRecord(backend) {
+ return this.getURL(backend);
+ }
+
+ queryRecord(store, type, query) {
+ const { backend } = query;
+ return this.ajax(this.getURL(backend), 'GET').then((resp) => {
+ resp.backend = backend;
+ return resp;
+ });
+ }
+ createRecord() {
+ return this._saveRecord(...arguments);
+ }
+ updateRecord() {
+ return this._saveRecord(...arguments);
+ }
+ _saveRecord(store, { modelName }, snapshot) {
+ const data = store.serializerFor(modelName).serialize(snapshot);
+ const url = this.getURL(snapshot.attr('backend'));
+ return this.ajax(url, 'POST', { data }).then(() => data);
+ }
+ checkConfigVars(backend) {
+ return this.ajax(`${this.getURL(backend, 'check')}`, 'GET');
+ }
+}
diff --git a/ui/app/adapters/kubernetes/role.js b/ui/app/adapters/kubernetes/role.js
new file mode 100644
index 0000000000..1bb268d2e2
--- /dev/null
+++ b/ui/app/adapters/kubernetes/role.js
@@ -0,0 +1,46 @@
+import NamedPathAdapter from 'vault/adapters/named-path';
+import { encodePath } from 'vault/utils/path-encoding-helpers';
+
+export default class KubernetesRoleAdapter extends NamedPathAdapter {
+ getURL(backend, name) {
+ const base = `${this.buildURL()}/${encodePath(backend)}/roles`;
+ return name ? `${base}/${name}` : base;
+ }
+ urlForQuery({ backend }) {
+ return this.getURL(backend);
+ }
+ urlForUpdateRecord(name, modelName, snapshot) {
+ return this.getURL(snapshot.attr('backend'), name);
+ }
+ urlForDeleteRecord(name, modelName, snapshot) {
+ return this.getURL(snapshot.attr('backend'), name);
+ }
+
+ query(store, type, query) {
+ const { backend } = query;
+ return this.ajax(this.getURL(backend), 'GET', { data: { list: true } }).then((resp) => {
+ return resp.data.keys.map((name) => ({ name, backend }));
+ });
+ }
+ queryRecord(store, type, query) {
+ const { backend, name } = query;
+ return this.ajax(this.getURL(backend, name), 'GET').then((resp) => {
+ resp.data.backend = backend;
+ resp.data.name = name;
+ return resp.data;
+ });
+ }
+ generateCredentials(backend, data) {
+ const generateCredentialsUrl = `${this.buildURL()}/${encodePath(backend)}/creds/${data.role}`;
+
+ return this.ajax(generateCredentialsUrl, 'POST', { data }).then((response) => {
+ const { lease_id, lease_duration, data } = response;
+
+ return {
+ lease_id,
+ lease_duration,
+ ...data,
+ };
+ });
+ }
+}
diff --git a/ui/app/app.js b/ui/app/app.js
index d0fde1b6c2..c3ea1c7eb3 100644
--- a/ui/app/app.js
+++ b/ui/app/app.js
@@ -49,6 +49,14 @@ export default class App extends Application {
},
},
},
+ kubernetes: {
+ dependencies: {
+ services: ['router', 'store', 'secret-mount-path', 'flashMessages'],
+ externalRoutes: {
+ secrets: 'vault.cluster.secrets.backends',
+ },
+ },
+ },
pki: {
dependencies: {
services: [
diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js
index 20e1692055..9851483fd0 100644
--- a/ui/app/helpers/mountable-secret-engines.js
+++ b/ui/app/helpers/mountable-secret-engines.js
@@ -104,6 +104,14 @@ const MOUNTABLE_SECRET_ENGINES = [
type: 'totp',
category: 'generic',
},
+ {
+ displayName: 'Kubernetes',
+ value: 'kubernetes',
+ type: 'kubernetes',
+ engineRoute: 'kubernetes.overview',
+ category: 'generic',
+ glyph: 'kubernetes-color',
+ },
];
export function mountableEngines() {
diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js
index 806b90513b..b8eb713403 100644
--- a/ui/app/helpers/supported-secret-backends.js
+++ b/ui/app/helpers/supported-secret-backends.js
@@ -12,6 +12,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'kmip',
'transform',
'keymgmt',
+ 'kubernetes',
];
export function supportedSecretBackends() {
diff --git a/ui/app/models/kubernetes/config.js b/ui/app/models/kubernetes/config.js
new file mode 100644
index 0000000000..44ef8f8bf4
--- /dev/null
+++ b/ui/app/models/kubernetes/config.js
@@ -0,0 +1,27 @@
+import Model, { attr } from '@ember-data/model';
+import { withFormFields } from 'vault/decorators/model-form-fields';
+
+@withFormFields(['kubernetesHost', 'serviceAccountJwt', 'kubernetesCaCert'])
+export default class KubernetesConfigModel extends Model {
+ @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
+ @attr('string', {
+ label: 'Kubernetes host',
+ subText:
+ 'Kubernetes API URL to connect to. Defaults to https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT if those environment variables are set.',
+ })
+ kubernetesHost;
+ @attr('string', {
+ label: 'Service account JWT',
+ subText:
+ 'The JSON web token of the service account used by the secret engine to manage Kubernetes roles. Defaults to the local pod’s JWT if found.',
+ })
+ serviceAccountJwt;
+ @attr('string', {
+ label: 'Kubernetes CA Certificate',
+ subText:
+ 'PEM-encoded CA certificate to use by the secret engine to verify the Kubernetes API server certificate. Defaults to the local pod’s CA if found.',
+ editType: 'textarea',
+ })
+ kubernetesCaCert;
+ @attr('boolean', { defaultValue: false }) disableLocalCaJwt;
+}
diff --git a/ui/app/models/kubernetes/role.js b/ui/app/models/kubernetes/role.js
new file mode 100644
index 0000000000..6e8af9c26a
--- /dev/null
+++ b/ui/app/models/kubernetes/role.js
@@ -0,0 +1,153 @@
+import Model, { attr } from '@ember-data/model';
+import { withModelValidations } from 'vault/decorators/model-validations';
+import { withFormFields } from 'vault/decorators/model-form-fields';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { tracked } from '@glimmer/tracking';
+
+const validations = {
+ name: [{ type: 'presence', message: 'Name is required' }],
+};
+const formFieldProps = [
+ 'name',
+ 'serviceAccountName',
+ 'kubernetesRoleType',
+ 'kubernetesRoleName',
+ 'allowedKubernetesNamespaces',
+ 'tokenMaxTtl',
+ 'tokenDefaultTtl',
+ 'nameTemplate',
+];
+
+@withModelValidations(validations)
+@withFormFields(formFieldProps)
+export default class KubernetesRoleModel extends Model {
+ @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
+ @attr('string', {
+ label: 'Role name',
+ subText: 'The role’s name in Vault.',
+ })
+ name;
+
+ @attr('string', {
+ label: 'Service account name',
+ subText: 'Vault will use the default template when generating service accounts, roles and role bindings.',
+ })
+ serviceAccountName;
+
+ @attr('string', {
+ label: 'Kubernetes role type',
+ editType: 'radio',
+ possibleValues: ['Role', 'ClusterRole'],
+ })
+ kubernetesRoleType;
+
+ @attr('string', {
+ label: 'Kubernetes role name',
+ subText: 'Vault will use the default template when generating service accounts, roles and role bindings.',
+ })
+ kubernetesRoleName;
+
+ @attr('string', {
+ label: 'Service account name',
+ subText: 'Vault will use the default template when generating service accounts, roles and role bindings.',
+ })
+ serviceAccountName;
+
+ @attr('string', {
+ label: 'Allowed Kubernetes namespaces',
+ subText:
+ 'A list of the valid Kubernetes namespaces in which this role can be used for creating service accounts. If set to "*" all namespaces are allowed.',
+ })
+ allowedKubernetesNamespaces;
+
+ @attr({
+ label: 'Max Lease TTL',
+ editType: 'ttl',
+ })
+ tokenMaxTtl;
+
+ @attr({
+ label: 'Default Lease TTL',
+ editType: 'ttl',
+ })
+ tokenDefaultTtl;
+
+ @attr('string', {
+ label: 'Name template',
+ editType: 'optionalText',
+ defaultSubText:
+ 'Vault will use the default template when generating service accounts, roles and role bindings.',
+ subText: 'Vault will use the default template when generating service accounts, roles and role bindings.',
+ })
+ nameTemplate;
+
+ @attr extraAnnotations;
+ @attr extraLabels;
+
+ @attr('string') generatedRoleRules;
+
+ @tracked _generationPreference;
+ get generationPreference() {
+ // when the user interacts with the radio cards the value will be set to the pseudo prop which takes precedence
+ if (this._generationPreference) {
+ return this._generationPreference;
+ }
+ // for existing roles, default the value based on which model prop has value -- only one can be set
+ let pref = null;
+ if (this.serviceAccountName) {
+ pref = 'basic';
+ } else if (this.kubernetesRoleName) {
+ pref = 'expanded';
+ } else if (this.generatedRoleRules) {
+ pref = 'full';
+ }
+ return pref;
+ }
+ set generationPreference(pref) {
+ // unset model props specific to filteredFormFields when changing preference
+ // only one of service_account_name, kubernetes_role_name or generated_role_rules can be set
+ const props = {
+ basic: ['kubernetesRoleType', 'kubernetesRoleName', 'generatedRoleRules', 'nameTemplate'],
+ expanded: ['serviceAccountName', 'generatedRoleRules'],
+ full: ['serviceAccountName', 'kubernetesRoleName'],
+ }[pref];
+ props.forEach((prop) => (this[prop] = null));
+ this._generationPreference = pref;
+ }
+
+ get filteredFormFields() {
+ // return different form fields based on generationPreference
+ const hiddenFieldIndices = {
+ basic: [2, 3, 7], // kubernetesRoleType, kubernetesRoleName and nameTemplate
+ expanded: [1], // serviceAccountName
+ full: [1, 3], // serviceAccountName and kubernetesRoleName
+ }[this.generationPreference];
+
+ return hiddenFieldIndices
+ ? this.formFields.filter((field, index) => !hiddenFieldIndices.includes(index))
+ : null;
+ }
+
+ @lazyCapabilities(apiPath`${'backend'}/roles/${'name'}`, 'backend', 'name') rolePath;
+ @lazyCapabilities(apiPath`${'backend'}/creds/${'name'}`, 'backend', 'name') credsPath;
+ @lazyCapabilities(apiPath`${'backend'}/roles`, 'backend') rolesPath;
+
+ get canCreate() {
+ return this.rolePath.get('canCreate');
+ }
+ get canDelete() {
+ return this.rolePath.get('canDelete');
+ }
+ get canEdit() {
+ return this.rolePath.get('canUpdate');
+ }
+ get canRead() {
+ return this.rolePath.get('canRead');
+ }
+ get canList() {
+ return this.rolesPath.get('canList');
+ }
+ get canGenerateCreds() {
+ return this.credsPath.get('canCreate');
+ }
+}
diff --git a/ui/app/router.js b/ui/app/router.js
index 4588691212..c2c90dd4f9 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -154,6 +154,7 @@ Router.map(function () {
this.route('backends', { path: '/' });
this.route('backend', { path: '/:backend' }, function () {
this.mount('kmip');
+ this.mount('kubernetes');
if (config.environment !== 'production') {
this.mount('pki');
}
diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js
index a81bb84881..4a115fefed 100644
--- a/ui/app/routes/vault/cluster.js
+++ b/ui/app/routes/vault/cluster.js
@@ -131,7 +131,8 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
return true;
},
loading(transition) {
- if (transition.queryParamsOnly || Ember.testing) {
+ const isSameRoute = transition.from?.name === transition.to?.name;
+ if (isSameRoute || Ember.testing) {
return;
}
// eslint-disable-next-line ember/no-controller-access-in-routes
diff --git a/ui/app/serializers/kubernetes/config.js b/ui/app/serializers/kubernetes/config.js
new file mode 100644
index 0000000000..fccf63dce6
--- /dev/null
+++ b/ui/app/serializers/kubernetes/config.js
@@ -0,0 +1,12 @@
+import ApplicationSerializer from '../application';
+
+export default class KubernetesConfigSerializer extends ApplicationSerializer {
+ primaryKey = 'backend';
+
+ serialize() {
+ const json = super.serialize(...arguments);
+ // remove backend value from payload
+ delete json.backend;
+ return json;
+ }
+}
diff --git a/ui/app/serializers/kubernetes/role.js b/ui/app/serializers/kubernetes/role.js
new file mode 100644
index 0000000000..dd6d2b1d6b
--- /dev/null
+++ b/ui/app/serializers/kubernetes/role.js
@@ -0,0 +1,12 @@
+import ApplicationSerializer from '../application';
+
+export default class KubernetesConfigSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+
+ serialize() {
+ const json = super.serialize(...arguments);
+ // remove backend value from payload
+ delete json.backend;
+ return json;
+ }
+}
diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss
index 6c9a9be2ac..d25b9793d7 100644
--- a/ui/app/styles/core/helpers.scss
+++ b/ui/app/styles/core/helpers.scss
@@ -92,9 +92,15 @@
.is-no-flex-grow {
flex-grow: 0 !important;
}
+.is-flex-half {
+ flex: 50%;
+}
.is-auto-width {
width: auto;
}
+.is-min-width-0 {
+ min-width: 0;
+}
.is-flex-between,
.is-grouped-split {
@@ -134,9 +140,20 @@
.has-tall-padding {
padding: 2.25rem;
}
+.has-side-padding-s {
+ padding-left: $spacing-s;
+ padding-right: $spacing-s;
+}
+.has-padding-m {
+ padding: $spacing-m;
+}
.has-top-bottom-margin {
margin: 1.25rem 0rem;
}
+.has-top-bottom-margin-negative-m {
+ margin-top: -$spacing-m;
+ margin-bottom: -$spacing-m;
+}
.is-sideless.has-short-padding {
padding: 0.25rem 1.25rem;
@@ -153,6 +170,13 @@
word-break: break-word;
white-space: pre-wrap;
}
+.truncate-second-line {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
.is-font-mono {
font-family: $family-monospace;
}
@@ -175,6 +199,9 @@
.has-background-transparent {
background: transparent !important;
}
+.has-background-gray-200 {
+ background-color: $ui-gray-200;
+}
@each $name, $pair in $colors {
$color: nth($pair, 1);
.has-background-#{$name} {
@@ -284,6 +311,12 @@ ul.bullet {
.has-text-grey-400 {
color: $ui-gray-400;
}
+.has-text-red {
+ color: $red;
+}
+.has-text-green {
+ color: $green;
+}
.has-text-align-center {
text-align: center;
}
diff --git a/ui/app/templates/components/json-editor.hbs b/ui/lib/core/addon/components/json-editor.hbs
similarity index 100%
rename from ui/app/templates/components/json-editor.hbs
rename to ui/lib/core/addon/components/json-editor.hbs
diff --git a/ui/app/components/json-editor.js b/ui/lib/core/addon/components/json-editor.js
similarity index 100%
rename from ui/app/components/json-editor.js
rename to ui/lib/core/addon/components/json-editor.js
diff --git a/ui/app/templates/components/kv-object-editor.hbs b/ui/lib/core/addon/components/kv-object-editor.hbs
similarity index 100%
rename from ui/app/templates/components/kv-object-editor.hbs
rename to ui/lib/core/addon/components/kv-object-editor.hbs
diff --git a/ui/app/components/kv-object-editor.js b/ui/lib/core/addon/components/kv-object-editor.js
similarity index 100%
rename from ui/app/components/kv-object-editor.js
rename to ui/lib/core/addon/components/kv-object-editor.js
diff --git a/ui/lib/core/addon/components/navigate-input.js b/ui/lib/core/addon/components/navigate-input.js
index 557cfcce9f..9cf9d80b23 100644
--- a/ui/lib/core/addon/components/navigate-input.js
+++ b/ui/lib/core/addon/components/navigate-input.js
@@ -188,12 +188,16 @@ export default Component.extend(FocusOnInsertMixin, {
actions: {
handleInput: function (filter) {
- this.filterDidChange(filter);
+ if (this.filterDidChange) {
+ this.filterDidChange(filter);
+ }
debounce(this, 'filterUpdated', filter, 200);
},
setFilterFocused: function (isFocused) {
- this.filterFocusDidChange(isFocused);
+ if (this.filterFocusDidChange) {
+ this.filterFocusDidChange(isFocused);
+ }
},
handleKeyPress: function (event) {
diff --git a/ui/app/helpers/format-duration.js b/ui/lib/core/addon/helpers/format-duration.js
similarity index 100%
rename from ui/app/helpers/format-duration.js
rename to ui/lib/core/addon/helpers/format-duration.js
diff --git a/ui/app/modifiers/code-mirror.js b/ui/lib/core/addon/modifiers/code-mirror.js
similarity index 100%
rename from ui/app/modifiers/code-mirror.js
rename to ui/lib/core/addon/modifiers/code-mirror.js
diff --git a/ui/lib/core/addon/templates/components/empty-state.hbs b/ui/lib/core/addon/templates/components/empty-state.hbs
index f96eb63841..c49a79802c 100644
--- a/ui/lib/core/addon/templates/components/empty-state.hbs
+++ b/ui/lib/core/addon/templates/components/empty-state.hbs
@@ -31,11 +31,11 @@
{{/if}}
{{#if (has-block)}}
-
+
{{yield}}
{{else if this.emptyActions}}
-
+
{{component this.emptyActions}}
{{/if}}
diff --git a/ui/lib/core/app/components/json-editor.js b/ui/lib/core/app/components/json-editor.js
new file mode 100644
index 0000000000..e54908d81b
--- /dev/null
+++ b/ui/lib/core/app/components/json-editor.js
@@ -0,0 +1 @@
+export { default } from 'core/components/json-editor';
diff --git a/ui/lib/core/app/components/kv-object-editor.js b/ui/lib/core/app/components/kv-object-editor.js
new file mode 100644
index 0000000000..ee01527969
--- /dev/null
+++ b/ui/lib/core/app/components/kv-object-editor.js
@@ -0,0 +1 @@
+export { default } from 'core/components/kv-object-editor';
diff --git a/ui/lib/core/app/helpers/format-duration.js b/ui/lib/core/app/helpers/format-duration.js
new file mode 100644
index 0000000000..425737298a
--- /dev/null
+++ b/ui/lib/core/app/helpers/format-duration.js
@@ -0,0 +1 @@
+export { default } from 'core/helpers/format-duration';
diff --git a/ui/lib/core/app/modifiers/code-mirror.js b/ui/lib/core/app/modifiers/code-mirror.js
new file mode 100644
index 0000000000..5d772783cf
--- /dev/null
+++ b/ui/lib/core/app/modifiers/code-mirror.js
@@ -0,0 +1 @@
+export { default } from 'core/modifiers/code-mirror';
diff --git a/ui/lib/core/package.json b/ui/lib/core/package.json
index 49af675687..b4326d2e24 100644
--- a/ui/lib/core/package.json
+++ b/ui/lib/core/package.json
@@ -26,6 +26,8 @@
"ember-wormhole": "*",
"escape-string-regexp": "*",
"@hashicorp/ember-flight-icons": "*",
- "@hashicorp/flight-icons": "*"
+ "@hashicorp/flight-icons": "*",
+ "codemirror": "*",
+ "ember-modifier": "*"
}
}
diff --git a/ui/lib/kubernetes/addon/components/config-cta.hbs b/ui/lib/kubernetes/addon/components/config-cta.hbs
new file mode 100644
index 0000000000..f810534f95
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/config-cta.hbs
@@ -0,0 +1,9 @@
+
+
+ Configure Kubernetes
+
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/configuration.hbs b/ui/lib/kubernetes/addon/components/page/configuration.hbs
new file mode 100644
index 0000000000..ebe636985a
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/configuration.hbs
@@ -0,0 +1,51 @@
+
+
+ {{if @config "Edit configuration" "Configure Kubernetes"}}
+
+
+
+{{#if @config}}
+ {{#if @config.disableLocalCaJwt}}
+
+ {{#if @config.kubernetesCaCert}}
+
+
+
+
+
+
+
+
+ PEM Format
+
+
+ {{@config.kubernetesCaCert}}
+
+
+
+
+
+
+
+
+
+
+ {{/if}}
+ {{else}}
+
+
+
+ These details were successfully inferred from Vault’s kubernetes environment and were not explicity set in this
+ config.
+
+
+ {{/if}}
+{{else}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/configure.hbs b/ui/lib/kubernetes/addon/components/page/configure.hbs
new file mode 100644
index 0000000000..cab576c88c
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/configure.hbs
@@ -0,0 +1,117 @@
+
+
+
+ Configure kubernetes
+
+
+
+
+
+
+
+ To customize your configuration, specify the type of Kubernetes cluster that credentials will be generated for.
+
+
+
+
+
+
+
+
+ {{#if @model.disableLocalCaJwt}}
+
+ {{#each @model.formFields as |attr|}}
+
+ {{/each}}
+ {{else if (eq this.inferredState "success")}}
+
+
Configuration values were inferred successfully.
+ {{else if (eq this.inferredState "error")}}
+
+
+ Vault could not infer a configuration from your environment variables. Check your configuration file to edit or delete
+ them, or configure manually.
+
+ {{else}}
+
+ Configuration values can be inferred from the pod and your local environment variables.
+
+
+
+ Get config values
+
+
+ {{/if}}
+
+
+
+
+
+
+ Save
+
+
+ Back
+
+
+
+{{#if this.showConfirm}}
+
+
+
+ Making changes to your configuration may affect how Vault will reach the Kubernetes API and authenticate with it. Are
+ you sure?
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/configure.js b/ui/lib/kubernetes/addon/components/page/configure.js
new file mode 100644
index 0000000000..52ab898d6d
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/configure.js
@@ -0,0 +1,83 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
+import errorMessage from 'vault/utils/error-message';
+
+/**
+ * @module Configure
+ * ConfigurePage component is a child component to configure kubernetes secrets engine.
+ *
+ * @param {object} model - config model that contains kubernetes configuration
+ */
+export default class ConfigurePageComponent extends Component {
+ @service router;
+ @service store;
+
+ @tracked inferredState;
+ @tracked modelValidations;
+ @tracked error;
+ @tracked showConfirm;
+
+ constructor() {
+ super(...arguments);
+ if (!this.args.model.isNew && !this.args.model.disableLocalCaJwt) {
+ this.inferredState = 'success';
+ }
+ }
+
+ get isDisabled() {
+ if (!this.args.model.disableLocalCaJwt && this.inferredState !== 'success') {
+ return true;
+ }
+ return this.save.isRunning || this.fetchInferred.isRunning;
+ }
+
+ leave(route) {
+ this.router.transitionTo(`vault.cluster.secrets.backend.kubernetes.${route}`);
+ }
+
+ @action
+ onRadioSelect(value) {
+ this.args.model.disableLocalCaJwt = value;
+ this.inferredState = null;
+ }
+
+ @task
+ @waitFor
+ *fetchInferred() {
+ try {
+ yield this.store.adapterFor('kubernetes/config').checkConfigVars(this.args.model.backend);
+ this.inferredState = 'success';
+ } catch {
+ this.inferredState = 'error';
+ }
+ }
+
+ @task
+ @waitFor
+ *save() {
+ if (!this.args.model.isNew && !this.showConfirm) {
+ this.showConfirm = true;
+ return;
+ }
+ this.showConfirm = false;
+ try {
+ yield this.args.model.save();
+ this.leave('configuration');
+ } catch (error) {
+ this.error = errorMessage(error, 'Error saving configuration. Please try again or contact support');
+ }
+ }
+
+ @action
+ cancel() {
+ const { model } = this.args;
+ const transitionRoute = model.isNew ? 'overview' : 'configuration';
+ const cleanupMethod = model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ model[cleanupMethod]();
+ this.leave(transitionRoute);
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/page/credentials.hbs b/ui/lib/kubernetes/addon/components/page/credentials.hbs
new file mode 100644
index 0000000000..84d7d37f8c
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/credentials.hbs
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+ {{if this.credentials "Credentials" "Generate credentials"}}
+
+
+
+
+{{#if this.credentials}}
+
+
+
+
+ Done
+
+
+{{else}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/credentials.js b/ui/lib/kubernetes/addon/components/page/credentials.js
new file mode 100644
index 0000000000..584879616c
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/credentials.js
@@ -0,0 +1,68 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
+import { add } from 'date-fns';
+import errorMessage from 'vault/utils/error-message';
+
+/**
+ * @module Credentials
+ * CredentialsPage component is a child component to show the generate and view
+ * credentials form.
+ *
+ * @param {string} roleName - role name as a string
+ * @param {string} backend - backend as a string
+ * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route
+ */
+export default class CredentialsPageComponent extends Component {
+ @service store;
+ @service router;
+
+ @tracked ttl = '';
+ @tracked clusterRoleBinding = false;
+ @tracked kubernetesNamespace;
+ @tracked error;
+
+ @tracked credentials;
+
+ get leaseExpiry() {
+ return add(new Date(), { seconds: this.credentials.lease_duration });
+ }
+
+ @action
+ cancel() {
+ this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.roles.role.details');
+ }
+
+ @action
+ setKubernetesNamespace({ target }) {
+ this.kubernetesNamespace = target.value;
+ }
+
+ @action
+ updateTtl({ goSafeTimeString }) {
+ this.ttl = goSafeTimeString;
+ }
+
+ @task
+ @waitFor
+ *fetchCredentials(event) {
+ event.preventDefault();
+ try {
+ const payload = {
+ role: this.args.roleName,
+ kubernetes_namespace: this.kubernetesNamespace,
+ cluster_role_binding: this.clusterRoleBinding,
+ ttl: this.ttl,
+ };
+
+ this.credentials = yield this.store
+ .adapterFor('kubernetes/role')
+ .generateCredentials(this.args.backend, payload);
+ } catch (error) {
+ this.error = errorMessage(error);
+ }
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/page/overview.hbs b/ui/lib/kubernetes/addon/components/page/overview.hbs
new file mode 100644
index 0000000000..45a5bdf975
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/overview.hbs
@@ -0,0 +1,54 @@
+
+ Configure Kubernetes
+
+
+{{#if @config}}
+
+
+
+
+
+ Roles
+
+ {{#if @roles.length}}
+ View Roles
+ {{else}}
+ Create Role
+ {{/if}}
+
+
The number of Vault roles being used to generate Kubernetes credentials.
+
+ {{or @roles.length "None"}}
+
+
+
+
+ Generate credentials
+
+
Quickly generate credentials by typing the role name.
+
+
+
+ Generate
+
+
+
+
+
+{{else}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/overview.js b/ui/lib/kubernetes/addon/components/page/overview.js
new file mode 100644
index 0000000000..649926d166
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/overview.js
@@ -0,0 +1,41 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+/**
+ * @module Overview
+ * OverviewPage component is a child component to overview kubernetes secrets engine.
+ *
+ * @param {object} config - config model that contains kubernetes configuration
+ * @param {object} backend - backend model that contains kubernetes configuration
+ * @param {array} roles - array of roles
+ * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route
+ */
+
+export default class OverviewPageComponent extends Component {
+ @service router;
+
+ @tracked selectedRole = null;
+ @tracked roleOptions = [];
+
+ constructor() {
+ super(...arguments);
+ this.roleOptions = this.args.roles.map((role) => {
+ return { name: role.name, id: role.name };
+ });
+ }
+
+ @action
+ selectRole([roleName]) {
+ this.selectedRole = roleName;
+ }
+
+ @action
+ generateCredential() {
+ this.router.transitionTo(
+ 'vault.cluster.secrets.backend.kubernetes.roles.role.credentials',
+ this.selectedRole
+ );
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs
new file mode 100644
index 0000000000..5e71eb5e8e
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.hbs
@@ -0,0 +1,139 @@
+
+
+
+ {{if @model.isNew "Create role" "Edit role"}}
+
+
+
+
+
+
+
+ A role in Vault dictates what will be generated for Kubernetes and what kind of rules will be used to do so. It is not a
+ Kubernetes role.
+
+
+
+ {{#each this.generationPreferences as |pref|}}
+
+ {{/each}}
+
+
+
+
+ Role options
+
+
+
+
+{{#if @model.generationPreference}}
+
+{{else}}
+
+{{/if}}
+
+
+
+
+
+ Save
+
+
+ Back
+
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js
new file mode 100644
index 0000000000..ad6e4c363c
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/role/create-and-edit.js
@@ -0,0 +1,163 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
+import { getRules } from '../../../utils/generated-role-rules';
+import { htmlSafe } from '@ember/template';
+import errorMessage from 'vault/utils/error-message';
+
+/**
+ * @module CreateAndEditRolePage
+ * CreateAndEditRolePage component is a child component for create and edit role pages.
+ *
+ * @param {object} model - role model that contains role record and backend
+ */
+
+export default class CreateAndEditRolePageComponent extends Component {
+ @service router;
+ @service flashMessages;
+
+ @tracked roleRulesTemplates;
+ @tracked selectedTemplateId;
+ @tracked modelValidations;
+
+ constructor() {
+ super(...arguments);
+ this.initRoleRules();
+ // if editing and annotations or labels exist expand the section
+ const { extraAnnotations, extraLabels } = this.args.model;
+ if (extraAnnotations || extraLabels) {
+ this.showAnnotations = true;
+ }
+ }
+
+ get generationPreferences() {
+ return [
+ {
+ title: 'Generate token only using existing service account',
+ description:
+ 'Enter a service account that already exists in Kubernetes and Vault will dynamically generate a token.',
+ value: 'basic',
+ },
+ {
+ title: 'Generate token, service account, and role binding objects',
+ description:
+ 'Enter a pre-existing role (or ClusterRole) to use. Vault will generate a token, a service account and role binding objects.',
+ value: 'expanded',
+ },
+ {
+ title: 'Generate entire Kubernetes object chain',
+ description:
+ 'Vault will generate the entire chain— a role, a token, a service account, and role binding objects— based on rules you supply.',
+ value: 'full',
+ },
+ ];
+ }
+
+ get extraFields() {
+ return [
+ {
+ type: 'annotations',
+ key: 'extraAnnotations',
+ description: 'Attach arbitrary non-identifying metadata to objects.',
+ },
+ {
+ type: 'labels',
+ key: 'extraLabels',
+ description:
+ 'Labels specify identifying attributes of objects that are meaningful and relevant to users.',
+ },
+ ];
+ }
+
+ get roleRulesHelpText() {
+ const message =
+ 'This specifies the Role or ClusterRole rules to use when generating a role. Kubernetes documentation is';
+ const link =
+ '
available here>';
+ return htmlSafe(`${message} ${link}.`);
+ }
+
+ @action
+ initRoleRules() {
+ // first check if generatedRoleRules matches one of the templates, the user may have chosen a template and not made changes
+ // in this case we need to select the corresponding template in the dropdown
+ // if there is no match then replace the example rules with the user defined value for no template option
+ const { generatedRoleRules } = this.args.model;
+ const rulesTemplates = getRules();
+ this.selectedTemplateId = '1';
+
+ if (generatedRoleRules) {
+ const template = rulesTemplates.findBy('rules', generatedRoleRules);
+ if (template) {
+ this.selectedTemplateId = template.id;
+ } else {
+ rulesTemplates.findBy('id', '1').rules = generatedRoleRules;
+ }
+ }
+ this.roleRulesTemplates = rulesTemplates;
+ }
+
+ @action
+ resetRoleRules() {
+ this.roleRulesTemplates = getRules();
+ }
+
+ @action
+ selectTemplate(event) {
+ this.selectedTemplateId = event.target.value;
+ }
+
+ @action
+ changePreference(pref) {
+ if (pref === 'full') {
+ this.initRoleRules();
+ } else {
+ this.selectedTemplateId = null;
+ }
+ this.args.model.generationPreference = pref;
+ }
+
+ @task
+ @waitFor
+ *save() {
+ try {
+ // set generatedRoleRoles to value of selected template
+ const selectedTemplate = this.roleRulesTemplates.findBy('id', this.selectedTemplateId);
+ if (selectedTemplate) {
+ this.args.model.generatedRoleRules = selectedTemplate.rules;
+ }
+ yield this.args.model.save();
+ this.router.transitionTo(
+ 'vault.cluster.secrets.backend.kubernetes.roles.role.details',
+ this.args.model.name
+ );
+ } catch (error) {
+ const message = errorMessage(error, 'Error saving role. Please try again or contact support');
+ this.flashMessages.danger(message);
+ }
+ }
+
+ @action
+ async onSave(event) {
+ event.preventDefault();
+ const { isValid, state } = await this.args.model.validate();
+ if (isValid) {
+ this.modelValidations = null;
+ this.save.perform();
+ } else {
+ this.flashMessages.info('Save not performed. Check form for errors');
+ this.modelValidations = state;
+ }
+ }
+
+ @action
+ cancel() {
+ const { model } = this.args;
+ const method = model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ model[method]();
+ this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.roles');
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/page/role/details.hbs b/ui/lib/kubernetes/addon/components/page/role/details.hbs
new file mode 100644
index 0000000000..9001a7c781
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/role/details.hbs
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ {{@model.name}}
+
+
+
+
+
+
+ {{#if @model.canDelete}}
+
+ Delete role
+
+
+ {{/if}}
+ {{#if @model.canGenerateCreds}}
+
+ Generate credentials
+
+ {{/if}}
+ {{#if @model.canEdit}}
+
+ Edit role
+
+ {{/if}}
+
+
+
+{{#each @model.filteredFormFields as |field|}}
+ {{#let (get @model field.name) as |value|}}
+
+ {{/let}}
+{{/each}}
+
+{{#if @model.generatedRoleRules}}
+
+
Generated role rules
+
+
+{{/if}}
+
+{{#each this.extraFields as |field|}}
+
+
{{field.label}}
+ {{#each-in (get @model field.key) as |key value|}}
+
+ {{/each-in}}
+
+{{/each}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/role/details.js b/ui/lib/kubernetes/addon/components/page/role/details.js
new file mode 100644
index 0000000000..b6084b0e19
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/role/details.js
@@ -0,0 +1,39 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import errorMessage from 'vault/utils/error-message';
+
+/**
+ * @module RoleDetailsPage
+ * RoleDetailsPage component is a child component for create and edit role pages.
+ *
+ * @param {object} model - role model that contains role record and backend
+ * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route
+ */
+
+export default class RoleDetailsPageComponent extends Component {
+ @service router;
+ @service flashMessages;
+
+ get extraFields() {
+ const fields = [];
+ if (this.args.model.extraAnnotations) {
+ fields.push({ label: 'Annotations', key: 'extraAnnotations' });
+ }
+ if (this.args.model.extraLabels) {
+ fields.push({ label: 'Labels', key: 'extraLabels' });
+ }
+ return fields;
+ }
+
+ @action
+ async delete() {
+ try {
+ await this.args.model.destroyRecord();
+ this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.roles');
+ } catch (error) {
+ const message = errorMessage(error, 'Unable to delete role. Please try again or contact support');
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/page/roles.hbs b/ui/lib/kubernetes/addon/components/page/roles.hbs
new file mode 100644
index 0000000000..8feff9dd53
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/roles.hbs
@@ -0,0 +1,76 @@
+
+
+ Create role
+
+
+
+{{#if (not @config)}}
+
+{{else if (not @roles)}}
+ {{#if @filterValue}}
+
+ {{else}}
+
+
+ Create role
+
+
+ {{/if}}
+{{else}}
+
+ {{#each @roles as |role|}}
+
+
+
+ {{role.name}}
+
+
+ {{#if role.rolesPath.isLoading}}
+
+
+ loading
+
+
+ {{else}}
+
+
+ Details
+
+
+
+
+ Edit
+
+
+
+
+
+ {{/if}}
+
+
+ {{/each}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/components/page/roles.js b/ui/lib/kubernetes/addon/components/page/roles.js
new file mode 100644
index 0000000000..dd846fe287
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/page/roles.js
@@ -0,0 +1,35 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import errorMessage from 'vault/utils/error-message';
+
+/**
+ * @module Roles
+ * RolesPage component is a child component to show list of roles
+ *
+ * @param {array} roles - array of roles
+ * @param {object} config - config model that contains kubernetes configuration
+ * @param {array} pageFilter - array of filtered roles
+ * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route
+ */
+export default class RolesPageComponent extends Component {
+ @service flashMessages;
+
+ get mountPoint() {
+ return getOwner(this).mountPoint;
+ }
+
+ @action
+ async onDelete(model) {
+ try {
+ const message = `Successfully deleted role ${model.name}`;
+ await model.destroyRecord();
+ this.args.roles.removeObject(model);
+ this.flashMessages.success(message);
+ } catch (error) {
+ const message = errorMessage(error, 'Error deleting role. Please try again or contact support');
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/lib/kubernetes/addon/components/tab-page-header.hbs b/ui/lib/kubernetes/addon/components/tab-page-header.hbs
new file mode 100644
index 0000000000..0048a90ff1
--- /dev/null
+++ b/ui/lib/kubernetes/addon/components/tab-page-header.hbs
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+ {{@model.id}}
+
+
+
+
+
+
+
+ Overview
+ Roles
+ Configuration
+
+
+
+
+
+ {{#if @filterRoles}}
+
+
+
+ {{/if}}
+
+ {{yield}}
+
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/controllers/roles/index.js b/ui/lib/kubernetes/addon/controllers/roles/index.js
new file mode 100644
index 0000000000..b56cb379be
--- /dev/null
+++ b/ui/lib/kubernetes/addon/controllers/roles/index.js
@@ -0,0 +1,5 @@
+import Controller from '@ember/controller';
+
+export default class KubernetesRolesController extends Controller {
+ queryParams = ['pageFilter'];
+}
diff --git a/ui/lib/kubernetes/addon/engine.js b/ui/lib/kubernetes/addon/engine.js
new file mode 100644
index 0000000000..051e082b82
--- /dev/null
+++ b/ui/lib/kubernetes/addon/engine.js
@@ -0,0 +1,19 @@
+import Engine from '@ember/engine';
+
+import loadInitializers from 'ember-load-initializers';
+import Resolver from 'ember-resolver';
+
+import config from './config/environment';
+
+const { modulePrefix } = config;
+
+export default class KubernetesEngine extends Engine {
+ modulePrefix = modulePrefix;
+ Resolver = Resolver;
+ dependencies = {
+ services: ['router', 'store', 'secret-mount-path', 'flashMessages'],
+ externalRoutes: ['secrets'],
+ };
+}
+
+loadInitializers(KubernetesEngine, modulePrefix);
diff --git a/ui/lib/kubernetes/addon/routes.js b/ui/lib/kubernetes/addon/routes.js
new file mode 100644
index 0000000000..291b32c6b9
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes.js
@@ -0,0 +1,15 @@
+import buildRoutes from 'ember-engines/routes';
+
+export default buildRoutes(function () {
+ this.route('overview');
+ this.route('roles', function () {
+ this.route('create');
+ this.route('role', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('edit');
+ this.route('credentials');
+ });
+ });
+ this.route('configure');
+ this.route('configuration');
+});
diff --git a/ui/lib/kubernetes/addon/routes/configuration.js b/ui/lib/kubernetes/addon/routes/configuration.js
new file mode 100644
index 0000000000..6c53330b99
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/configuration.js
@@ -0,0 +1,19 @@
+import FetchConfigRoute from './fetch-config';
+
+export default class KubernetesConfigureRoute extends FetchConfigRoute {
+ model() {
+ return {
+ backend: this.modelFor('application'),
+ config: this.configModel,
+ };
+ }
+
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: resolvedModel.backend.id },
+ ];
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/configure.js b/ui/lib/kubernetes/addon/routes/configure.js
new file mode 100644
index 0000000000..abf6e4045b
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/configure.js
@@ -0,0 +1,8 @@
+import FetchConfigRoute from './fetch-config';
+
+export default class KubernetesConfigureRoute extends FetchConfigRoute {
+ async model() {
+ const backend = this.secretMountPath.get();
+ return this.configModel || this.store.createRecord('kubernetes/config', { backend });
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/fetch-config.js b/ui/lib/kubernetes/addon/routes/fetch-config.js
new file mode 100644
index 0000000000..3a846d91a0
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/fetch-config.js
@@ -0,0 +1,31 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+/**
+ * the overview, configure, configuration and roles routes all need to be aware of the config for the engine
+ * if the user has not configured they are prompted to do so in each of the routes
+ * this route can be extended so the check happens in the beforeModel hook since that may change what is returned from the model hook
+ */
+
+export default class KubernetesFetchConfigRoute extends Route {
+ @service store;
+ @service secretMountPath;
+
+ configModel = null;
+
+ async beforeModel() {
+ const backend = this.secretMountPath.get();
+ // check the store for record first
+ this.configModel = this.store.peekRecord('kubernetes/config', backend);
+ if (!this.configModel) {
+ return this.store
+ .queryRecord('kubernetes/config', { backend })
+ .then((record) => {
+ this.configModel = record;
+ })
+ .catch(() => {
+ // it's ok! we don't need to transition to the error route
+ });
+ }
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/index.js b/ui/lib/kubernetes/addon/routes/index.js
new file mode 100644
index 0000000000..3cc0c89dac
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/index.js
@@ -0,0 +1,10 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class KubernetesRoute extends Route {
+ @service router;
+
+ redirect() {
+ this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.overview');
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/overview.js b/ui/lib/kubernetes/addon/routes/overview.js
new file mode 100644
index 0000000000..bcae6f5e9a
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/overview.js
@@ -0,0 +1,22 @@
+import { hash } from 'rsvp';
+import FetchConfigRoute from './fetch-config';
+
+export default class KubernetesOverviewRoute extends FetchConfigRoute {
+ async model() {
+ const backend = this.secretMountPath.get();
+ return hash({
+ config: this.configModel,
+ backend: this.modelFor('application'),
+ roles: this.store.query('kubernetes/role', { backend }).catch(() => []),
+ });
+ }
+
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: resolvedModel.backend.id },
+ ];
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/create.js b/ui/lib/kubernetes/addon/routes/roles/create.js
new file mode 100644
index 0000000000..be35f940f8
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/create.js
@@ -0,0 +1,12 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class KubernetesRolesCreateRoute extends Route {
+ @service store;
+ @service secretMountPath;
+
+ model() {
+ const backend = this.secretMountPath.get();
+ return this.store.createRecord('kubernetes/role', { backend });
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/index.js b/ui/lib/kubernetes/addon/routes/roles/index.js
new file mode 100644
index 0000000000..da7a4e4ae2
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/index.js
@@ -0,0 +1,31 @@
+import FetchConfigRoute from '../fetch-config';
+import { hash } from 'rsvp';
+
+export default class KubernetesRolesRoute extends FetchConfigRoute {
+ model(params, transition) {
+ // filter roles based on pageFilter value
+ const { pageFilter } = transition.to.queryParams;
+ const roles = this.store
+ .query('kubernetes/role', { backend: this.secretMountPath.get() })
+ .then((models) =>
+ pageFilter
+ ? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase()))
+ : models
+ )
+ .catch(() => []);
+ return hash({
+ backend: this.modelFor('application'),
+ config: this.configModel,
+ roles,
+ });
+ }
+
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: resolvedModel.backend.id },
+ ];
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/role/credentials.js b/ui/lib/kubernetes/addon/routes/roles/role/credentials.js
new file mode 100644
index 0000000000..0827dd8368
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/role/credentials.js
@@ -0,0 +1,23 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+export default class KubernetesRoleCredentialsRoute extends Route {
+ @service secretMountPath;
+
+ model() {
+ return {
+ roleName: this.paramsFor('roles.role').name,
+ backend: this.secretMountPath.get(),
+ };
+ }
+
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: resolvedModel.backend, route: 'overview' },
+ { label: 'roles', route: 'roles' },
+ { label: resolvedModel.roleName, route: 'roles.role.details' },
+ { label: 'credentials' },
+ ];
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/role/details.js b/ui/lib/kubernetes/addon/routes/roles/role/details.js
new file mode 100644
index 0000000000..a2969104dd
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/role/details.js
@@ -0,0 +1,23 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class KubernetesRoleDetailsRoute extends Route {
+ @service store;
+ @service secretMountPath;
+
+ model() {
+ const backend = this.secretMountPath.get();
+ const { name } = this.paramsFor('roles.role');
+ return this.store.queryRecord('kubernetes/role', { backend, name });
+ }
+
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: resolvedModel.backend, route: 'overview' },
+ { label: 'roles', route: 'roles' },
+ { label: resolvedModel.name },
+ ];
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/role/edit.js b/ui/lib/kubernetes/addon/routes/roles/role/edit.js
new file mode 100644
index 0000000000..503e555a15
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/role/edit.js
@@ -0,0 +1,13 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class KubernetesRoleEditRoute extends Route {
+ @service store;
+ @service secretMountPath;
+
+ model() {
+ const backend = this.secretMountPath.get();
+ const { name } = this.paramsFor('roles.role');
+ return this.store.queryRecord('kubernetes/role', { backend, name });
+ }
+}
diff --git a/ui/lib/kubernetes/addon/routes/roles/role/index.js b/ui/lib/kubernetes/addon/routes/roles/role/index.js
new file mode 100644
index 0000000000..2f08bd2bce
--- /dev/null
+++ b/ui/lib/kubernetes/addon/routes/roles/role/index.js
@@ -0,0 +1,10 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class KubernetesRoleRoute extends Route {
+ @service router;
+
+ redirect() {
+ this.router.transitionTo('vault.cluster.secrets.backend.kubernetes.roles.role.details');
+ }
+}
diff --git a/ui/lib/kubernetes/addon/templates/configuration.hbs b/ui/lib/kubernetes/addon/templates/configuration.hbs
new file mode 100644
index 0000000000..5b84c30104
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/configuration.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/configure.hbs b/ui/lib/kubernetes/addon/templates/configure.hbs
new file mode 100644
index 0000000000..ca54b89241
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/configure.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/overview.hbs b/ui/lib/kubernetes/addon/templates/overview.hbs
new file mode 100644
index 0000000000..2d1aeec8b5
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/overview.hbs
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/roles/create.hbs b/ui/lib/kubernetes/addon/templates/roles/create.hbs
new file mode 100644
index 0000000000..9c66bafe25
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/roles/create.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/roles/index.hbs b/ui/lib/kubernetes/addon/templates/roles/index.hbs
new file mode 100644
index 0000000000..f255161c14
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/roles/index.hbs
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/roles/role/credentials.hbs b/ui/lib/kubernetes/addon/templates/roles/role/credentials.hbs
new file mode 100644
index 0000000000..3562e18edb
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/roles/role/credentials.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/roles/role/details.hbs b/ui/lib/kubernetes/addon/templates/roles/role/details.hbs
new file mode 100644
index 0000000000..e7ad643c75
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/roles/role/details.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/templates/roles/role/edit.hbs b/ui/lib/kubernetes/addon/templates/roles/role/edit.hbs
new file mode 100644
index 0000000000..9c66bafe25
--- /dev/null
+++ b/ui/lib/kubernetes/addon/templates/roles/role/edit.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/lib/kubernetes/addon/utils/generated-role-rules.js b/ui/lib/kubernetes/addon/utils/generated-role-rules.js
new file mode 100644
index 0000000000..4a89578de5
--- /dev/null
+++ b/ui/lib/kubernetes/addon/utils/generated-role-rules.js
@@ -0,0 +1,150 @@
+const example = `# The below is an example that you can use as a starting point.
+#
+# rules:
+# - apiGroups: [""]
+# resources: ["serviceaccounts", "serviceaccounts/token"]
+# verbs: ["create", "update", "delete"]
+# - apiGroups: ["rbac.authorization.k8s.io"]
+# resources: ["rolebindings", "clusterrolebindings"]
+# verbs: ["create", "update", "delete"]
+# - apiGroups: ["rbac.authorization.k8s.io"]
+# resources: ["roles", "clusterroles"]
+# verbs: ["bind", "escalate", "create", "update", "delete"]
+`;
+
+const readResources = `rules:
+- apiGroups: [""]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["extensions"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["apps"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["batch"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["policy"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["networking.k8s.io"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["autoscaling"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+`;
+
+const editResources = `rules:
+- apiGroups: [""]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: [""]
+ resources:
+ ["pods", "pods/attach", "pods/exec", "pods/portforward", "pods/proxy"]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: [""]
+ resources:
+ [
+ "configmaps",
+ "events",
+ "persistentvolumeclaims",
+ "replicationcontrollers",
+ "replicationcontrollers/scale",
+ "secrets",
+ "serviceaccounts",
+ "services",
+ "services/proxy",
+ ]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: [""]
+ resources: ["serviceaccounts/token"]
+ verbs: ["create"]
+- apiGroups: ["extensions"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["extensions"]
+ resources:
+ [
+ "daemonsets",
+ "deployments",
+ "deployments/rollback",
+ "deployments/scale",
+ "ingresses",
+ "networkpolicies",
+ "replicasets",
+ "replicasets/scale",
+ "replicationcontrollers/scale",
+ ]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: ["apps"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["apps"]
+ resources:
+ [
+ "daemonsets",
+ "deployments",
+ "deployments/rollback",
+ "deployments/scale",
+ "replicasets",
+ "replicasets/scale",
+ "statefulsets",
+ "statefulsets/scale",
+ ]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: ["batch"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["batch"]
+ resources: ["cronjobs", "jobs"]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: ["policy"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["policy"]
+ resources: ["poddisruptionbudgets"]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: ["networking.k8s.io"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["networking.k8s.io"]
+ resources: ["ingresses", "networkpolicies"]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+- apiGroups: ["autoscaling"]
+ resources: ["*"]
+ verbs: ["get", "watch", "list"]
+- apiGroups: ["autoscaling"]
+ resources: ["horizontalpodautoscalers"]
+ verbs: ["create", "delete", "deletecollection", "patch", "update"]
+`;
+
+const updatePods = `rules:
+- apiGroups: [""]
+ resources: ["secrets", "configmaps", "pods", "endpoints"]
+ verbs: ["get", "watch", "list", "create", "delete", "deletecollection", "patch", "update"]
+`;
+
+const updateServices = `rules:
+- apiGroups: [""]
+ resources: ["secrets", "services"]
+ verbs: ["get", "watch", "list", "create", "delete", "deletecollection", "patch", "update"]
+`;
+
+const usePolicies = `rules:
+- apiGroups: ['policy']
+ resources: ['podsecuritypolicies']
+ verbs: ['use']
+ resourceNames:
+ -
+`;
+
+export const getRules = () => [
+ { id: '1', label: 'No template', rules: example },
+ { id: '2', label: 'Read resources in a namespace', rules: readResources },
+ { id: '3', label: 'Edit resources in a namespace', rules: editResources },
+ { id: '4', label: 'Update pods, secrets, configmaps, and endpoints', rules: updatePods },
+ { id: '5', label: 'Update services and secrets', rules: updateServices },
+ { id: '6', label: 'Use pod security policies', rules: usePolicies },
+];
diff --git a/ui/lib/kubernetes/config/environment.js b/ui/lib/kubernetes/config/environment.js
new file mode 100644
index 0000000000..f7521fb50e
--- /dev/null
+++ b/ui/lib/kubernetes/config/environment.js
@@ -0,0 +1,11 @@
+/* eslint-env node */
+'use strict';
+
+module.exports = function (environment) {
+ const ENV = {
+ modulePrefix: 'kubernetes',
+ environment,
+ };
+
+ return ENV;
+};
diff --git a/ui/lib/kubernetes/index.js b/ui/lib/kubernetes/index.js
new file mode 100644
index 0000000000..aebbb0b832
--- /dev/null
+++ b/ui/lib/kubernetes/index.js
@@ -0,0 +1,15 @@
+/* eslint-env node */
+/* eslint-disable node/no-extraneous-require */
+'use strict';
+
+const { buildEngine } = require('ember-engines/lib/engine-addon');
+
+module.exports = buildEngine({
+ name: 'kubernetes',
+ lazyLoading: {
+ enabled: false,
+ },
+ isDevelopingAddon() {
+ return true;
+ },
+});
diff --git a/ui/lib/kubernetes/package.json b/ui/lib/kubernetes/package.json
new file mode 100644
index 0000000000..068934f6f1
--- /dev/null
+++ b/ui/lib/kubernetes/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "kubernetes",
+ "keywords": [
+ "ember-addon",
+ "ember-engine"
+ ],
+ "dependencies": {
+ "ember-cli-htmlbars": "*",
+ "ember-cli-babel": "*",
+ "ember-concurrency": "*",
+ "@ember/test-waiters": "*",
+ "ember-inflector": "*"
+ },
+ "ember-addon": {
+ "paths": [
+ "../core"
+ ]
+ }
+}
diff --git a/ui/mirage/factories/kubernetes-config.js b/ui/mirage/factories/kubernetes-config.js
new file mode 100644
index 0000000000..08c5f78510
--- /dev/null
+++ b/ui/mirage/factories/kubernetes-config.js
@@ -0,0 +1,11 @@
+import { Factory } from 'ember-cli-mirage';
+
+export default Factory.extend({
+ kubernetes_host: 'https://192.168.99.100:8443',
+ kubernetes_ca_cert:
+ '-----BEGIN CERTIFICATE-----\nMIIDNTCCAh2gApGgAwIBAgIULNEk+01LpkDeJujfsAgIULNEkAgIULNEckApGgAwIBAg+01LpkDeJuj\n-----END CERTIFICATE-----',
+ disable_local_ca_jwt: true,
+
+ // property used only for record lookup and filtered from response payload
+ path: null,
+});
diff --git a/ui/mirage/factories/kubernetes-role.js b/ui/mirage/factories/kubernetes-role.js
new file mode 100644
index 0000000000..7e2f8cb89a
--- /dev/null
+++ b/ui/mirage/factories/kubernetes-role.js
@@ -0,0 +1,54 @@
+import { Factory, trait } from 'ember-cli-mirage';
+
+const generated_role_rules = `rules:
+- apiGroups: [""]
+ resources: ["secrets", "services"]
+ verbs: ["get", "watch", "list", "create", "delete", "deletecollection", "patch", "update"]
+`;
+const name_template = '{{.FieldName | lowercase}}';
+const extra_annotations = { foo: 'bar', baz: 'qux' };
+const extra_labels = { foobar: 'baz', barbaz: 'foo' };
+
+export default Factory.extend({
+ name: (i) => `role-${i}`,
+ allowed_kubernetes_namespaces: '*',
+ allowed_kubernetes_namespace_selector: '',
+ token_max_ttl: 86400,
+ token_default_ttl: 600,
+ service_account_name: 'default',
+ kubernetes_role_name: '',
+ kubernetes_role_type: 'Role',
+ generated_role_rules: '',
+ name_template: '',
+ extra_annotations: null,
+ extra_labels: null,
+
+ afterCreate(record) {
+ // only one of these three props can be defined
+ if (record.generated_role_rules) {
+ record.service_account_name = null;
+ record.kubernetes_role_name = null;
+ } else if (record.kubernetes_role_name) {
+ record.service_account_name = null;
+ record.generated_role_rules = null;
+ } else if (record.service_account_name) {
+ record.generated_role_rules = null;
+ record.kubernetes_role_name = null;
+ }
+ },
+ withRoleName: trait({
+ service_account_name: null,
+ generated_role_rules: null,
+ kubernetes_role_name: 'vault-k8s-secrets-role',
+ extra_annotations,
+ name_template,
+ }),
+ withRoleRules: trait({
+ service_account_name: null,
+ kubernetes_role_name: null,
+ generated_role_rules,
+ extra_annotations,
+ extra_labels,
+ name_template,
+ }),
+});
diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js
index 8c28ffa4de..c51ab5c1a8 100644
--- a/ui/mirage/handlers/index.js
+++ b/ui/mirage/handlers/index.js
@@ -9,5 +9,6 @@ import mfaConfig from './mfa-config';
import mfaLogin from './mfa-login';
import oidcConfig from './oidc-config';
import hcpLink from './hcp-link';
+import kubernetes from './kubernetes';
-export { base, activity, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink };
+export { base, activity, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink, kubernetes };
diff --git a/ui/mirage/handlers/kubernetes.js b/ui/mirage/handlers/kubernetes.js
new file mode 100644
index 0000000000..e9b5c6a2b1
--- /dev/null
+++ b/ui/mirage/handlers/kubernetes.js
@@ -0,0 +1,100 @@
+import { Response } from 'miragejs';
+
+export default function (server) {
+ const getRecord = (schema, req, dbKey) => {
+ const { path, name } = req.params;
+ const findBy = dbKey === 'kubernetesConfigs' ? { path } : { name };
+ const record = schema.db[dbKey].findBy(findBy);
+ if (record) {
+ delete record.path;
+ delete record.id;
+ }
+ return record ? { data: record } : new Response(404, {}, { errors: [] });
+ };
+ const createRecord = (req, key) => {
+ const data = JSON.parse(req.requestBody);
+ if (key === 'kubernetes-config') {
+ data.path = req.params.path;
+ }
+ server.create(key, data);
+ return new Response(204);
+ };
+ const deleteRecord = (schema, req, dbKey) => {
+ const { name } = req.params;
+ const record = schema.db[dbKey].findBy({ name });
+ if (record) {
+ schema.db[dbKey].remove(record.id);
+ }
+ return new Response(204);
+ };
+
+ server.get('/:path/config', (schema, req) => {
+ return getRecord(schema, req, 'kubernetesConfigs');
+ });
+ server.post('/:path/config', (schema, req) => {
+ return createRecord(req, 'kubernetes-config');
+ });
+ server.delete('/:path/config', (schema, req) => {
+ return deleteRecord(schema, req, 'kubernetesConfigs');
+ });
+ // endpoint for checking for environment variables necessary for inferred config
+ server.get('/:path/check', () => {
+ const response = {};
+ const status = Math.random() > 0.5 ? 204 : 404;
+ if (status === 404) {
+ response.errors = [
+ 'Missing environment variables: KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT_HTTPS',
+ ];
+ }
+ return new Response(status, response);
+ });
+ server.get('/:path/roles', (schema) => {
+ return {
+ data: {
+ keys: schema.db.kubernetesRoles.where({}).mapBy('name'),
+ },
+ };
+ });
+ server.get('/:path/roles/:name', (schema, req) => {
+ return getRecord(schema, req, 'kubernetesRoles');
+ });
+ server.post('/:path/roles/:name', (schema, req) => {
+ return createRecord(req, 'kubernetes-role');
+ });
+ server.delete('/:path/roles/:name', (schema, req) => {
+ return deleteRecord(schema, req, 'kubernetesRoles');
+ });
+ server.post('/:path/creds/:role', (schema, req) => {
+ const { role } = req.params;
+ const record = schema.db.kubernetesRoles.findBy({ name: role });
+ const data = JSON.parse(req.requestBody);
+ let errors;
+ if (!record) {
+ errors = [`role '${role}' does not exist`];
+ } else if (!data.kubernetes_namespace) {
+ errors = ["'kubernetes_namespace' is required"];
+ }
+ // creds cannot be fetched after creation so we don't need to store them
+ return errors
+ ? new Response(400, {}, { errors })
+ : {
+ request_id: '58fefc6c-5195-c17a-94f2-8f889f3df57c',
+ lease_id: 'kubernetes/creds/default-role/aWczfcfJ7NKUdiirJrPXIs38',
+ renewable: false,
+ lease_duration: 3600,
+ data: {
+ service_account_name: 'default',
+ service_account_namespace: 'default',
+ service_account_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imlr',
+ },
+ };
+ });
+
+ server.get('/sys/internal/ui/mounts/kubernetes', () => ({
+ data: {
+ accessor: 'kubernetes_9f846a87',
+ path: 'kubernetes/',
+ type: 'kubernetes',
+ },
+ }));
+}
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index 86080aa5fe..810caf5c0d 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -1,4 +1,12 @@
+import ENV from 'vault/config/environment';
+const { handler } = ENV['ember-cli-mirage'];
+import kubernetesScenario from './kubernetes';
+
export default function (server) {
server.create('clients/config');
server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] });
+
+ if (handler === 'kubernetes') {
+ kubernetesScenario(server);
+ }
}
diff --git a/ui/mirage/scenarios/kubernetes.js b/ui/mirage/scenarios/kubernetes.js
new file mode 100644
index 0000000000..710a06e5fd
--- /dev/null
+++ b/ui/mirage/scenarios/kubernetes.js
@@ -0,0 +1,8 @@
+export default function (server, shouldConfigureRoles = true) {
+ server.create('kubernetes-config', { path: 'kubernetes' });
+ if (shouldConfigureRoles) {
+ server.create('kubernetes-role');
+ server.create('kubernetes-role', 'withRoleName');
+ server.create('kubernetes-role', 'withRoleRules');
+ }
+}
diff --git a/ui/package.json b/ui/package.json
index dcf512378d..a647e4426d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -244,6 +244,7 @@
"lib/css",
"lib/keep-gitkeep",
"lib/kmip",
+ "lib/kubernetes",
"lib/open-api-explorer",
"lib/pki",
"lib/replication",
diff --git a/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js b/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js
new file mode 100644
index 0000000000..e0ed5ad991
--- /dev/null
+++ b/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js
@@ -0,0 +1,51 @@
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import kubernetesScenario from 'vault/mirage/scenarios/kubernetes';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import { visit, click, currentRouteName } from '@ember/test-helpers';
+
+module('Acceptance | kubernetes | configuration', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'kubernetes';
+ });
+ hooks.beforeEach(function () {
+ kubernetesScenario(this.server);
+ this.visitConfiguration = () => {
+ return visit('/vault/secrets/kubernetes/kubernetes/configuration');
+ };
+ this.validateRoute = (assert, route, message) => {
+ assert.strictEqual(currentRouteName(), `vault.cluster.secrets.backend.kubernetes.${route}`, message);
+ };
+ return authPage.login();
+ });
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it should transition to configure page on Edit Configuration click from toolbar', async function (assert) {
+ assert.expect(1);
+ await this.visitConfiguration();
+ await click('[data-test-toolbar-config-action]');
+ this.validateRoute(assert, 'configure', 'Transitions to Configure route on click');
+ });
+ test('it should transition to the configuration page on Save click in Configure', async function (assert) {
+ assert.expect(1);
+ await this.visitConfiguration();
+ await click('[data-test-toolbar-config-action]');
+ await click('[data-test-config-save]');
+ await click('[data-test-config-confirm]');
+ this.validateRoute(assert, 'configuration', 'Transitions to Configuration route on click');
+ });
+ test('it should transition to the configuration page on Cancel click in Configure', async function (assert) {
+ assert.expect(1);
+ await this.visitConfiguration();
+ await click('[data-test-toolbar-config-action]');
+ await click('[data-test-config-cancel]');
+ this.validateRoute(assert, 'configuration', 'Transitions to Configuration route on click');
+ });
+});
diff --git a/ui/tests/acceptance/secrets/backend/kubernetes/credentials-test.js b/ui/tests/acceptance/secrets/backend/kubernetes/credentials-test.js
new file mode 100644
index 0000000000..cf79ee5b92
--- /dev/null
+++ b/ui/tests/acceptance/secrets/backend/kubernetes/credentials-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import kubernetesScenario from 'vault/mirage/scenarios/kubernetes';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import { fillIn, visit, click, currentRouteName } from '@ember/test-helpers';
+
+module('Acceptance | kubernetes | credentials', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'kubernetes';
+ });
+ hooks.beforeEach(function () {
+ kubernetesScenario(this.server);
+ this.visitRoleCredentials = () => {
+ return visit('/vault/secrets/kubernetes/kubernetes/roles/role-0/credentials');
+ };
+ this.validateRoute = (assert, route, message) => {
+ assert.strictEqual(currentRouteName(), `vault.cluster.secrets.backend.kubernetes.${route}`, message);
+ };
+ return authPage.login();
+ });
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it should have correct breadcrumb links in credentials view', async function (assert) {
+ assert.expect(3);
+ await this.visitRoleCredentials();
+ await click('[data-test-breadcrumbs] li:nth-child(3) a');
+ this.validateRoute(assert, 'roles.role.details', 'Transitions to role details route on breadcrumb click');
+ await this.visitRoleCredentials();
+ await click('[data-test-breadcrumbs] li:nth-child(2) a');
+ this.validateRoute(assert, 'roles.index', 'Transitions to roles route on breadcrumb click');
+ await this.visitRoleCredentials();
+ await click('[data-test-breadcrumbs] li:nth-child(1) a');
+ this.validateRoute(assert, 'overview', 'Transitions to overview route on breadcrumb click');
+ });
+
+ test('it should transition to role details view on Back click', async function (assert) {
+ assert.expect(1);
+ await this.visitRoleCredentials();
+ await click('[data-test-generate-credentials-back]');
+
+ await this.validateRoute(assert, 'roles.role.details', 'Transitions to role details on Back click');
+ });
+
+ test('it should transition to role details view on Done click', async function (assert) {
+ assert.expect(1);
+ await this.visitRoleCredentials();
+ this.server.post('/kubernetes-test/creds/role-0', () => {
+ assert.ok('POST request made to generate credentials');
+ return {
+ request_id: '58fefc6c-5195-c17a-94f2-8f889f3df57c',
+ lease_id: 'kubernetes/creds/default-role/aWczfcfJ7NKUdiirJrPXIs38',
+ renewable: false,
+ lease_duration: 3600,
+ data: {
+ service_account_name: 'default',
+ service_account_namespace: 'default',
+ service_account_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imlr',
+ },
+ };
+ });
+ await fillIn('[data-test-kubernetes-namespace]', 'kubernetes-test');
+ await click('[data-test-toggle-input]');
+ await click('[data-test-toggle-input="Time-to-Live (TTL)"]');
+ await fillIn('[data-test-ttl-value="Time-to-Live (TTL)"]', 2);
+ await click('[data-test-generate-credentials-button]');
+ await click('[data-test-generate-credentials-done]');
+
+ await this.validateRoute(assert, 'roles.role.details', 'Transitions to role details on Done click');
+ });
+});
diff --git a/ui/tests/acceptance/secrets/backend/kubernetes/overview-test.js b/ui/tests/acceptance/secrets/backend/kubernetes/overview-test.js
new file mode 100644
index 0000000000..94f923c3c4
--- /dev/null
+++ b/ui/tests/acceptance/secrets/backend/kubernetes/overview-test.js
@@ -0,0 +1,64 @@
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import kubernetesScenario from 'vault/mirage/scenarios/kubernetes';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import { visit, click, currentRouteName } from '@ember/test-helpers';
+import { selectChoose } from 'ember-power-select/test-support';
+
+module('Acceptance | kubernetes | overview', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'kubernetes';
+ });
+ hooks.beforeEach(function () {
+ this.createScenario = (shouldConfigureRoles = true) =>
+ shouldConfigureRoles ? kubernetesScenario(this.server) : kubernetesScenario(this.server, false);
+
+ this.visitOverview = () => {
+ return visit('/vault/secrets/kubernetes/kubernetes/overview');
+ };
+ this.validateRoute = (assert, route, message) => {
+ assert.strictEqual(currentRouteName(), `vault.cluster.secrets.backend.kubernetes.${route}`, message);
+ };
+ return authPage.login();
+ });
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it should transition to configuration page during empty state', async function (assert) {
+ assert.expect(1);
+ await this.visitOverview();
+ await click('[data-test-component="empty-state"] a');
+ this.validateRoute(assert, 'configure', 'Transitions to Configure route on click');
+ });
+
+ test('it should transition to view roles', async function (assert) {
+ assert.expect(1);
+ this.createScenario();
+ await this.visitOverview();
+ await click('[data-test-roles-card] .is-no-underline');
+ this.validateRoute(assert, 'roles.index', 'Transitions to roles route on View Roles click');
+ });
+
+ test('it should transition to create roles', async function (assert) {
+ assert.expect(1);
+ this.createScenario(false);
+ await this.visitOverview();
+ await click('[data-test-roles-card] .is-no-underline');
+ this.validateRoute(assert, 'roles.create', 'Transitions to roles route on Create Roles click');
+ });
+
+ test('it should transition to generate credentials', async function (assert) {
+ assert.expect(1);
+ await this.createScenario();
+ await this.visitOverview();
+ await selectChoose('.search-select', 'role-0');
+ await click('[data-test-generate-credential-button]');
+ this.validateRoute(assert, 'roles.role.credentials', 'Transitions to roles route on Generate click');
+ });
+});
diff --git a/ui/tests/acceptance/secrets/backend/kubernetes/roles-test.js b/ui/tests/acceptance/secrets/backend/kubernetes/roles-test.js
new file mode 100644
index 0000000000..3736e2d530
--- /dev/null
+++ b/ui/tests/acceptance/secrets/backend/kubernetes/roles-test.js
@@ -0,0 +1,124 @@
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import kubernetesScenario from 'vault/mirage/scenarios/kubernetes';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import { fillIn, visit, currentURL, click, currentRouteName } from '@ember/test-helpers';
+
+module('Acceptance | kubernetes | roles', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'kubernetes';
+ });
+ hooks.beforeEach(function () {
+ kubernetesScenario(this.server);
+ this.visitRoles = () => {
+ return visit('/vault/secrets/kubernetes/kubernetes/roles');
+ };
+ this.validateRoute = (assert, route, message) => {
+ assert.strictEqual(currentRouteName(), `vault.cluster.secrets.backend.kubernetes.${route}`, message);
+ };
+ return authPage.login();
+ });
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it should filter roles', async function (assert) {
+ await this.visitRoles();
+ assert.dom('[data-test-list-item-link]').exists({ count: 3 }, 'Roles list renders');
+ await fillIn('[data-test-comoponent="navigate-input"]', '1');
+ assert.dom('[data-test-list-item-link]').exists({ count: 1 }, 'Filtered roles list renders');
+ assert.ok(currentURL().includes('pageFilter=1'), 'pageFilter query param value is set');
+ });
+
+ test('it should link to role details on list item click', async function (assert) {
+ assert.expect(1);
+ await this.visitRoles();
+ await click('[data-test-list-item-link]');
+ this.validateRoute(assert, 'roles.role.details', 'Transitions to details route on list item click');
+ });
+
+ test('it should have correct breadcrumb links in role details view', async function (assert) {
+ assert.expect(2);
+ await this.visitRoles();
+ await click('[data-test-list-item-link]');
+ await click('[data-test-breadcrumbs] li:nth-child(2) a');
+ this.validateRoute(assert, 'roles.index', 'Transitions to roles route on breadcrumb click');
+ await click('[data-test-list-item-link]');
+ await click('[data-test-breadcrumbs] li:nth-child(1) a');
+ this.validateRoute(assert, 'overview', 'Transitions to overview route on breadcrumb click');
+ });
+
+ test('it should have functional list item menu', async function (assert) {
+ assert.expect(3);
+ await this.visitRoles();
+ for (const action of ['details', 'edit', 'delete']) {
+ await click('[data-test-list-item-popup] button');
+ await click(`[data-test-${action}]`);
+ if (action === 'delete') {
+ await click('[data-test-confirm-button]');
+ assert.dom('[data-test-list-item-link]').exists({ count: 2 }, 'Deleted role removed from list');
+ } else {
+ this.validateRoute(
+ assert,
+ `roles.role.${action}`,
+ `Transitions to ${action} route on menu action click`
+ );
+ const selector =
+ action === 'details' ? '[data-test-breadcrumbs] li:nth-child(2) a' : '[data-test-cancel]';
+ await click(selector);
+ }
+ }
+ });
+
+ test('it should create role', async function (assert) {
+ assert.expect(2);
+ await this.visitRoles();
+ await click('[data-test-toolbar-roles-action]');
+ await click('[data-test-radio-card="basic"]');
+ await fillIn('[data-test-input="name"]', 'new-test-role');
+ await fillIn('[data-test-input="serviceAccountName"]', 'default');
+ await fillIn('[data-test-input="allowedKubernetesNamespaces"]', '*');
+ await click('[data-test-save]');
+ this.validateRoute(assert, 'roles.role.details', 'Transitions to details route on save success');
+ await click('[data-test-breadcrumbs] li:nth-child(2) a');
+ assert.dom('[data-test-role="new-test-role"]').exists('New role renders in list');
+ });
+
+ test('it should have functional toolbar actions in details view', async function (assert) {
+ assert.expect(3);
+ await this.visitRoles();
+ await click('[data-test-list-item-link]');
+ await click('[data-test-generate-credentials]');
+ this.validateRoute(assert, 'roles.role.credentials', 'Transitions to credentials route');
+ await click('[data-test-breadcrumbs] li:nth-child(3) a');
+ await click('[data-test-edit]');
+ this.validateRoute(assert, 'roles.role.edit', 'Transitions to edit route');
+ await click('[data-test-cancel]');
+ await click('[data-test-list-item-link]');
+ await click('[data-test-delete] button');
+ await click('[data-test-confirm-button]');
+ assert
+ .dom('[data-test-list-item-link]')
+ .exists({ count: 2 }, 'Transitions to roles route and deleted role removed from list');
+ });
+
+ test('it should generate credentials for role', async function (assert) {
+ assert.expect(1);
+ await this.visitRoles();
+ await click('[data-test-list-item-link]');
+ await click('[data-test-generate-credentials]');
+ await fillIn('[data-test-kubernetes-namespace]', 'test-namespace');
+ await click('[data-test-generate-credentials-button]');
+ await click('[data-test-generate-credentials-done]');
+ this.validateRoute(
+ assert,
+ 'roles.role.details',
+ 'Transitions to details route when done generating credentials'
+ );
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/config-cta-test.js b/ui/tests/integration/components/kubernetes/config-cta-test.js
new file mode 100644
index 0000000000..cbd9161aa2
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/config-cta-test.js
@@ -0,0 +1,24 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | ConfigCta', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ test('it should render message and action', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+ assert.dom('[data-test-empty-state-title]').hasText('Kubernetes not configured', 'Title renders');
+ assert
+ .dom('[data-test-empty-state-message]')
+ .hasText(
+ 'Get started by establishing the URL of the Kubernetes API to connect to, along with some additional options.',
+ 'Message renders'
+ );
+ assert.dom('[data-test-config-cta] a').hasText('Configure Kubernetes', 'Action renders');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/configuration-test.js b/ui/tests/integration/components/kubernetes/page/configuration-test.js
new file mode 100644
index 0000000000..b76dcbb523
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/configuration-test.js
@@ -0,0 +1,95 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | Page::Configuration', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.pushPayload('secret-engine', {
+ modelName: 'secret-engine',
+ data: {
+ accessor: 'kubernetes_f3400dee',
+ path: 'kubernetes-test/',
+ type: 'kubernetes',
+ },
+ });
+ this.backend = this.store.peekRecord('secret-engine', 'kubernetes-test');
+ this.config = null;
+
+ this.setConfig = (disableLocal) => {
+ const data = this.server.create(
+ 'kubernetes-config',
+ !disableLocal ? { disable_local_ca_jwt: false } : null
+ );
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-test',
+ ...data,
+ });
+ this.config = this.store.peekRecord('kubernetes/config', 'kubernetes-test');
+ };
+
+ this.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: this.backend.id },
+ ];
+
+ this.renderComponent = () => {
+ return render(
+ hbs` `,
+ {
+ owner: this.engine,
+ }
+ );
+ };
+ });
+
+ test('it should render tab page header and config cta', async function (assert) {
+ await this.renderComponent();
+ assert.dom('.title svg').hasClass('flight-icon-kubernetes', 'Kubernetes icon renders in title');
+ assert.dom('.title').hasText('kubernetes-test', 'Mount path renders in title');
+ assert
+ .dom('[data-test-toolbar-config-action]')
+ .hasText('Configure Kubernetes', 'Toolbar action has correct text');
+ assert.dom('[data-test-config-cta]').exists('Config cta renders');
+ });
+
+ test('it should render message for inferred configuration', async function (assert) {
+ this.setConfig(false);
+ await this.renderComponent();
+ assert
+ .dom('[data-test-inferred-message] svg')
+ .hasClass('flight-icon-check-circle-fill', 'Inferred message icon renders');
+ const message =
+ 'These details were successfully inferred from Vault’s kubernetes environment and were not explicity set in this config.';
+ assert.dom('[data-test-inferred-message]').hasText(message, 'Inferred message renders');
+ assert
+ .dom('[data-test-toolbar-config-action]')
+ .hasText('Edit configuration', 'Toolbar action has correct text');
+ });
+
+ test('it should render host and certificate info', async function (assert) {
+ this.setConfig(true);
+ await this.renderComponent();
+ assert.dom('[data-test-row-label="Kubernetes host"]').exists('Kubernetes host label renders');
+ assert
+ .dom('[data-test-row-value="Kubernetes host"]')
+ .hasText(this.config.kubernetesHost, 'Kubernetes host value renders');
+ assert.dom('[data-test-row-label="Certificate"]').exists('Certificate label renders');
+ assert
+ .dom('[data-test-certificate-icon]')
+ .hasClass('flight-icon-certificate', 'Certificate card icon renders');
+ assert.dom('[data-test-certificate-label]').hasText('PEM Format', 'Certificate card label renders');
+ assert
+ .dom('[data-test-certificate-value]')
+ .hasText(this.config.kubernetesCaCert, 'Certificate card value renders');
+ assert.dom('[data-test-certificate-copy]').exists('Certificate copy button renders');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/configure-test.js b/ui/tests/integration/components/kubernetes/page/configure-test.js
new file mode 100644
index 0000000000..5c0cede416
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/configure-test.js
@@ -0,0 +1,191 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render, click, waitUntil, find, fillIn } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+import { Response } from 'miragejs';
+import sinon from 'sinon';
+
+module('Integration | Component | kubernetes | Page::Configure', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.newModel = this.store.createRecord('kubernetes/config', { backend: 'kubernetes-new' });
+ this.existingConfig = {
+ kubernetes_host: 'https://192.168.99.100:8443',
+ kubernetes_ca_cert: '-----BEGIN CERTIFICATE-----\n.....\n-----END CERTIFICATE-----',
+ service_account_jwt: 'test-jwt',
+ disable_local_ca_jwt: true,
+ };
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-edit',
+ ...this.existingConfig,
+ });
+ this.editModel = this.store.peekRecord('kubernetes/config', 'kubernetes-edit');
+ });
+
+ test('it should display proper options when toggling radio cards', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+
+ assert
+ .dom('[data-test-radio-card="local"] input')
+ .isChecked('Local cluster radio card is checked by default');
+ assert
+ .dom('[data-test-config] p')
+ .hasText(
+ 'Configuration values can be inferred from the pod and your local environment variables.',
+ 'Inferred text is displayed'
+ );
+ assert.dom('[data-test-config] button').hasText('Get config values', 'Get config button renders');
+ assert
+ .dom('[data-test-config-save]')
+ .isDisabled('Save button is disabled when config values have not been inferred');
+ assert.dom('[data-test-config-cancel]').hasText('Back', 'Back button renders');
+
+ await click('[data-test-radio-card="manual"]');
+ assert.dom('[data-test-field]').exists({ count: 3 }, 'Form fields render');
+ assert.dom('[data-test-config-save]').isNotDisabled('Save button is enabled');
+ assert.dom('[data-test-config-cancel]').hasText('Back', 'Back button renders');
+ });
+
+ test('it should check for inferred config variables', async function (assert) {
+ assert.expect(8);
+
+ let status = 404;
+ this.server.get('/:path/check', () => {
+ assert.ok(
+ waitUntil(() => find('[data-test-config] button').disabled),
+ 'Button is disabled while request is in flight'
+ );
+ return new Response(status, {});
+ });
+
+ await render(hbs` `, { owner: this.engine });
+
+ await click('[data-test-config] button');
+ assert
+ .dom('[data-test-icon="x-square-fill"]')
+ .hasClass('has-text-red', 'Icon is displayed for error state with correct styling');
+ const error =
+ 'Vault could not infer a configuration from your environment variables. Check your configuration file to edit or delete them, or configure manually.';
+ assert.dom('[data-test-config] span').hasText(error, 'Error text is displayed');
+ assert.dom('[data-test-config-save]').isDisabled('Save button is disabled in error state');
+
+ status = 204;
+ await click('[data-test-radio-card="manual"]');
+ await click('[data-test-radio-card="local"]');
+ await click('[data-test-config] button');
+ assert
+ .dom('[data-test-icon="check-circle-fill"]')
+ .hasClass('has-text-green', 'Icon is displayed for success state with correct styling');
+ assert
+ .dom('[data-test-config] span')
+ .hasText('Configuration values were inferred successfully.', 'Success text is displayed');
+ assert.dom('[data-test-config-save]').isNotDisabled('Save button is enabled in success state');
+ });
+
+ test('it should create new manual config', async function (assert) {
+ assert.expect(2);
+
+ this.server.post('/:path/config', (schema, req) => {
+ const json = JSON.parse(req.requestBody);
+ assert.deepEqual(json, this.existingConfig, 'Values are passed to create endpoint');
+ return new Response(204, {});
+ });
+
+ const stub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
+
+ await render(hbs` `, { owner: this.engine });
+
+ await click('[data-test-radio-card="manual"]');
+ await fillIn('[data-test-input="kubernetesHost"]', this.existingConfig.kubernetes_host);
+ await fillIn('[data-test-input="serviceAccountJwt"]', this.existingConfig.service_account_jwt);
+ await fillIn('[data-test-input="kubernetesCaCert"]', this.existingConfig.kubernetes_ca_cert);
+ await click('[data-test-config-save]');
+ assert.ok(
+ stub.calledWith('vault.cluster.secrets.backend.kubernetes.configuration'),
+ 'Transitions to configuration route on save success'
+ );
+ });
+
+ test('it should edit existing manual config', async function (assert) {
+ assert.expect(6);
+
+ const stub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
+
+ await render(hbs` `, { owner: this.engine });
+
+ assert.dom('[data-test-radio-card="manual"] input').isChecked('Manual config radio card is checked');
+ assert
+ .dom('[data-test-input="kubernetesHost"]')
+ .hasValue(this.existingConfig.kubernetes_host, 'Host field is populated');
+ assert
+ .dom('[data-test-input="serviceAccountJwt"]')
+ .hasValue(this.existingConfig.service_account_jwt, 'JWT field is populated');
+ assert
+ .dom('[data-test-input="kubernetesCaCert"]')
+ .hasValue(this.existingConfig.kubernetes_ca_cert, 'Cert field is populated');
+
+ await fillIn('[data-test-input="kubernetesHost"]', 'http://localhost:1212');
+ await click('[data-test-config-cancel]');
+
+ assert.ok(
+ stub.calledWith('vault.cluster.secrets.backend.kubernetes.configuration'),
+ 'Transitions to configuration route when cancelling edit'
+ );
+ assert.strictEqual(
+ this.editModel.kubernetesHost,
+ this.existingConfig.kubernetes_host,
+ 'Model values are rolled back on cancel'
+ );
+ });
+
+ test('it should display inferred success message when editing model using local values', async function (assert) {
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-edit-2',
+ disable_local_ca_jwt: false,
+ });
+ this.model = this.store.peekRecord('kubernetes/config', 'kubernetes-edit-2');
+
+ await render(hbs` `, { owner: this.engine });
+
+ assert.dom('[data-test-radio-card="local"] input').isChecked('Local cluster radio card is checked');
+ assert
+ .dom('[data-test-icon="check-circle-fill"]')
+ .hasClass('has-text-green', 'Icon is displayed for success state with correct styling');
+ assert
+ .dom('[data-test-config] span')
+ .hasText('Configuration values were inferred successfully.', 'Success text is displayed');
+ });
+
+ test('it should show confirmation modal when saving edits', async function (assert) {
+ assert.expect(2);
+
+ this.server.post('/:path/config', () => {
+ assert.ok(true, 'Save request made after confirmation');
+ return new Response(204, {});
+ });
+
+ await render(
+ hbs`
+
+
+ `,
+ { owner: this.engine }
+ );
+ await click('[data-test-config-save]');
+ assert
+ .dom('.modal-card-body')
+ .hasText(
+ 'Making changes to your configuration may affect how Vault will reach the Kubernetes API and authenticate with it. Are you sure?',
+ 'Confirm modal renders'
+ );
+ await click('[data-test-config-confirm]');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/credentials-test.js b/ui/tests/integration/components/kubernetes/page/credentials-test.js
new file mode 100644
index 0000000000..19f38cb4a8
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/credentials-test.js
@@ -0,0 +1,133 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render, click, fillIn } from '@ember/test-helpers';
+import { Response } from 'miragejs';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | Page::Credentials', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.backend = 'kubernetes-test';
+ this.roleName = 'role-0';
+
+ this.getCreateCredentialsError = (roleName, errorType = null) => {
+ let errors;
+
+ if (errorType === 'noNamespace') {
+ errors = ["'kubernetes_namespace' is required"];
+ } else {
+ errors = [`role '${roleName}' does not exist`];
+ }
+
+ this.server.post(`/kubernetes-test/creds/${roleName}`, () => {
+ return new Response(400, {}, { errors });
+ });
+ };
+ this.breadcrumbs = [
+ { label: this.backend, route: 'overview' },
+ { label: 'roles', route: 'roles' },
+ { label: this.roleName, route: 'roles.role.details' },
+ { label: 'credentials' },
+ ];
+ this.renderComponent = () => {
+ return render(
+ hbs` `,
+ {
+ owner: this.engine,
+ }
+ );
+ };
+ });
+
+ test('it should display generate credentials form', async function (assert) {
+ await this.renderComponent();
+ assert.dom('[data-test-credentials-header]').hasText('Generate credentials');
+ assert
+ .dom('[data-test-generate-credentials] p')
+ .hasText(`This will generate credentials using the role ${this.roleName}.`);
+ assert.dom('[data-test-generate-credentials] label').hasText('Kubernetes namespace');
+ assert
+ .dom('[data-test-generate-credentials] .is-size-8')
+ .hasText('The namespace in which to generate the credentials.');
+ assert.dom('[data-test-toggle-label] .title').hasText('ClusterRoleBinding');
+ assert
+ .dom('[data-test-toggle-label] .description')
+ .hasText(
+ 'Generate a ClusterRoleBinding to grant permissions across the whole cluster instead of within a namespace. This requires the Vault role to have kubernetes_role_type set to ClusterRole.'
+ );
+ });
+
+ test('it should show errors states when generating credentials', async function (assert) {
+ assert.expect(2);
+
+ this.getCreateCredentialsError(this.roleName, 'noNamespace');
+ await this.renderComponent();
+ await click('[data-test-generate-credentials-button]');
+
+ assert.dom('[data-test-error] .alert-banner-message-body').hasText("'kubernetes_namespace' is required");
+
+ this.roleName = 'role-2';
+ this.getCreateCredentialsError(this.roleName);
+
+ await this.renderComponent();
+ await click('[data-test-generate-credentials-button]');
+ assert
+ .dom('[data-test-error] .alert-banner-message-body')
+ .hasText(`role '${this.roleName}' does not exist`);
+ });
+
+ test('it should show correct credential information after generate credentials is clicked', async function (assert) {
+ assert.expect(15);
+
+ this.server.post('/kubernetes-test/creds/role-0', () => {
+ assert.ok('POST request made to generate credentials');
+ return {
+ request_id: '58fefc6c-5195-c17a-94f2-8f889f3df57c',
+ lease_id: 'kubernetes/creds/default-role/aWczfcfJ7NKUdiirJrPXIs38',
+ renewable: false,
+ lease_duration: 3600,
+ data: {
+ service_account_name: 'default',
+ service_account_namespace: 'default',
+ service_account_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imlr',
+ },
+ };
+ });
+
+ await this.renderComponent();
+ await fillIn('[data-test-kubernetes-namespace]', 'kubernetes-test');
+ assert.dom('[data-test-kubernetes-namespace]').hasValue('kubernetes-test', 'kubernetes-test');
+
+ await click('[data-test-toggle-input]');
+ await click('[data-test-toggle-input="Time-to-Live (TTL)"]');
+ await fillIn('[data-test-ttl-value="Time-to-Live (TTL)"]', 2);
+ await click('[data-test-generate-credentials-button]');
+
+ assert.dom('[data-test-credentials-header]').hasText('Credentials');
+ assert.dom('[data-test-alert-banner] .message-title').hasText('Warning');
+ assert
+ .dom('[data-test-alert-banner] .alert-banner-message-body')
+ .hasText("You won't be able to access these credentials later, so please copy them now.");
+ assert.dom('[data-test-row-label="Service account token"]').hasText('Service account token');
+ await click('[data-test-value-div="Service account token"] [data-test-button]');
+ assert
+ .dom('[data-test-value-div="Service account token"] .display-only')
+ .hasText('eyJhbGciOiJSUzI1NiIsImtpZCI6Imlr');
+ assert.dom('[data-test-row-label="Namespace"]').hasText('Namespace');
+ assert.dom('[data-test-value-div="Namespace"]').exists();
+ assert.dom('[data-test-row-label="Service account name"]').hasText('Service account name');
+ assert.dom('[data-test-value-div="Service account name"]').exists();
+
+ assert.dom('[data-test-row-label="Lease expiry"]').hasText('Lease expiry');
+ assert.dom('[data-test-value-div="Lease expiry"]').exists();
+ assert.dom('[data-test-row-label="lease_id"]').hasText('lease_id');
+ assert
+ .dom('[data-test-value-div="lease_id"] [data-test-row-value="lease_id"]')
+ .hasText('kubernetes/creds/default-role/aWczfcfJ7NKUdiirJrPXIs38');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/overview-test.js b/ui/tests/integration/components/kubernetes/page/overview-test.js
new file mode 100644
index 0000000000..18d832b2cf
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/overview-test.js
@@ -0,0 +1,109 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render } from '@ember/test-helpers';
+import { typeInSearch, clickTrigger, selectChoose } from 'ember-power-select/test-support/helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | Page::Overview', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.pushPayload('secret-engine', {
+ modelName: 'secret-engine',
+ data: {
+ accessor: 'kubernetes_f3400dee',
+ path: 'kubernetes-test/',
+ type: 'kubernetes',
+ },
+ });
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-test',
+ ...this.server.create('kubernetes-config'),
+ });
+ this.store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ ...this.server.create('kubernetes-role'),
+ });
+ this.store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ ...this.server.create('kubernetes-role'),
+ });
+ this.backend = this.store.peekRecord('secret-engine', 'kubernetes-test');
+ this.config = this.store.peekRecord('kubernetes/config', 'kubernetes-test');
+ this.roles = this.store.peekAll('kubernetes/role');
+ this.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: this.backend.id },
+ ];
+ this.renderComponent = () => {
+ return render(
+ hbs` `,
+ { owner: this.engine }
+ );
+ };
+ });
+
+ test('it should display role card', async function (assert) {
+ await this.renderComponent();
+ assert.dom('[data-test-roles-card] .title').hasText('Roles');
+ assert
+ .dom('[data-test-roles-card] p')
+ .hasText('The number of Vault roles being used to generate Kubernetes credentials.');
+ assert.dom('[data-test-roles-card] a').hasText('View Roles');
+
+ this.roles = [];
+
+ await this.renderComponent();
+ assert.dom('[data-test-roles-card] a').hasText('Create Role');
+ });
+
+ test('it should display correct number of roles in role card', async function (assert) {
+ await this.renderComponent();
+ assert.dom('[data-test-roles-card] .has-font-weight-normal').hasText('2');
+
+ this.roles = [];
+
+ await this.renderComponent();
+ assert.dom('[data-test-roles-card] .has-font-weight-normal').hasText('None');
+ });
+
+ test('it should display generate credentials card', async function (assert) {
+ await this.renderComponent();
+ assert.dom('[data-test-generate-credential-card] .title').hasText('Generate credentials');
+ assert
+ .dom('[data-test-generate-credential-card] p')
+ .hasText('Quickly generate credentials by typing the role name.');
+ });
+
+ test('it should show options for SearchSelect', async function (assert) {
+ await this.renderComponent();
+ await clickTrigger();
+ assert.strictEqual(this.element.querySelectorAll('.ember-power-select-option').length, 2);
+ await typeInSearch('role-0');
+ assert.strictEqual(this.element.querySelectorAll('.ember-power-select-option').length, 1);
+ assert.dom('[data-test-generate-credential-card] button').isDisabled();
+ await selectChoose('', '.ember-power-select-option', 2);
+ assert.dom('[data-test-generate-credential-card] button').isNotDisabled();
+ });
+
+ test('it should show ConfigCta when no config is set up', async function (assert) {
+ this.config = null;
+
+ await this.renderComponent();
+ assert.dom('.empty-state .empty-state-title').hasText('Kubernetes not configured');
+ assert
+ .dom('.empty-state .empty-state-message')
+ .hasText(
+ 'Get started by establishing the URL of the Kubernetes API to connect to, along with some additional options.'
+ );
+ assert.dom('.empty-state .empty-state-actions').hasText('Configure Kubernetes');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js b/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js
new file mode 100644
index 0000000000..fe09563f73
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/role/create-and-edit-test.js
@@ -0,0 +1,268 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render, click, fillIn } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+import sinon from 'sinon';
+
+module('Integration | Component | kubernetes | Page::Role::CreateAndEdit', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ const router = this.owner.lookup('service:router');
+ const routerStub = sinon.stub(router, 'transitionTo');
+ this.transitionCalledWith = (routeName, name) => {
+ const route = `vault.cluster.secrets.backend.kubernetes.${routeName}`;
+ const args = name ? [route, name] : [route];
+ return routerStub.calledWith(...args);
+ };
+
+ const store = this.owner.lookup('service:store');
+ this.getRole = (trait) => {
+ const role = this.server.create('kubernetes-role', trait);
+ store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ ...role,
+ });
+ return store.peekRecord('kubernetes/role', role.name);
+ };
+
+ this.newModel = store.createRecord('kubernetes/role', { backend: 'kubernetes-test' });
+ });
+
+ test('it should display placeholder when generation preference is not selected', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+ assert
+ .dom('[data-test-empty-state-title]')
+ .hasText('Choose an option above', 'Empty state title renders');
+ assert
+ .dom('[data-test-empty-state-message]')
+ .hasText(
+ 'To configure a Vault role, choose what should be generated in Kubernetes by Vault.',
+ 'Empty state message renders'
+ );
+ assert.dom('[data-test-save]').isDisabled('Save button is disabled');
+ });
+
+ test('it should display different form fields based on generation preference selection', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+ const commonFields = [
+ 'name',
+ 'allowedKubernetesNamespaces',
+ 'tokenMaxTtl',
+ 'tokenDefaultTtl',
+ 'annotations',
+ ];
+
+ await click('[data-test-radio-card="basic"]');
+ ['serviceAccountName', ...commonFields].forEach((field) => {
+ assert.dom(`[data-test-field="${field}"]`).exists(`${field} field renders`);
+ });
+
+ await click('[data-test-radio-card="expanded"]');
+ ['kubernetesRoleType', 'kubernetesRoleName', 'nameTemplate', ...commonFields].forEach((field) => {
+ assert.dom(`[data-test-field="${field}"]`).exists(`${field} field renders`);
+ });
+
+ await click('[data-test-radio-card="full"]');
+ ['kubernetesRoleType', 'nameTemplate', ...commonFields].forEach((field) => {
+ assert.dom(`[data-test-field="${field}"]`).exists(`${field} field renders`);
+ });
+ assert.dom('[data-test-generated-role-rules]').exists('Generated role rules section renders');
+ });
+
+ test('it should clear specific form fields when switching generation preference', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+
+ await click('[data-test-radio-card="basic"]');
+ await fillIn('[data-test-input="serviceAccountName"]', 'test');
+ await click('[data-test-radio-card="expanded"]');
+ assert.strictEqual(
+ this.newModel.serviceAccountName,
+ null,
+ 'Service account name cleared when switching from basic to expanded'
+ );
+
+ await fillIn('[data-test-input="kubernetesRoleName"]', 'test');
+ await click('[data-test-radio-card="full"]');
+ assert.strictEqual(
+ this.newModel.kubernetesRoleName,
+ null,
+ 'Kubernetes role name cleared when switching from expanded to full'
+ );
+
+ await click('[data-test-input="kubernetesRoleType"] input');
+ await click('[data-test-toggle-input="show-nameTemplate"]');
+ await fillIn('[data-test-input="nameTemplate"]', 'bar');
+ await fillIn('[data-test-select-template]', '6');
+ await click('[data-test-radio-card="expanded"]');
+ assert.strictEqual(
+ this.newModel.generatedRoleRules,
+ null,
+ 'Role rules cleared when switching from full to expanded'
+ );
+
+ await click('[data-test-radio-card="basic"]');
+ assert.strictEqual(
+ this.newModel.kubernetesRoleType,
+ null,
+ 'Kubernetes role type cleared when switching from expanded to basic'
+ );
+ assert.strictEqual(
+ this.newModel.kubernetesRoleName,
+ null,
+ 'Kubernetes role name cleared when switching from expanded to basic'
+ );
+ assert.strictEqual(
+ this.newModel.nameTemplate,
+ null,
+ 'Name template cleared when switching from expanded to basic'
+ );
+ });
+
+ test('it should create new role', async function (assert) {
+ assert.expect(3);
+
+ this.server.post('/kubernetes-test/roles/role-1', () => assert.ok('POST request made to save role'));
+
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-radio-card="basic"]');
+ await click('[data-test-save]');
+ assert.dom('[data-test-inline-error-message]').hasText('Name is required', 'Validation error renders');
+ await fillIn('[data-test-input="name"]', 'role-1');
+ await fillIn('[data-test-input="serviceAccountName"]', 'default');
+ await click('[data-test-save]');
+ assert.ok(
+ this.transitionCalledWith('roles.role.details', this.newModel.name),
+ 'Transitions to details route on save'
+ );
+ });
+
+ test('it should populate fields when editing role', async function (assert) {
+ assert.expect(15);
+
+ this.server.post('/kubernetes-test/roles/:name', () => assert.ok('POST request made to save role'));
+
+ for (const pref of ['basic', 'expanded', 'full']) {
+ const trait = { expanded: 'withRoleName', full: 'withRoleRules' }[pref];
+ this.role = this.getRole(trait);
+ await render(hbs` `, { owner: this.engine });
+ assert.dom(`[data-test-radio-card="${pref}"] input`).isChecked('Correct radio card is checked');
+ assert.dom('[data-test-input="name"]').hasValue(this.role.name, 'Role name is populated');
+ const selector = {
+ basic: { name: '[data-test-input="serviceAccountName"]', method: 'hasValue', value: 'default' },
+ expanded: {
+ name: '[data-test-input="kubernetesRoleName"]',
+ method: 'hasValue',
+ value: 'vault-k8s-secrets-role',
+ },
+ full: {
+ name: '[data-test-select-template]',
+ method: 'hasValue',
+ value: '5',
+ },
+ }[pref];
+ assert.dom(selector.name)[selector.method](selector.value);
+ await click('[data-test-save]');
+ assert.ok(
+ this.transitionCalledWith('roles.role.details', this.role.name),
+ 'Transitions to details route on save'
+ );
+ }
+ });
+
+ test('it should show and hide annotations and labels', async function (assert) {
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-radio-card="basic"]');
+ assert.dom('[data-test-annotations]').doesNotExist('Annotations and labels are hidden');
+
+ await click('[data-test-field="annotations"]');
+ await fillIn('[data-test-kv="annotations"] [data-test-kv-key]', 'foo');
+ await fillIn('[data-test-kv="annotations"] [data-test-kv-value]', 'bar');
+ await click('[data-test-kv="annotations"] [data-test-kv-add-row]');
+ assert.deepEqual(this.newModel.extraAnnotations, { foo: 'bar' }, 'Annotations set');
+
+ await fillIn('[data-test-kv="labels"] [data-test-kv-key]', 'bar');
+ await fillIn('[data-test-kv="labels"] [data-test-kv-value]', 'baz');
+ await click('[data-test-kv="labels"] [data-test-kv-add-row]');
+ assert.deepEqual(this.newModel.extraLabels, { bar: 'baz' }, 'Labels set');
+ });
+
+ test('it should expand annotations and labels when editing if they were populated', async function (assert) {
+ this.role = this.getRole();
+ await render(hbs` `, { owner: this.engine });
+ assert
+ .dom('[data-test-annotations]')
+ .doesNotExist('Annotations and labels are collapsed initially when not defined');
+ this.role = this.getRole('withRoleRules');
+ await render(hbs` `, { owner: this.engine });
+ assert
+ .dom('[data-test-annotations]')
+ .exists('Annotations and labels are expanded initially when defined');
+ });
+
+ test('it should restore role rule example', async function (assert) {
+ this.role = this.getRole('withRoleRules');
+ await render(hbs` `, { owner: this.engine });
+ const addedText = 'this will be add to the start of the first line in the JsonEditor';
+ await fillIn('[data-test-component="code-mirror-modifier"] textarea', addedText);
+ await click('[data-test-restore-example]');
+ assert.dom('.CodeMirror-code').doesNotContainText(addedText, 'Role rules example restored');
+ });
+
+ test('it should set generatedRoleRoles model prop on save', async function (assert) {
+ assert.expect(1);
+
+ this.server.post('/kubernetes-test/roles/role-1', (schema, req) => {
+ const payload = JSON.parse(req.requestBody);
+ const role = this.server.create('kubernetes-role', 'withRoleRules');
+ assert.strictEqual(
+ payload.generated_role_rules,
+ role.generated_role_rules,
+ 'Generated roles rules are passed in save request'
+ );
+ });
+
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-radio-card="full"]');
+ await fillIn('[data-test-input="name"]', 'role-1');
+ await fillIn('[data-test-select-template]', '5');
+ await click('[data-test-save]');
+ });
+
+ test('it should unset selectedTemplateId when switching from full generation preference', async function (assert) {
+ assert.expect(1);
+
+ this.server.post('/kubernetes-test/roles/role-1', (schema, req) => {
+ const payload = JSON.parse(req.requestBody);
+ assert.strictEqual(payload.generated_role_rules, null, 'Generated roles rules are not set');
+ });
+
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-radio-card="full"]');
+ await fillIn('[data-test-input="name"]', 'role-1');
+ await fillIn('[data-test-select-template]', '5');
+ await click('[data-test-radio-card="basic"]');
+ await fillIn('[data-test-input="serviceAccountName"]', 'default');
+ await click('[data-test-save]');
+ });
+
+ test('it should go back to list route and clean up model', async function (assert) {
+ const unloadSpy = sinon.spy(this.newModel, 'unloadRecord');
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-cancel]');
+ assert.ok(unloadSpy.calledOnce, 'New model is unloaded on cancel');
+ assert.ok(this.transitionCalledWith('roles'), 'Transitions to roles list on cancel');
+
+ this.role = this.getRole();
+ const rollbackSpy = sinon.spy(this.role, 'rollbackAttributes');
+ await render(hbs` `, { owner: this.engine });
+ await click('[data-test-cancel]');
+ assert.ok(rollbackSpy.calledOnce, 'Attributes are rolled back for existing model on cancel');
+ assert.ok(this.transitionCalledWith('roles'), 'Transitions to roles list on cancel');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/role/details-test.js b/ui/tests/integration/components/kubernetes/page/role/details-test.js
new file mode 100644
index 0000000000..97ac50c128
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/role/details-test.js
@@ -0,0 +1,140 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render, click } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+import sinon from 'sinon';
+import { duration } from 'core/helpers/format-duration';
+
+const allFields = [
+ { label: 'Role name', key: 'name' },
+ { label: 'Kubernetes role type', key: 'kubernetesRoleType' },
+ { label: 'Kubernetes role name', key: 'kubernetesRoleName' },
+ { label: 'Service account name', key: 'serviceAccountName' },
+ { label: 'Allowed Kubernetes namespaces', key: 'allowedKubernetesNamespaces' },
+ { label: 'Max Lease TTL', key: 'tokenMaxTtl' },
+ { label: 'Default Lease TTL', key: 'tokenDefaultTtl' },
+ { label: 'Name template', key: 'nameTemplate' },
+];
+
+module('Integration | Component | kubernetes | Page::Role::Details', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ const store = this.owner.lookup('service:store');
+ this.server.post('/sys/capabilities-self', () => ({
+ data: {
+ capabilities: ['root'],
+ },
+ }));
+ this.renderComponent = (trait) => {
+ const data = this.server.create('kubernetes-role', trait);
+ store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ ...data,
+ });
+ this.model = store.peekRecord('kubernetes/role', data.name);
+ this.breadcrumbs = [
+ { label: this.model.backend, route: 'overview' },
+ { label: 'roles', route: 'roles' },
+ { label: this.model.name },
+ ];
+ return render(hbs` `, {
+ owner: this.engine,
+ });
+ };
+
+ this.assertFilteredFields = (hiddenIndices, assert) => {
+ const fields = allFields.filter((field, index) => !hiddenIndices.includes(index));
+ assert
+ .dom('[data-test-filtered-field]')
+ .exists({ count: fields.length }, 'Correct number of filtered fields render');
+ fields.forEach((field) => {
+ assert
+ .dom(`[data-test-row-label="${field.label}"]`)
+ .hasText(field.label, `${field.label} label renders`);
+ const modelValue = this.model[field.key];
+ const value = field.key.includes('Ttl') ? duration([modelValue], {}) : modelValue;
+ assert.dom(`[data-test-row-value="${field.label}"]`).hasText(value, `${field.label} value renders`);
+ });
+ };
+
+ this.assertExtraFields = (modelKeys, assert) => {
+ modelKeys.forEach((modelKey) => {
+ for (const key in this.model[modelKey]) {
+ assert.dom(`[data-test-row-label="${key}"]`).hasText(key, `${modelKey} key renders`);
+ assert
+ .dom(`[data-test-row-value="${key}"]`)
+ .hasText(this.model[modelKey][key], `${modelKey} value renders`);
+ }
+ });
+ };
+ });
+
+ test('it should render header with role name and breadcrumbs', async function (assert) {
+ await this.renderComponent();
+ assert.dom('[data-test-header-title]').hasText(this.model.name, 'Role name renders in header');
+ assert
+ .dom('[data-test-breadcrumbs] li:nth-child(1)')
+ .containsText(this.model.backend, 'Overview breadcrumb renders');
+ assert.dom('[data-test-breadcrumbs] li:nth-child(2) a').containsText('roles', 'Roles breadcrumb renders');
+ assert
+ .dom('[data-test-breadcrumbs] li:nth-child(3)')
+ .containsText(this.model.name, 'Role breadcrumb renders');
+ });
+
+ test('it should render toolbar actions', async function (assert) {
+ assert.expect(5);
+
+ const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
+
+ await this.renderComponent();
+
+ this.server.delete(`/${this.model.backend}/roles/${this.model.name}`, () => {
+ assert.ok(true, 'Request made to delete role');
+ return;
+ });
+
+ assert.dom('[data-test-delete] button').hasText('Delete role', 'Delete action renders');
+ assert
+ .dom('[data-test-generate-credentials]')
+ .hasText('Generate credentials', 'Generate credentials action renders');
+ assert.dom('[data-test-edit]').hasText('Edit role', 'Edit action renders');
+
+ await click('[data-test-delete] button');
+ await click('[data-test-confirm-button]');
+ assert.ok(
+ transitionStub.calledWith('vault.cluster.secrets.backend.kubernetes.roles'),
+ 'Transitions to roles route on delete success'
+ );
+ });
+
+ test('it should render fields that correspond to basic creation', async function (assert) {
+ assert.expect(13);
+ await this.renderComponent();
+ this.assertFilteredFields([1, 2, 7], assert);
+ assert.dom('[data-test-generated-role-rules]').doesNotExist('Generated role rules do not render');
+ assert.dom('[data-test-extra-fields]').doesNotExist('Annotations and labels do not render');
+ });
+
+ test('it should render fields that correspond to expanded creation', async function (assert) {
+ assert.expect(21);
+ await this.renderComponent('withRoleName');
+ this.assertFilteredFields([3], assert);
+ assert.dom('[data-test-generated-role-rules]').doesNotExist('Generated role rules do not render');
+ this.assertExtraFields(['extraAnnotations'], assert);
+ assert.dom('[data-test-extra-fields="Labels"]').doesNotExist('Labels do not render');
+ });
+
+ test('it should render fields that correspond to full creation', async function (assert) {
+ assert.expect(22);
+ await this.renderComponent('withRoleRules');
+ this.assertFilteredFields([2, 3], assert);
+ assert.dom('[data-test-generated-role-rules]').exists('Generated role rules render');
+ this.assertExtraFields(['extraAnnotations', 'extraLabels'], assert);
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/page/roles-test.js b/ui/tests/integration/components/kubernetes/page/roles-test.js
new file mode 100644
index 0000000000..6d29c35706
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/page/roles-test.js
@@ -0,0 +1,101 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render, click } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | Page::Roles', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.pushPayload('secret-engine', {
+ modelName: 'secret-engine',
+ data: {
+ accessor: 'kubernetes_f3400dee',
+ path: 'kubernetes-test/',
+ type: 'kubernetes',
+ },
+ });
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-test',
+ ...this.server.create('kubernetes-config'),
+ });
+ this.store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ ...this.server.create('kubernetes-role'),
+ });
+ this.backend = this.store.peekRecord('secret-engine', 'kubernetes-test');
+ this.config = this.store.peekRecord('kubernetes/config', 'kubernetes-test');
+ this.roles = this.store.peekAll('kubernetes/role');
+ this.filterValue = '';
+ this.breadcrumbs = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: this.backend.id },
+ ];
+
+ this.renderComponent = () => {
+ return render(
+ hbs` `,
+ { owner: this.engine }
+ );
+ };
+ });
+
+ test('it should render tab page header and config cta', async function (assert) {
+ this.config = null;
+ await this.renderComponent();
+ assert.dom('.title svg').hasClass('flight-icon-kubernetes', 'Kubernetes icon renders in title');
+ assert.dom('.title').hasText('kubernetes-test', 'Mount path renders in title');
+ assert.dom('[data-test-toolbar-roles-action]').hasText('Create role', 'Toolbar action has correct text');
+ assert
+ .dom('[data-test-toolbar-roles-action] svg')
+ .hasClass('flight-icon-plus', 'Toolbar action has correct icon');
+ assert.dom('[data-test-nav-input]').exists('Roles filter input renders');
+ assert.dom('[data-test-config-cta]').exists('Config cta renders');
+ });
+
+ test('it should render create roles cta', async function (assert) {
+ this.roles = null;
+ await this.renderComponent();
+ assert.dom('[data-test-empty-state-title]').hasText('No roles yet', 'Title renders');
+ assert
+ .dom('[data-test-empty-state-message]')
+ .hasText(
+ 'When created, roles will be listed here. Create a role to start generating service account tokens.',
+ 'Message renders'
+ );
+ assert.dom('[data-test-empty-state-actions] a').hasText('Create role', 'Action renders');
+ });
+
+ test('it should render no matches filter message', async function (assert) {
+ this.roles = [];
+ this.filterValue = 'test';
+ await this.renderComponent();
+ assert
+ .dom('[data-test-empty-state-title]')
+ .hasText('There are no roles matching "test"', 'Filter message renders');
+ });
+
+ test('it should render roles list', async function (assert) {
+ this.server.post('/sys/capabilities-self', () => ({
+ data: {
+ 'kubernetes/role': ['root'],
+ },
+ }));
+ await this.renderComponent();
+ assert.dom('[data-test-list-item-content] svg').hasClass('flight-icon-user', 'List item icon renders');
+ assert
+ .dom('[data-test-list-item-content]')
+ .hasText(this.roles.firstObject.name, 'List item name renders');
+ await click('[data-test-popup-menu-trigger]');
+ assert.dom('[data-test-details]').hasText('Details', 'Details link renders in menu');
+ assert.dom('[data-test-edit]').hasText('Edit', 'Edit link renders in menu');
+ assert.dom('[data-test-delete]').hasText('Delete', 'Details link renders in menu');
+ });
+});
diff --git a/ui/tests/integration/components/kubernetes/tab-page-header-test.js b/ui/tests/integration/components/kubernetes/tab-page-header-test.js
new file mode 100644
index 0000000000..beb90057b3
--- /dev/null
+++ b/ui/tests/integration/components/kubernetes/tab-page-header-test.js
@@ -0,0 +1,80 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { render } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | kubernetes | TabPageHeader', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kubernetes');
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.pushPayload('secret-engine', {
+ modelName: 'secret-engine',
+ data: {
+ accessor: 'kubernetes_f3400dee',
+ path: 'kubernetes-test/',
+ type: 'kubernetes',
+ },
+ });
+ this.model = this.store.peekRecord('secret-engine', 'kubernetes-test');
+ this.mount = this.model.path.slice(0, -1);
+ this.breadcrumbs = [{ label: 'secrets', route: 'secrets', linkExternal: true }, { label: this.mount }];
+ });
+
+ test('it should render breadcrumbs', async function (assert) {
+ await render(hbs` `, {
+ owner: this.engine,
+ });
+ assert.dom('[data-test-breadcrumbs] li:nth-child(1) a').hasText('secrets', 'Secrets breadcrumb renders');
+
+ assert
+ .dom('[data-test-breadcrumbs] li:nth-child(2)')
+ .containsText(this.mount, 'Mount path breadcrumb renders');
+ });
+
+ test('it should render title', async function (assert) {
+ await render(hbs` `, {
+ owner: this.engine,
+ });
+ assert
+ .dom('[data-test-header-title] svg')
+ .hasClass('flight-icon-kubernetes', 'Correct icon renders in title');
+ assert.dom('[data-test-header-title]').hasText(this.mount, 'Mount path renders in title');
+ });
+
+ test('it should render tabs', async function (assert) {
+ await render(hbs` `, {
+ owner: this.engine,
+ });
+ assert.dom('[data-test-tab="overview"]').hasText('Overview', 'Overview tab renders');
+ assert.dom('[data-test-tab="roles"]').hasText('Roles', 'Roles tab renders');
+ assert.dom('[data-test-tab="config"]').hasText('Configuration', 'Configuration tab renders');
+ });
+
+ test('it should render filter for roles', async function (assert) {
+ await render(
+ hbs` `,
+ { owner: this.engine }
+ );
+ assert.dom('[data-test-nav-input] input').hasValue('test', 'Filter renders with provided value');
+ });
+
+ test('it should yield block for toolbar actions', async function (assert) {
+ await render(
+ hbs`
+
+ It yields!
+
+ `,
+ { owner: this.engine }
+ );
+
+ assert
+ .dom('.toolbar-actions [data-test-yield]')
+ .hasText('It yields!', 'Block is yielded for toolbar actions');
+ });
+});
diff --git a/ui/tests/unit/adapters/kubernetes/config-test.js b/ui/tests/unit/adapters/kubernetes/config-test.js
new file mode 100644
index 0000000000..a52838198e
--- /dev/null
+++ b/ui/tests/unit/adapters/kubernetes/config-test.js
@@ -0,0 +1,56 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+
+module('Unit | Adapter | kubernetes/config', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.unloadAll('kubernetes/config');
+ });
+
+ test('it should make request to correct endpoint when querying record', async function (assert) {
+ assert.expect(1);
+ this.server.get('/kubernetes-test/config', () => {
+ assert.ok('GET request made to correct endpoint when querying record');
+ });
+ await this.store.queryRecord('kubernetes/config', { backend: 'kubernetes-test' });
+ });
+
+ test('it should make request to correct endpoint when creating new record', async function (assert) {
+ assert.expect(1);
+ this.server.post('/kubernetes-test/config', () => {
+ assert.ok('POST request made to correct endpoint when creating new record');
+ });
+ const record = this.store.createRecord('kubernetes/config', { backend: 'kubernetes-test' });
+ await record.save();
+ });
+
+ test('it should make request to correct endpoint when updating record', async function (assert) {
+ assert.expect(1);
+ this.server.post('/kubernetes-test/config', () => {
+ assert.ok('POST request made to correct endpoint when updating record');
+ });
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-test',
+ });
+ const record = this.store.peekRecord('kubernetes/config', 'kubernetes-test');
+ await record.save();
+ });
+
+ test('it should make request to correct endpoint when deleting record', async function (assert) {
+ assert.expect(1);
+ this.server.delete('/kubernetes-test/config', () => {
+ assert.ok('DELETE request made to correct endpoint when deleting record');
+ });
+ this.store.pushPayload('kubernetes/config', {
+ modelName: 'kubernetes/config',
+ backend: 'kubernetes-test',
+ });
+ const record = this.store.peekRecord('kubernetes/config', 'kubernetes-test');
+ await record.destroyRecord();
+ });
+});
diff --git a/ui/tests/unit/adapters/kubernetes/role-test.js b/ui/tests/unit/adapters/kubernetes/role-test.js
new file mode 100644
index 0000000000..8a785ea8af
--- /dev/null
+++ b/ui/tests/unit/adapters/kubernetes/role-test.js
@@ -0,0 +1,71 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+
+module('Unit | Adapter | kubernetes/role', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.unloadAll('kubernetes/role');
+ });
+
+ test('it should make request to correct endpoint when listing records', async function (assert) {
+ assert.expect(1);
+ this.server.get('/kubernetes-test/roles', (schema, req) => {
+ assert.ok(req.queryParams.list, 'GET request made to correct endpoint when listing records');
+ return { data: { keys: ['test-role'] } };
+ });
+ await this.store.query('kubernetes/role', { backend: 'kubernetes-test' });
+ });
+
+ test('it should make request to correct endpoint when querying record', async function (assert) {
+ assert.expect(1);
+ this.server.get('/kubernetes-test/roles/test-role', () => {
+ assert.ok('GET request made to correct endpoint when querying record');
+ return { data: {} };
+ });
+ await this.store.queryRecord('kubernetes/role', { backend: 'kubernetes-test', name: 'test-role' });
+ });
+
+ test('it should make request to correct endpoint when creating new record', async function (assert) {
+ assert.expect(1);
+ this.server.post('/kubernetes-test/roles/test-role', () => {
+ assert.ok('POST request made to correct endpoint when creating new record');
+ });
+ const record = this.store.createRecord('kubernetes/role', {
+ backend: 'kubernetes-test',
+ name: 'test-role',
+ });
+ await record.save();
+ });
+
+ test('it should make request to correct endpoint when updating record', async function (assert) {
+ assert.expect(1);
+ this.server.post('/kubernetes-test/roles/test-role', () => {
+ assert.ok('POST request made to correct endpoint when updating record');
+ });
+ this.store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ name: 'test-role',
+ });
+ const record = this.store.peekRecord('kubernetes/role', 'test-role');
+ await record.save();
+ });
+
+ test('it should make request to correct endpoint when deleting record', async function (assert) {
+ assert.expect(1);
+ this.server.delete('/kubernetes-test/roles/test-role', () => {
+ assert.ok('DELETE request made to correct endpoint when deleting record');
+ });
+ this.store.pushPayload('kubernetes/role', {
+ modelName: 'kubernetes/role',
+ backend: 'kubernetes-test',
+ name: 'test-role',
+ });
+ const record = this.store.peekRecord('kubernetes/role', 'test-role');
+ await record.destroyRecord();
+ });
+});