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. +

+
+ +
+ {{/if}} +
+ +
+ +
+ + +
+ +{{#if this.showConfirm}} + + +
+ + +
+
+{{/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}} +
+ + + + + + + + + +
+ +
+ +
+{{else}} +
+
+
+

This will generate credentials using the role {{@roleName}}.

+ + {{#if this.error}} + + {{/if}} + + +
+ The namespace in which to generate the credentials. +
+ + +
+ +

ClusterRoleBinding

+
+ + 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. + +
+
+
+ + +
+
+ + +
+
+
+{{/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.

+
+ + +
+
+
+
+{{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}} +
+ {{#each @model.filteredFormFields as |field|}} + + {{/each}} + +
+ + {{#if this.showAnnotations}} +
+ {{#each this.extraFields as |field|}} +
+

Extra {{field.type}}

+

+ {{field.description}} + See + + Kubernetes + {{singularize field.type}} + documentation here + . +

+ +
+
+ {{/each}} +
+ {{/if}} +
+ + {{#if (eq @model.generationPreference "full")}} +
+

+ Generated role rules +

+ +
+ +
+ {{#let (find-by "id" this.selectedTemplateId this.roleRulesTemplates) as |template|}} + + + + {{/let}} +
+ {{/if}} + +{{else}} + +{{/if}} + +
+ +
+ + +
\ 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}} +
  • + +
  • + {{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}} +

    +
    +
    + +
    + +
    + + + {{#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(); + }); +});