Kubernetes Secrets Engine (#17893)

* Ember Engine for Kubernetes Secrets Engine (#17881)

* adds in-repo ember engine for kubernetes secrets engine

* updates kubernetes engine class name

* Kubernetes route plumbing (#17895)

* kubernetes route plumbing

* adds kubernetes role index route with redirect to details

* adds kubernetes as mountable and supported secrets engine (#17891)

* adds models, adapters and serializers for kubernetes secrets engine (#18010)

* adds mirage factories and handlers for kubernetes (#17943)

* Kubernetes Secrets Engine Configuration (#18093)

* moves RadioCard component to core addon

* adds kubernetes configuration view

* fixes tests using RadioCard after label for and input id changes

* adds confirm modal when editing kubernetes config

* addresses review comments

* Kubernetes Configuration View (#18147)

* removes configuration edit and index routes

* adds kubernetes configuration view

* Kubernetes Roles List (#18211)

* removes configuration edit and index routes

* adds kubernetes configuration view

* adds kubernetes secrets engine roles list view

* updates role details disabled state to explicitly check for false

* VAULT-9863 Kubernetes Overview Page (#18232)

* Add overview page view

* Add overview page tests

* Address feedback to update tests and minor changes

* Use template built in helper for conditionally showing num roles

* Set up roleOptions in constructor

* Set up models in tests and fix minor bug

* Kubernetes Secrets Engine Create/Edit Views (#18271)

* moves kv-object-editor to core addon

* moves json-editor to core addon

* adds kubernetes secrets engine create/edit views

* updates kubernetes/role adapter test

* addresses feedback

* fixes issue with overview route showing 404 page (#18303)

* Kubernetes Role Details View (#18294)

* moves format-duration helper to core addon

* adds kubernetes secrets engine role details view

* adds tests for role details page component

* adds capabilities checks for toolbar actions

* fixes list link for secrets in an ember engine (#18313)

* Manual Testing: Bug Fixes and Improvements (#18333)

* updates overview, configuration and roles components to pass args for individual model properties

* bug fixes and improvements

* adds top level index route to redirect to overview

* VAULT-9877 Kubernetes Credential Generate/View Pages (#18270)

* Add credentials route with create and view components

* Update mirage response for creds and add ajax post call for creds in adapter

* Move credentials create and view into one component

* Add test classes

* Remove files and update backend property name

* Code cleanup and add tests

* Put test helper in helper function

* Add one more test!

* Add code optimizations

* Fix model in route and add form

* Add onSubmit to form and preventDefault

* Fix tests

* Update mock data for test to be strong rather than record

* adds acceptance tests for kubernetes secrets engine roles (#18360)

* VAULT-11862 Kubernetes acceptance tests (#18431)

* VAULT-12185 overview acceptance tests

* VAULT-12298 credentials acceptance tests

* VAULT-12186 configuration acceptance tests

* VAULT-12127 Refactor breadcrumbs to use breadcrumb component (#18489)

* VAULT-12127 Refactor breadcrumbs to use Page::Breadcrumbs component

* Fix failing tests by adding breadcrumbs properties

* VAULT-12166 add jsdocs to kubernetes secrets engine pages (#18509)

* fixes incorrect merge conflict resolution

* updates kubernetes check env vars endpoint (#18588)

* hides kubernetes ca cert field if not defined in configuration view

* fixes loading substate handling issue (#18592)

* adds changelog entry

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Jordan Reimer 2023-01-18 14:02:41 -07:00 committed by GitHub
parent 553e1cfb0d
commit 2e44d2020a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 3676 additions and 7 deletions

3
changelog/17893.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Adds Kubernetes secrets engine
```

View File

@ -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');
}
}

View File

@ -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,
};
});
}
}

View File

@ -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: [

View File

@ -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() {

View File

@ -12,6 +12,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'kmip',
'transform',
'keymgmt',
'kubernetes',
];
export function supportedSecretBackends() {

View File

@ -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 pods 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 pods CA if found.',
editType: 'textarea',
})
kubernetesCaCert;
@attr('boolean', { defaultValue: false }) disableLocalCaJwt;
}

View File

@ -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 roles 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');
}
}

View File

@ -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');
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -31,11 +31,11 @@
</p>
{{/if}}
{{#if (has-block)}}
<div class="empty-state-actions">
<div class="empty-state-actions" data-test-empty-state-actions>
{{yield}}
</div>
{{else if this.emptyActions}}
<div class="empty-state-actions">
<div class="empty-state-actions" data-test-empty-state-actions>
{{component this.emptyActions}}
</div>
{{/if}}

View File

@ -0,0 +1 @@
export { default } from 'core/components/json-editor';

View File

@ -0,0 +1 @@
export { default } from 'core/components/kv-object-editor';

View File

@ -0,0 +1 @@
export { default } from 'core/helpers/format-duration';

View File

@ -0,0 +1 @@
export { default } from 'core/modifiers/code-mirror';

View File

@ -26,6 +26,8 @@
"ember-wormhole": "*",
"escape-string-regexp": "*",
"@hashicorp/ember-flight-icons": "*",
"@hashicorp/flight-icons": "*"
"@hashicorp/flight-icons": "*",
"codemirror": "*",
"ember-modifier": "*"
}
}

View File

@ -0,0 +1,9 @@
<EmptyState
data-test-config-cta
@title="Kubernetes not configured"
@message="Get started by establishing the URL of the Kubernetes API to connect to, along with some additional options."
>
<LinkTo class="has-top-margin-xs" @route="configure">
Configure Kubernetes
</LinkTo>
</EmptyState>

View File

@ -0,0 +1,51 @@
<TabPageHeader @model={{@backend}} @breadcrumbs={{@breadcrumbs}}>
<ToolbarLink @route="configure" data-test-toolbar-config-action>
{{if @config "Edit configuration" "Configure Kubernetes"}}
</ToolbarLink>
</TabPageHeader>
{{#if @config}}
{{#if @config.disableLocalCaJwt}}
<InfoTableRow @label="Kubernetes host" @value={{@config.kubernetesHost}} />
{{#if @config.kubernetesCaCert}}
<InfoTableRow @label="Certificate">
<div class="column is-half box is-rounded">
<div class="is-flex-row">
<span class="has-left-margin-s">
<Icon @name="certificate" @size="24" data-test-certificate-icon />
</span>
<div class="has-left-margin-m is-min-width-0">
<p class="has-text-weight-bold" data-test-certificate-label>
PEM Format
</p>
<code class="is-size-8 truncate-second-line has-text-grey" data-test-certificate-value>
{{@config.kubernetesCaCert}}
</code>
</div>
<div class="is-flex has-background-grey-lighter has-side-padding-s has-top-bottom-margin-negative-m">
<CopyButton
data-test-certificate-copy
class="button is-transparent is-flex-v-centered"
@clipboardText={{@config.kubernetesCaCert}}
@buttonType="button"
@success={{action (set-flash-message "Certificate copied")}}
>
<Icon @name="clipboard-copy" aria-label="Copy" />
</CopyButton>
</div>
</div>
</div>
</InfoTableRow>
{{/if}}
{{else}}
<div class="has-top-margin-l" data-test-inferred-message>
<Icon @name="check-circle-fill" class="has-text-green" />
<span>
These details were successfully inferred from Vaults kubernetes environment and were not explicity set in this
config.
</span>
</div>
{{/if}}
{{else}}
<ConfigCta />
{{/if}}

View File

@ -0,0 +1,117 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Configure kubernetes
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m">
To customize your configuration, specify the type of Kubernetes cluster that credentials will be generated for.
</p>
<div class="is-flex-row has-top-margin-s">
<RadioCard
class="has-fixed-width"
@title="Local cluster"
@description="Generate credentials for the local Kubernetes cluster that Vault is running on, using Vaults service account."
@icon="kubernetes-color"
@value={{false}}
@groupValue={{@model.disableLocalCaJwt}}
@onChange={{this.onRadioSelect}}
data-test-radio-card="local"
/>
<RadioCard
class="has-fixed-width"
@title="Manual configuration"
@description="Generate credentials for an external Kubernetes cluster, using a service account that you specify."
@icon="vault"
@iconClass="has-text-black"
@value={{true}}
@groupValue={{@model.disableLocalCaJwt}}
@onChange={{this.onRadioSelect}}
data-test-radio-card="manual"
/>
</div>
<div class="has-top-margin-m" data-test-config>
{{#if @model.disableLocalCaJwt}}
<MessageError @errorMessage={{this.error}} />
{{#each @model.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
{{else if (eq this.inferredState "success")}}
<Icon @name="check-circle-fill" class="has-text-green" />
<span>Configuration values were inferred successfully.</span>
{{else if (eq this.inferredState "error")}}
<Icon @name="x-square-fill" class="has-text-red" />
<span class="has-text-red">
Vault could not infer a configuration from your environment variables. Check your configuration file to edit or delete
them, or configure manually.
</span>
{{else}}
<p>
Configuration values can be inferred from the pod and your local environment variables.
</p>
<div>
<button
class="button has-top-margin-s {{if this.fetchInferred.isRunning 'is-loading'}}"
type="button"
disabled={{this.fetchInferred.isRunning}}
{{on "click" (perform this.fetchInferred)}}
>
Get config values
</button>
</div>
{{/if}}
</div>
<hr class="has-background-gray-200 has-top-margin-l" />
<div class="has-top-margin-s has-bottom-margin-s">
<button
data-test-config-save
class="button is-primary"
type="button"
disabled={{this.isDisabled}}
{{on "click" (perform this.save)}}
>
Save
</button>
<button
data-test-config-cancel
class="button has-left-margin-xs"
type="button"
disabled={{or this.save.isRunning this.fetchInferred.isRunning}}
{{on "click" this.cancel}}
>
Back
</button>
</div>
{{#if this.showConfirm}}
<Modal
@title="Edit configuration"
@type="warning"
@isActive={{this.showConfirm}}
@showCloseButton={{true}}
@onClose={{fn (mut this.showConfirm) false}}
>
<section class="modal-card-body">
<p>
Making changes to your configuration may affect how Vault will reach the Kubernetes API and authenticate with it. Are
you sure?
</p>
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button data-test-config-confirm type="button" class="button is-primary" {{on "click" (perform this.save)}}>
Confirm
</button>
<button type="button" class="button" onclick={{fn (mut this.showConfirm) false}}>
Cancel
</button>
</footer>
</Modal>
{{/if}}

View File

@ -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);
}
}

View File

@ -0,0 +1,119 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3 has-bottom-margin-2" data-test-credentials-header>
{{if this.credentials "Credentials" "Generate credentials"}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if this.credentials}}
<div class="box is-sideless is-fullwidth is-marginless has-bottom-padding-l" data-test-credentials-details>
<AlertBanner
@class="is-marginless"
@type="warning"
@title="Warning"
@message="You won't be able to access these credentials later, so please copy them now."
/>
<InfoTableRow @label="Service account token">
<MaskedInput
@value={{this.credentials.service_account_token}}
@name="Service Account Token"
@displayOnly={{true}}
@allowCopy={{true}}
/>
</InfoTableRow>
<InfoTableRow
@label="Namespace"
@value={{this.credentials.service_account_namespace}}
@addCopyButton={{true}}
@alwaysRender={{true}}
/>
<InfoTableRow
@label="Service account name"
@value={{this.credentials.service_account_name}}
@addCopyButton={{true}}
@alwaysRender={{true}}
/>
<InfoTableRow @label="Lease expiry" @value={{date-format this.leaseExpiry "MMMM do yyyy, h:mm:ss a"}} />
<InfoTableRow @label="lease_id" @value={{this.credentials.lease_id}} />
<InfoTableRow />
</div>
<div class="has-top-margin-l">
<button class="button is-primary" type="button" data-test-generate-credentials-done {{on "click" this.cancel}}>
Done
</button>
</div>
{{else}}
<div data-test-generate-credentials>
<form {{on "submit" (perform this.fetchCredentials)}}>
<div class="field box is-sideless is-fullwidth is-marginless">
<p>This will generate credentials using the role <span class="is-font-mono">{{@roleName}}</span>.</p>
{{#if this.error}}
<MessageError class="has-top-margin-l" @errorMessage={{this.error}} />
{{/if}}
<label for="token" class="is-label has-top-margin-l">Kubernetes namespace</label>
<div class="has-text-grey is-size-8 has-bottom-margin-xs">
The namespace in which to generate the credentials.
</div>
<Input
@type="text"
@value={{this.kubernetesNamespace}}
class="input"
{{on "input" this.setKubernetesNamespace}}
data-test-kubernetes-namespace
/>
<div class="has-top-margin-l has-bottom-margin-l">
<Toggle
@status="success"
@size="small"
@checked={{this.clusterRoleBinding}}
@onChange={{(toggle-action "clusterRoleBinding" this)}}
data-test-kubernetes-clusterRoleBinding
>
<h3 class="title is-7 is-marginless">ClusterRoleBinding</h3>
<div class="description has-text-grey">
<span>
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.
</span>
</div>
</Toggle>
</div>
<TtlPicker
class="has-top-margin-l has-bottom-margin-m"
@initialEnabled={{false}}
@label="Time-to-Live (TTL)"
@onChange={{this.updateTtl}}
@helperTextDisabled="The TTL of the generated Kubernetes service account token. Defaults to the role's default TTL, or the default system TTL."
/>
</div>
<div class="has-top-margin-l">
<button
class="button is-primary {{if this.fetchCredentials.isRunning 'is-loading'}}"
type="submit"
disabled={{this.fetchCredentials.isRunning}}
data-test-generate-credentials-button
>
Generate credentials
</button>
<button
class="button has-left-margin-xs"
type="button"
disabled={{this.fetchCredentials.isRunning}}
{{on "click" this.cancel}}
data-test-generate-credentials-back
>
Back
</button>
</div>
</form>
</div>
{{/if}}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,54 @@
<TabPageHeader @model={{@backend}} @breadcrumbs={{@breadcrumbs}}>
<ToolbarLink @route="configure">Configure Kubernetes</ToolbarLink>
</TabPageHeader>
{{#if @config}}
<div class="has-top-margin-m">
<div class="is-flex">
<div class="box has-padding-m has-right-margin-m is-rounded column is-flex-half" data-test-roles-card>
<div class="is-flex-between">
<h2 class="title is-3">
Roles
</h2>
{{#if @roles.length}}
<LinkTo class="is-no-underline" @route="roles">View Roles</LinkTo>
{{else}}
<LinkTo class="is-no-underline" @route="roles.create">Create Role</LinkTo>
{{/if}}
</div>
<p>The number of Vault roles being used to generate Kubernetes credentials.</p>
<h2 class="title has-font-weight-normal is-3 has-top-margin-l">
{{or @roles.length "None"}}
</h2>
</div>
<div class="box has-padding-m is-rounded column is-flex-half" data-test-generate-credential-card>
<h2 class="title is-3">
Generate credentials
</h2>
<p>Quickly generate credentials by typing the role name.</p>
<div class="is-flex-center has-top-margin-s">
<SearchSelect
class="is-marginless is-flex-1"
@placeholder="Type to find a role..."
@disallowNewItems={{true}}
@options={{this.roleOptions}}
@selectLimit="1"
@fallbackComponent="input-search"
@onChange={{this.selectRole}}
/>
<button
class="button has-left-margin-m"
type="button"
disabled={{not this.selectedRole}}
{{on "click" this.generateCredential}}
data-test-generate-credential-button
>
Generate
</button>
</div>
</div>
</div>
</div>
{{else}}
<ConfigCta />
{{/if}}

View File

@ -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
);
}
}

View File

@ -0,0 +1,139 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
{{if @model.isNew "Create role" "Edit role"}}
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m">
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.
</p>
<div class="is-flex-row has-top-margin-s">
{{#each this.generationPreferences as |pref|}}
<RadioCard
@title={{pref.title}}
@description={{pref.description}}
@icon="token"
@value={{pref.value}}
@groupValue={{@model.generationPreference}}
@onChange={{this.changePreference}}
data-test-radio-card={{pref.value}}
/>
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-l">
<h2 class="title is-4">
Role options
</h2>
<hr class="is-marginless has-background-gray-200" />
</div>
{{#if @model.generationPreference}}
<form id="role" {{on "submit" this.onSave}} data-test-policy-form>
{{#each @model.filteredFormFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<div class="has-bottom-margin-s">
<ToggleButton
data-test-field="annotations"
@isOpen={{this.showAnnotations}}
@openLabel="Hide annotations and labels"
@closedLabel="Annotations and labels"
@onClick={{fn (mut this.showAnnotations)}}
/>
{{#if this.showAnnotations}}
<div class="box" data-test-annotations>
{{#each this.extraFields as |field|}}
<div class={{if (eq field.type "labels") "has-top-margin-xl"}}>
<h2 class="title is-4">Extra {{field.type}}</h2>
<p>
{{field.description}}
See
<ExternalLink @href="https://kubernetes.io/docs/concepts/overview/working-with-objects/{{field.type}}/">
Kubernetes
{{singularize field.type}}
documentation here
</ExternalLink>.
</p>
<KvObjectEditor
class="has-top-margin-m"
data-test-kv={{field.type}}
@value={{get @model field.key}}
@onChange={{fn (mut (get @model field.key))}}
/>
</div>
<hr />
{{/each}}
</div>
{{/if}}
</div>
{{#if (eq @model.generationPreference "full")}}
<div class="has-top-margin-m has-bottom-margin-l" data-test-generated-role-rules>
<h2 class="title is-4">
Generated role rules
</h2>
<FormFieldLabel
for="templates"
@label="Role rules template"
@subText="Start with a template for role rules based on your use case"
/>
<div class="select is-fullwidth">
<select id="templates" data-test-select-template {{on "change" this.selectTemplate}}>
{{#each this.roleRulesTemplates as |template|}}
<option selected={{eq this.selectedTemplateId template.id}} value={{template.id}}>
{{template.label}}
</option>
{{/each}}
</select>
</div>
{{#let (find-by "id" this.selectedTemplateId this.roleRulesTemplates) as |template|}}
<JsonEditor
class="has-top-margin-l"
data-test-rules
@title="Role rules"
@value={{template.rules}}
@mode="ruby"
@valueUpdated={{fn (mut template.rules)}}
@helpText={{this.roleRulesHelpText}}
>
<button type="button" class="toolbar-link" {{on "click" this.resetRoleRules}} data-test-restore-example>
Restore example
<Icon @name="reload" />
</button>
</JsonEditor>
{{/let}}
</div>
{{/if}}
</form>
{{else}}
<EmptyState
class="is-shadowless"
@title="Choose an option above"
@message="To configure a Vault role, choose what should be generated in Kubernetes by Vault."
/>
{{/if}}
<hr class="is-marginless has-background-gray-200" />
<div class="has-top-margin-l has-bottom-margin-s">
<button
type="submit"
form="role"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{or (not @model.generationPreference) this.save.isRunning}}
data-test-save
>
Save
</button>
<button type="button" class="button has-left-margin-s" data-test-cancel {{on "click" this.cancel}}>
Back
</button>
</div>

View File

@ -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 =
'<a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/" target="_blank" rel="noopener noreferrer">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');
}
}

View File

@ -0,0 +1,64 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
{{@model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<ConfirmAction @buttonClasses="toolbar-link" @onConfirmAction={{this.delete}} data-test-delete>
Delete role
</ConfirmAction>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @model.canGenerateCreds}}
<ToolbarLink @route="roles.role.credentials" data-test-generate-credentials>
Generate credentials
</ToolbarLink>
{{/if}}
{{#if @model.canEdit}}
<ToolbarLink @route="roles.role.edit" data-test-edit>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#each @model.filteredFormFields as |field|}}
{{#let (get @model field.name) as |value|}}
<InfoTableRow
data-test-filtered-field
@label={{field.options.label}}
@value={{if (eq field.options.editType "ttl") (format-duration value) value}}
/>
{{/let}}
{{/each}}
{{#if @model.generatedRoleRules}}
<div class="has-top-margin-xl" data-test-generated-role-rules>
<h2 class="title is-4">Generated role rules</h2>
<JsonEditor
@title="Role rules"
@value={{@model.generatedRoleRules}}
@mode="ruby"
@readOnly={{true}}
@showToolbar={{true}}
@theme="hashi auto-height"
/>
</div>
{{/if}}
{{#each this.extraFields as |field|}}
<div class="has-top-margin-xl" data-test-extra-fields={{field.label}}>
<h2 class="title is-4 is-marginless">{{field.label}}</h2>
{{#each-in (get @model field.key) as |key value|}}
<InfoTableRow @label={{key}} @value={{value}} />
{{/each-in}}
</div>
{{/each}}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,76 @@
<TabPageHeader @model={{@backend}} @filterRoles={{true}} @rolesFilterValue={{@filterValue}} @breadcrumbs={{@breadcrumbs}}>
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-roles-action>
Create role
</ToolbarLink>
</TabPageHeader>
{{#if (not @config)}}
<ConfigCta />
{{else if (not @roles)}}
{{#if @filterValue}}
<EmptyState @title="There are no roles matching &quot;{{@filterValue}}&quot;" />
{{else}}
<EmptyState
data-test-config-cta
@title="No roles yet"
@message="When created, roles will be listed here. Create a role to start generating service account tokens."
>
<LinkTo class="has-top-margin-xs" @route="roles.create">
Create role
</LinkTo>
</EmptyState>
{{/if}}
{{else}}
<div class="has-bottom-margin-s">
{{#each @roles as |role|}}
<ListItem @linkPrefix={{this.mountPoint}} @linkParams={{array "roles.role.details" role.name}} as |Item|>
<Item.content>
<Icon @name="user" />
<span data-test-role={{role.name}}>{{role.name}}</span>
</Item.content>
<Item.menu as |Menu|>
{{#if role.rolesPath.isLoading}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-details
@route="roles.role.details"
@model={{role}}
@disabled={{eq role.canRead false}}
>
Details
</LinkTo>
</li>
<li class="action">
<LinkTo
class="has-text-black has-text-weight-semibold"
data-test-edit
@route="roles.role.edit"
@model={{role}}
@disabled={{not role.canEdit}}
>
Edit
</LinkTo>
</li>
<li class="action">
<Menu.Message
data-test-delete
@id={{role.id}}
@triggerText="Delete"
@title="Are you sure?"
@message="Deleting this role means that youll need to recreate it in order to generate credentials again."
@onConfirm={{fn this.onDelete role}}
/>
</li>
{{/if}}
</Item.menu>
</ListItem>
{{/each}}
</div>
{{/if}}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,36 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
{{@model.id}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="kubernetes tabs">
<ul>
<LinkTo @route="overview" data-test-tab="overview">Overview</LinkTo>
<LinkTo @route="roles" data-test-tab="roles">Roles</LinkTo>
<LinkTo @route="configuration" data-test-tab="config">Configuration</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
{{#if @filterRoles}}
<ToolbarFilters>
<NavigateInput
@filter={{@rolesFilterValue}}
@placeholder="Filter roles"
@urls={{hash list="vault.cluster.secrets.backend.kubernetes.roles"}}
/>
</ToolbarFilters>
{{/if}}
<ToolbarActions>
{{yield}}
</ToolbarActions>
</Toolbar>

View File

@ -0,0 +1,5 @@
import Controller from '@ember/controller';
export default class KubernetesRolesController extends Controller {
queryParams = ['pageFilter'];
}

View File

@ -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);

View File

@ -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');
});

View File

@ -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 },
];
}
}

View File

@ -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 });
}
}

View File

@ -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
});
}
}
}

View File

@ -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');
}
}

View File

@ -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 },
];
}
}

View File

@ -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 });
}
}

View File

@ -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 },
];
}
}

View File

@ -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' },
];
}
}

View File

@ -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 },
];
}
}

View File

@ -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 });
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1 @@
<Page::Configuration @config={{this.model.config}} @backend={{this.model.backend}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -0,0 +1 @@
<Page::Configure @model={{this.model}} />

View File

@ -0,0 +1,6 @@
<Page::Overview
@config={{this.model.config}}
@backend={{this.model.backend}}
@roles={{this.model.roles}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -0,0 +1 @@
<Page::Role::CreateAndEdit @model={{this.model}} />

View File

@ -0,0 +1,7 @@
<Page::Roles
@roles={{this.model.roles}}
@config={{this.model.config}}
@backend={{this.model.backend}}
@filterValue={{this.pageFilter}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -0,0 +1 @@
<Page::Credentials @roleName={{this.model.roleName}} @backend={{this.model.backend}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -0,0 +1 @@
<Page::Role::Details @model={{@model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -0,0 +1 @@
<Page::Role::CreateAndEdit @model={{this.model}} />

View File

@ -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:
- <list of policies to authorize>
`;
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 },
];

View File

@ -0,0 +1,11 @@
/* eslint-env node */
'use strict';
module.exports = function (environment) {
const ENV = {
modulePrefix: 'kubernetes',
environment,
};
return ENV;
};

View File

@ -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;
},
});

View File

@ -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"
]
}
}

View File

@ -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,
});

View File

@ -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,
}),
});

View File

@ -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 };

View File

@ -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',
},
}));
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -244,6 +244,7 @@
"lib/css",
"lib/keep-gitkeep",
"lib/kmip",
"lib/kubernetes",
"lib/open-api-explorer",
"lib/pki",
"lib/replication",

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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'
);
});
});

