Kubernetes config state updates (#19074)

* hides roles toolbar actions when k8s is not configured

* adds error page component to core addon

* moves fetch-config to decorator

* updates kubernetes prompt config logic

* adds kubernetes error route

* fixes tests

* adds error handling for kubernetes roles list view

* removes unneeded arg to withConfig decorator
This commit is contained in:
Jordan Reimer 2023-02-09 09:18:02 -07:00 committed by GitHub
parent 074312dde2
commit bc5a598d70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 252 additions and 99 deletions

View File

@ -0,0 +1,25 @@
<div class="box is-sideless has-background-white-bis has-text-grey has-text-centered has-tall-padding" data-test-page-error>
{{#if (eq @error.httpStatus 404)}}
<h1 class="title is-3 has-text-grey">
404 Not Found
</h1>
<p>Sorry, we were unable to find any content at <code>{{@error.path}}</code>.</p>
{{else if (eq @error.httpStatus 403)}}
<h1 class="title is-3 has-text-grey">
Not authorized
</h1>
<p>You are not authorized to access content at <code>{{@error.path}}</code>.</p>
{{else}}
<h1 class="title is-3 has-text-grey">
Error
</h1>
<p>
{{#if @error.message}}
<p data-test-page-error-message>{{@error.message}}</p>
{{/if}}
{{#each @error.errors as |error index|}}
<p data-test-page-error-details={{index}}>{{error}}</p>
{{/each}}
</p>
{{/if}}
</div>

View File

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

View File

@ -2,7 +2,9 @@
<ToolbarLink @route="configure">Configure Kubernetes</ToolbarLink>
</TabPageHeader>
{{#if @config}}
{{#if @promptConfig}}
<ConfigCta />
{{else}}
<div class="selectable-card-container has-grid has-top-margin-l has-two-col-grid">
<OverviewCard
@cardTitle="Roles"
@ -38,6 +40,4 @@
</div>
</OverviewCard>
</div>
{{else}}
<ConfigCta />
{{/if}}

View File

@ -7,7 +7,7 @@ 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 {boolean} promptConfig - whether or not to display config cta
* @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

View File

@ -1,10 +1,17 @@
<TabPageHeader @model={{@backend}} @filterRoles={{true}} @rolesFilterValue={{@filterValue}} @breadcrumbs={{@breadcrumbs}}>
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-roles-action>
Create role
</ToolbarLink>
<TabPageHeader
@model={{@backend}}
@filterRoles={{not @promptConfig}}
@rolesFilterValue={{@filterValue}}
@breadcrumbs={{@breadcrumbs}}
>
{{#unless @promptConfig}}
<ToolbarLink @route="roles.create" @type="add" data-test-toolbar-roles-action>
Create role
</ToolbarLink>
{{/unless}}
</TabPageHeader>
{{#if (not @config)}}
{{#if @promptConfig}}
<ConfigCta />
{{else if (not @roles)}}
{{#if @filterValue}}

View File

@ -9,7 +9,7 @@ import errorMessage from 'vault/utils/error-message';
* 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 {boolean} promptConfig - whether or not to display config cta
* @param {array} pageFilter - array of filtered roles
* @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route
*/

View File

@ -0,0 +1,49 @@
import Route from '@ember/routing/route';
/**
* 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
* decorate the necessary routes to perform the check in the beforeModel hook since that may change what is returned for the model
*/
export function withConfig() {
return function decorator(SuperClass) {
if (!Object.prototype.isPrototypeOf.call(Route, SuperClass)) {
// eslint-disable-next-line
console.error(
'withConfig decorator must be used on an instance of ember Route class. Decorator not applied to returned class'
);
return SuperClass;
}
return class FetchConfig extends SuperClass {
configModel = null;
configError = null;
promptConfig = false;
async beforeModel() {
super.beforeModel(...arguments);
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((error) => {
// we need to ignore if the user does not have permission or other failures so as to not block the other operations
if (error.httpStatus === 404) {
this.promptConfig = true;
} else {
// not considering 404 an error since it triggers the cta
// this error is thrown in the configuration route so we can display the error in the view
this.configError = error;
}
});
}
}
};
};
}

View File

@ -1,7 +1,17 @@
import FetchConfigRoute from './fetch-config';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from '../decorators/fetch-config';
@withConfig()
export default class KubernetesConfigureRoute extends Route {
@service store;
@service secretMountPath;
export default class KubernetesConfigureRoute extends FetchConfigRoute {
model() {
// in case of any error other than 404 we want to display that to the user
if (this.configError) {
throw this.configError;
}
return {
backend: this.modelFor('application'),
config: this.configModel,

View File

@ -1,6 +1,12 @@
import FetchConfigRoute from './fetch-config';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from '../decorators/fetch-config';
@withConfig()
export default class KubernetesConfigureRoute extends Route {
@service store;
@service secretMountPath;
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,15 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class KubernetesErrorRoute extends Route {
@service secretMountPath;
setupController(controller) {
super.setupController(...arguments);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
];
controller.backend = this.modelFor('application');
}
}

View File

@ -1,31 +0,0 @@
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

@ -1,11 +1,17 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from 'kubernetes/decorators/fetch-config';
import { hash } from 'rsvp';
import FetchConfigRoute from './fetch-config';
export default class KubernetesOverviewRoute extends FetchConfigRoute {
@withConfig()
export default class KubernetesOverviewRoute extends Route {
@service store;
@service secretMountPath;
async model() {
const backend = this.secretMountPath.get();
return hash({
config: this.configModel,
promptConfig: this.promptConfig,
backend: this.modelFor('application'),
roles: this.store.query('kubernetes/role', { backend }).catch(() => []),
});

View File

@ -1,7 +1,13 @@
import FetchConfigRoute from '../fetch-config';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from 'kubernetes/decorators/fetch-config';
import { hash } from 'rsvp';
export default class KubernetesRolesRoute extends FetchConfigRoute {
@withConfig()
export default class KubernetesRolesRoute extends Route {
@service store;
@service secretMountPath;
model(params, transition) {
// filter roles based on pageFilter value
const { pageFilter } = transition.to.queryParams;
@ -12,10 +18,15 @@ export default class KubernetesRolesRoute extends FetchConfigRoute {
? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase()))
: models
)
.catch(() => []);
.catch((error) => {
if (error.httpStatus === 404) {
return [];
}
throw error;
});
return hash({
backend: this.modelFor('application'),
config: this.configModel,
promptConfig: this.promptConfig,
roles,
});
}

View File

@ -0,0 +1,3 @@
<TabPageHeader @model={{this.backend}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Error @error={{this.model}} />

View File

@ -1,5 +1,5 @@
<Page::Overview
@config={{this.model.config}}
@promptConfig={{this.model.promptConfig}}
@backend={{this.model.backend}}
@roles={{this.model.roles}}
@breadcrumbs={{this.breadcrumbs}}

View File

@ -1,6 +1,6 @@
<Page::Roles
@roles={{this.model.roles}}
@config={{this.model.config}}
@promptConfig={{this.model.promptConfig}}
@backend={{this.model.backend}}
@filterValue={{this.pageFilter}}
@breadcrumbs={{this.breadcrumbs}}

View File

@ -21,28 +21,4 @@
</nav>
</div>
<div class="box is-sideless has-background-white-bis has-text-grey has-text-centered has-tall-padding" data-test-pki-error>
{{#if (eq this.model.httpStatus 404)}}
<h1 class="title is-3 has-text-grey">
404 Not Found
</h1>
<p>Sorry, we were unable to find any content at <code>{{or this.model.path this.path}}</code>.</p>
{{else if (eq this.model.httpStatus 403)}}
<h1 class="title is-3 has-text-grey">
Not authorized
</h1>
<p>You are not authorized to access content at <code>{{or this.model.path this.path}}</code>.</p>
{{else}}
<h1 class="title is-3 has-text-grey">
Error
</h1>
<p>
{{#if this.model.message}}
<p>{{this.model.message}}</p>
{{/if}}
{{#each this.model.errors as |error|}}
<p>{{error}}</p>
{{/each}}
</p>
{{/if}}
</div>
<Page::Error @error={{this.model}} />

View File

@ -5,6 +5,7 @@ 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 { Response } from 'miragejs';
module('Acceptance | kubernetes | configuration', function (hooks) {
setupApplicationTest(hooks);
@ -13,6 +14,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) {
hooks.before(function () {
ENV['ember-cli-mirage'].handler = 'kubernetes';
});
hooks.beforeEach(function () {
kubernetesScenario(this.server);
this.visitConfiguration = () => {
@ -23,6 +25,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) {
};
return authPage.login();
});
hooks.after(function () {
ENV['ember-cli-mirage'].handler = null;
});
@ -33,6 +36,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) {
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();
@ -41,6 +45,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) {
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();
@ -48,4 +53,25 @@ module('Acceptance | kubernetes | configuration', function (hooks) {
await click('[data-test-config-cancel]');
this.validateRoute(assert, 'configuration', 'Transitions to Configuration route on click');
});
test('it should transition to error route on config fetch error other than 404', async function (assert) {
this.server.get('/kubernetes/config', () => new Response(403));
await this.visitConfiguration();
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kubernetes.error',
'Transitions to error route on config fetch error'
);
});
test('it should not transition to error route on config fetch 404', async function (assert) {
this.server.get('/kubernetes/config', () => new Response(404));
await this.visitConfiguration();
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kubernetes.configuration',
'Transitions to configuration route on fetch 404'
);
assert.dom('[data-test-empty-state-title]').hasText('Kubernetes not configured', 'Config cta renders');
});
});

View File

@ -22,11 +22,6 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks)
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',
@ -38,15 +33,15 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks)
...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.promptConfig = false;
this.renderComponent = () => {
return render(
hbs`<Page::Overview @config={{this.config}} @backend={{this.backend}} @roles={{this.roles}} @breadcrumbs={{this.breadcrumbs}} />`,
hbs`<Page::Overview @promptConfig={{this.promptConfig}} @backend={{this.backend}} @roles={{this.roles}} @breadcrumbs={{this.breadcrumbs}} />`,
{ owner: this.engine }
);
};
@ -98,7 +93,7 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks)
});
test('it should show ConfigCta when no config is set up', async function (assert) {
this.config = null;
this.promptConfig = true;
await this.renderComponent();
assert.dom(SELECTORS.emptyStateTitle).hasText('Kubernetes not configured');

View File

@ -20,49 +20,50 @@ module('Integration | Component | kubernetes | Page::Roles', function (hooks) {
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.promptConfig = false;
this.renderComponent = () => {
return render(
hbs`<Page::Roles @config={{this.config}} @backend={{this.backend}} @roles={{this.roles}} @filterValue={{this.filterValue}} @breadcrumbs={{this.breadcrumbs}} />`,
hbs`<Page::Roles @promptConfig={{this.promptConfig}} @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;
this.promptConfig = true;
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');
.dom('[data-test-toolbar-roles-action]')
.doesNotExist('Create role', 'Toolbar action does not render when not configured');
assert
.dom('[data-test-nav-input]')
.doesNotExist('Roles filter input does not render when not configured');
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-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-empty-state-title]').hasText('No roles yet', 'Title renders');
assert
.dom('[data-test-empty-state-message]')

View File

@ -0,0 +1,53 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | page/error', function (hooks) {
setupRenderingTest(hooks);
test('it should render 404 error', async function (assert) {
this.error = {
httpStatus: 404,
path: '/v1/kubernetes/config',
};
await render(hbs`<Page::Error @error={{this.error}} />`);
assert.dom('h1').hasText('404 Not Found', 'Error title renders');
assert
.dom('p')
.hasText(`Sorry, we were unable to find any content at ${this.error.path}.`, 'Error message renders');
});
test('it should render 403 error', async function (assert) {
this.error = {
httpStatus: 403,
path: '/v1/kubernetes/config',
};
await render(hbs`<Page::Error @error={{this.error}} />`);
assert.dom('h1').hasText('Not authorized', 'Error title renders');
assert
.dom('p')
.hasText(`You are not authorized to access content at ${this.error.path}.`, 'Error message renders');
});
test('it should render general error', async function (assert) {
this.error = {
message: 'An unexpected error occurred',
errors: ['This is one thing that went wrong', 'Unfortunately something else went wrong too'],
};
await render(hbs`<Page::Error @error={{this.error}} />`);
assert.dom('h1').hasText('Error', 'Error title renders');
assert.dom('[data-test-page-error-message]').hasText(this.error.message, 'Error message renders');
this.error.errors.forEach((error, index) => {
assert
.dom(`[data-test-page-error-details="${index}"]`)
.hasText(this.error.errors[index], 'Error detail renders');
});
});
});