View File

@ -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`<ConfigCta />`, { 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');
});
});

View File

@ -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`<Page::Configuration @backend={{this.backend}} @config={{this.config}} @breadcrumbs={{this.breadcrumbs}} />`,
{
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 Vaults 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');
});
});

View File

@ -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`<Page::Configure @model={{this.newModel}} />`, { 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`<Page::Configure @model={{this.newModel}} />`, { 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`<Page::Configure @model={{this.newModel}} />`, { 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`<Page::Configure @model={{this.editModel}} />`, { 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`<Page::Configure @model={{this.model}} />`, { 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`
<div id="modal-wormhole"></div>
<Page::Configure @model={{this.editModel}} />
`,
{ 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]');
});
});

View File

@ -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`<Page::Credentials @backend={{this.backend}} @roleName={{this.roleName}} @breadcrumbs={{this.breadcrumbs}}/>`,
{
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');
});
});

View File

@ -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`<Page::Overview @config={{this.config}} @backend={{this.backend}} @roles={{this.roles}} @breadcrumbs={{this.breadcrumbs}} />`,
{ 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');
});
});

View File

@ -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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.role}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.role}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.role}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.role}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.newModel}} />`, { 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`<Page::Role::CreateAndEdit @model={{this.role}} />`, { 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');
});
});

View File

@ -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`<Page::Role::Details @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, {
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);
});
});

View File

@ -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`<Page::Roles @config={{this.config}} @backend={{this.backend}} @roles={{this.roles}} @filterValue={{this.filterValue}} @breadcrumbs={{this.breadcrumbs}} />`,
{ 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');
});
});

View File

@ -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`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, {
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`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, {
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`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, {
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`<TabPageHeader @model={{this.model}} @filterRoles={{true}} @rolesFilterValue="test" @breadcrumbs={{this.breadcrumbs}} />`,
{ 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`
<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}}>
<span data-test-yield>It yields!</span>
</TabPageHeader>
`,
{ owner: this.engine }
);
assert
.dom('.toolbar-actions [data-test-yield]')
.hasText('It yields!', 'Block is yielded for toolbar actions');
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});