Merge remote-tracking branch 'remotes/from/ce/main'

This commit is contained in:
hc-github-team-secure-vault-core 2025-12-16 20:02:37 +00:00
commit 46bbd40e9e
32 changed files with 557 additions and 416 deletions

View File

@ -72,7 +72,7 @@ export default class App extends Application {
},
kubernetes: {
dependencies: {
services: [{ 'app-router': 'router' }, 'store', 'secret-mount-path', 'flash-messages'],
services: [{ 'app-router': 'router' }, 'store', 'secret-mount-path', 'flash-messages', 'api'],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
},

View File

@ -0,0 +1,47 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import type { KubernetesConfigureRequest } from '@hashicorp/vault-client-typescript';
import type { Validations } from 'vault/app-types';
export default class KubernetesConfigForm extends Form<KubernetesConfigureRequest> {
formFields = [
new FormField('kubernetes_host', 'string', {
label: 'Kubernetes host',
subText: 'Kubernetes API URL to connect to.',
}),
new FormField('service_account_jwt', '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.',
}),
new FormField('kubernetes_ca_cert', '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',
}),
];
validations: Validations = {
kubernetes_host: [
{
validator: (data: KubernetesConfigForm['data']) =>
data.disable_local_ca_jwt && !data.kubernetes_host ? false : true,
message: 'Kubernetes host is required',
},
],
};
toJSON() {
// ensure that values from a previous manual configuration are unset
const { disable_local_ca_jwt } = this.data;
const data = disable_local_ca_jwt ? this.data : { disable_local_ca_jwt };
return super.toJSON(data);
}
}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{@backend}} @breadcrumbs={{@breadcrumbs}}>
<TabPageHeader @model={{@secretsEngine}} @breadcrumbs={{@breadcrumbs}}>
{{#if @config}}
<ToolbarLink @route="configure" data-test-secret-backend-configure>
Edit configuration
@ -12,11 +12,11 @@
</TabPageHeader>
{{#if @config}}
{{#if @config.disableLocalCaJwt}}
<InfoTableRow @label="Kubernetes host" @value={{@config.kubernetesHost}} />
{{#if @config.kubernetesCaCert}}
{{#if @config.disable_local_ca_jwt}}
<InfoTableRow @label="Kubernetes host" @value={{@config.kubernetes_host}} />
{{#if @config.kubernetes_ca_cert}}
<InfoTableRow @label="Certificate">
<CertificateCard @data={{@config.kubernetesCaCert}} @isPem={{true}} />
<CertificateCard @data={{@config.kubernetes_ca_cert}} @isPem={{true}} />
</InfoTableRow>
{{/if}}
{{else}}
@ -33,7 +33,7 @@
{{/if}}
<SecretsEngineMountConfig
@secretsEngine={{@backend}}
@secretsEngine={{@secretsEngine}}
class="has-top-margin-xl has-bottom-margin-xl"
data-test-mount-config
/>

View File

@ -27,7 +27,7 @@
@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}}
@groupValue={{@form.data.disable_local_ca_jwt}}
@onChange={{this.onRadioSelect}}
data-test-radio-card="local"
/>
@ -38,17 +38,17 @@
@icon="vault"
@iconClass="has-text-black"
@value={{true}}
@groupValue={{@model.disableLocalCaJwt}}
@groupValue={{@form.data.disable_local_ca_jwt}}
@onChange={{this.onRadioSelect}}
data-test-radio-card="manual"
/>
</div>
<div class="has-top-margin-m" data-test-config>
{{#if @model.disableLocalCaJwt}}
{{#if @form.data.disable_local_ca_jwt}}
<MessageError @errorMessage={{this.error}} />
{{#each @model.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{#each @form.formFields as |field|}}
<FormField @attr={{field}} @model={{@form}} @modelValidations={{this.modelValidations}} />
{{/each}}
{{else if (eq this.inferredState "success")}}
<Icon @name="check-circle-fill" class="has-text-success" />

View File

@ -1,97 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { 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('app-router') router;
@service store;
@tracked inferredState;
@tracked modelValidations;
@tracked alert;
@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;
const { isValid, state, invalidFormMessage } = yield this.args.model.validate();
if (!isValid) {
this.modelValidations = state;
this.alert = invalidFormMessage;
return;
}
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,115 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { 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 type ApiService from 'vault/services/api';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type RouterService from '@ember/routing/router-service';
import type { ValidationMap, Breadcrumb } from 'vault/app-types';
import type Owner from '@ember/owner';
import type KubernetesConfigForm from 'vault/forms/secrets/kubernetes/config';
interface Args {
form: KubernetesConfigForm;
breadcrumbs: Array<Breadcrumb>;
}
/**
* @module Configure
* ConfigurePage component is a child component to configure kubernetes secrets engine.
*
* @param {object} form - config form that contains kubernetes configuration
*/
export default class ConfigurePageComponent extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@tracked inferredState: 'success' | 'error' | null = null;
@tracked modelValidations: ValidationMap | null = null;
@tracked alert = '';
@tracked error = '';
@tracked showConfirm = false;
constructor(owner: Owner, args: Args) {
super(owner, args);
const { form } = this.args;
if (!form.isNew && !form.data.disable_local_ca_jwt) {
this.inferredState = 'success';
}
}
get isDisabled() {
if (!this.args.form.data.disable_local_ca_jwt && this.inferredState !== 'success') {
return true;
}
return this.save.isRunning || this.fetchInferred.isRunning;
}
leave(route: string) {
this.router.transitionTo(`vault.cluster.secrets.backend.kubernetes.${route}`);
}
@action
onRadioSelect(value: boolean) {
this.args.form.data.disable_local_ca_jwt = value;
this.inferredState = null;
}
fetchInferred = task(
waitFor(async () => {
try {
await this.api.secrets.kubernetesCheckConfiguration(this.secretMountPath.currentPath);
this.inferredState = 'success';
} catch {
this.inferredState = 'error';
}
})
);
save = task(
waitFor(async () => {
const { form } = this.args;
if (!form.isNew && !this.showConfirm) {
this.showConfirm = true;
return;
}
this.showConfirm = false;
this.modelValidations = null;
this.alert = '';
const { isValid, state, invalidFormMessage, data } = form.toJSON();
if (isValid) {
try {
await this.api.secrets.kubernetesConfigure(this.secretMountPath.currentPath, data);
this.leave('configuration');
} catch (error) {
const { message } = await this.api.parseError(
error,
'Error saving configuration. Please try again or contact support'
);
this.error = message;
}
} else {
this.modelValidations = state;
this.alert = invalidFormMessage;
}
})
);
@action
cancel() {
const { isNew } = this.args.form;
const transitionRoute = isNew ? 'overview' : 'configuration';
this.leave(transitionRoute);
}
}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{@backend}} @breadcrumbs={{@breadcrumbs}} />
<TabPageHeader @model={{@secretsEngine}} @breadcrumbs={{@breadcrumbs}} />
{{#if @promptConfig}}
<ConfigCta />

View File

@ -13,7 +13,7 @@ import { action } from '@ember/object';
* OverviewPage component is a child component to overview kubernetes secrets engine.
*
* @param {boolean} promptConfig - whether or not to display config cta
* @param {object} backend - backend model that contains kubernetes configuration
* @param {object} secretsEngine - SecretsEngine resource 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

@ -4,7 +4,7 @@
}}
<TabPageHeader
@model={{@backend}}
@model={{@secretsEngine}}
@filterRoles={{not @promptConfig}}
@query={{this.query}}
@breadcrumbs={{@breadcrumbs}}

View File

@ -16,7 +16,7 @@ export default class KubernetesEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['app-router', 'store', 'secret-mount-path', 'flash-messages'],
services: ['app-router', 'store', 'secret-mount-path', 'flash-messages', 'api'],
externalRoutes: ['secrets'],
};
}

View File

@ -0,0 +1,48 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type { ModelFrom } from 'vault/vault/route';
import type ApiService from 'vault/services/api';
import type Transition from '@ember/routing/transition';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
import type { KubernetesConfigureRequest } from '@hashicorp/vault-client-typescript';
export type KubernetesApplicationModel = ModelFrom<KubernetesApplicationRoute>;
export default class KubernetesApplicationRoute extends Route {
@service declare readonly api: ApiService;
async model(params: Record<string, unknown>, transition: Transition) {
const secretsEngine = super.model(params, transition) as SecretsEngineResource;
let config: KubernetesConfigureRequest | undefined;
let promptConfig = false;
let configError: unknown;
// check if engine is configured
// child routes will handle prompting for configuration if needed
try {
const { data } = await this.api.secrets.kubernetesReadConfiguration(secretsEngine.id);
config = data as KubernetesConfigureRequest;
} catch (error) {
const { response, status } = await this.api.parseError(error);
// not considering 404 an error since it triggers the cta
if (status === 404) {
promptConfig = true;
} else {
// ignore if the user does not have permission or other failures so as to not block the other operations
// this error is thrown in the configuration route so we can display the error in the view
configError = response;
}
}
return {
secretsEngine,
config,
configError,
promptConfig,
};
}
}

View File

@ -1,35 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
@withConfig('kubernetes/config')
export default class KubernetesConfigureRoute extends Route {
@service store;
@service secretMountPath;
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,
};
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend.id, route: 'overview', model: resolvedModel.backend },
{ label: 'Configuration' },
];
}
}

View File

@ -0,0 +1,43 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ModelFrom } from 'vault/route';
import type { KubernetesApplicationModel } from './application';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/app-types';
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: KubernetesApplicationModel;
}
export type KubernetesConfigureModel = ModelFrom<KubernetesConfigureRoute>;
export default class KubernetesConfigureRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;
model() {
const { config, configError, secretsEngine } = this.modelFor('application') as KubernetesApplicationModel;
// in case of any error other than 404 we want to display that to the user
if (configError) {
throw configError;
}
return { secretsEngine, config };
}
setupController(controller: RouteController, resolvedModel: KubernetesConfigureModel) {
super.setupController(controller, resolvedModel);
const { currentPath } = this.secretMountPath;
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: currentPath, route: 'overview', model: currentPath },
{ label: 'Configuration' },
];
}
}

View File

@ -1,29 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
@withConfig('kubernetes/config')
export default class KubernetesConfigureRoute extends Route {
@service store;
@service secretMountPath;
async model() {
const backend = this.secretMountPath.currentPath;
return this.configModel || this.store.createRecord('kubernetes/config', { backend });
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'overview', model: resolvedModel.backend },
{ label: 'Configure' },
];
}
}

View File

@ -0,0 +1,41 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import KubernetesConfigForm from 'vault/forms/secrets/kubernetes/config';
import { ModelFrom } from 'vault/route';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type { KubernetesApplicationModel } from './application';
import type { Breadcrumb } from 'vault/app-types';
import type Controller from '@ember/controller';
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: KubernetesConfigureModel;
}
export type KubernetesConfigureModel = ModelFrom<KubernetesConfigureRoute>;
export default class KubernetesConfigureRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;
async model() {
const { config } = this.modelFor('application') as KubernetesApplicationModel;
const data = config || { disable_local_ca_jwt: false };
return new KubernetesConfigForm(data, { isNew: !config });
}
setupController(controller: RouteController, resolvedModel: KubernetesConfigureModel) {
super.setupController(controller, resolvedModel);
const { currentPath } = this.secretMountPath;
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: currentPath, route: 'overview', model: currentPath },
{ label: 'Configure' },
];
}
}

View File

@ -1,20 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { 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

@ -0,0 +1,35 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ModelFrom } from 'vault/route';
import type Controller from '@ember/controller';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
import type { Breadcrumb } from 'vault/app-types';
import { KubernetesApplicationModel } from './application';
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
secretsEngine: SecretsEngineResource;
}
type KubernetesErrorModel = ModelFrom<KubernetesErrorRoute>;
export default class KubernetesErrorRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;
setupController(controller: RouteController, resolvedModel: KubernetesErrorModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
];
const { secretsEngine } = this.modelFor('application') as KubernetesApplicationModel;
controller.secretsEngine = secretsEngine;
}
}

View File

@ -1,33 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
import { hash } from 'rsvp';
@withConfig('kubernetes/config')
export default class KubernetesOverviewRoute extends Route {
@service store;
@service secretMountPath;
async model() {
const backend = this.secretMountPath.currentPath;
return hash({
promptConfig: this.promptConfig,
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,46 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { ModelFrom } from 'vault/route';
import type { KubernetesApplicationModel } from './application';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Store from '@ember-data/store';
import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/app-types';
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: KubernetesOverviewModel;
}
export type KubernetesOverviewModel = ModelFrom<KubernetesOverviewRoute>;
export default class KubernetesOverviewRoute extends Route {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;
async model() {
const backend = this.secretMountPath.currentPath;
const { promptConfig, secretsEngine } = this.modelFor('application') as KubernetesApplicationModel;
return hash({
promptConfig,
secretsEngine,
roles: this.store.query('kubernetes/role', { backend }).catch(() => []),
});
}
setupController(controller: RouteController, resolvedModel: KubernetesOverviewModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath },
];
}
}

View File

@ -1,48 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { withConfig } from 'core/decorators/fetch-secrets-engine-config';
import { hash } from 'rsvp';
@withConfig('kubernetes/config')
export default class KubernetesRolesRoute extends Route {
@service store;
@service secretMountPath;
model(params, transition) {
// filter roles based on pageFilter value
const { pageFilter } = transition.to.queryParams;
const roles = this.store
.query('kubernetes/role', { backend: this.secretMountPath.currentPath })
.then((models) =>
pageFilter
? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase()))
: models
)
.catch((error) => {
if (error.httpStatus === 404) {
return [];
}
throw error;
});
return hash({
backend: this.modelFor('application'),
promptConfig: this.promptConfig,
roles,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend.id, route: 'overview', model: resolvedModel.backend },
{ label: 'Roles' },
];
}
}

View File

@ -0,0 +1,61 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { ModelFrom } from 'vault/route';
import type { KubernetesApplicationModel } from '../application';
import type Store from '@ember-data/store';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type Controller from '@ember/controller';
import type { Breadcrumb } from 'vault/app-types';
import type Transition from '@ember/routing/transition';
import type AdapterError from 'vault/@ember-data/adapter/error';
interface RouteController extends Controller {
breadcrumbs: Array<Breadcrumb>;
model: KubernetesRolesModel;
}
export type KubernetesRolesModel = ModelFrom<KubernetesRolesRoute>;
export default class KubernetesRolesRoute extends Route {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;
async model(_params: unknown, transition: Transition) {
const { promptConfig, secretsEngine } = this.modelFor('application') as KubernetesApplicationModel;
const model = { promptConfig, secretsEngine, roles: [] };
try {
// filter roles based on pageFilter value
const { pageFilter } = (transition.to?.queryParams || {}) as { pageFilter?: string };
const models = await this.store.query('kubernetes/role', { backend: this.secretMountPath.currentPath });
const roles = pageFilter
? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase()))
: models;
return {
...model,
roles: roles as unknown[],
};
} catch (error) {
if ((error as AdapterError).httpStatus !== 404) {
throw error;
}
}
return model;
}
setupController(controller: RouteController, resolvedModel: KubernetesRolesModel) {
super.setupController(controller, resolvedModel);
const { currentPath } = this.secretMountPath;
controller.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: currentPath, route: 'overview', model: currentPath },
{ label: 'Roles' },
];
}
}

View File

@ -3,4 +3,8 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Configuration @config={{this.model.config}} @backend={{this.model.backend}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Configuration
@config={{this.model.config}}
@secretsEngine={{this.model.secretsEngine}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -3,4 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Configure @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Configure @form={{this.model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -3,6 +3,6 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{this.backend}} @breadcrumbs={{this.breadcrumbs}} />
<TabPageHeader @model={{this.secretsEngine}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Error @error={{this.model}} />

View File

@ -5,7 +5,7 @@
<Page::Overview
@promptConfig={{this.model.promptConfig}}
@backend={{this.model.backend}}
@secretsEngine={{this.model.secretsEngine}}
@roles={{this.model.roles}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -6,7 +6,7 @@
<Page::Roles
@roles={{this.model.roles}}
@promptConfig={{this.model.promptConfig}}
@backend={{this.model.backend}}
@secretsEngine={{this.model.secretsEngine}}
@filterValue={{this.pageFilter}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -11,7 +11,16 @@
"@ember/test-waiters": "*",
"ember-inflector": "*",
"@hashicorp/design-system-components": "*",
"ember-auto-import": "*"
"ember-auto-import": "*",
"ember-cli-typescript": "*",
"@types/ember": "latest",
"@types/ember__array": "latest",
"@types/ember__component": "latest",
"@types/ember__controller": "latest",
"@types/ember__engine": "latest",
"@types/ember__routing": "latest",
"@types/rsvp": "latest",
"ember-template-lint": "*"
},
"ember-addon": {
"paths": [

View File

@ -11,6 +11,7 @@ import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import SecretsEngineResource from 'vault/resources/secrets/engine';
module('Integration | Component | kubernetes | Page::Configuration', function (hooks) {
setupRenderingTest(hooks);
@ -18,39 +19,29 @@ module('Integration | Component | kubernetes | Page::Configuration', function (h
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.secretsEngine = new SecretsEngineResource({
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(
this.config = 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 },
{ label: this.secretsEngine.id },
];
this.renderComponent = () => {
return render(
hbs`<Page::Configuration @backend={{this.backend}} @config={{this.config}} @breadcrumbs={{this.breadcrumbs}} />`,
hbs`<Page::Configuration @config={{this.config}} @secretsEngine={{this.secretsEngine}} @breadcrumbs={{this.breadcrumbs}} />`,
{
owner: this.engine,
}
@ -86,7 +77,7 @@ module('Integration | Component | kubernetes | Page::Configuration', function (h
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');
.hasText(this.config.kubernetes_host, 'Kubernetes host value renders');
assert.dom('[data-test-row-label="Certificate"]').exists('Certificate label renders');
assert.dom('[data-test-certificate-card]').exists('Certificate card component renders');
@ -95,6 +86,6 @@ module('Integration | Component | kubernetes | Page::Configuration', function (h
assert.dom('[data-test-certificate-label]').hasText('PEM Format', 'Certificate label renders');
assert
.dom('[data-test-certificate-value]')
.hasText(this.config.kubernetesCaCert, 'Certificate value renders');
.hasText(this.config.kubernetes_ca_cert, 'Certificate value renders');
});
});

View File

@ -7,12 +7,13 @@ 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 { render, click, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { Response } from 'miragejs';
import sinon from 'sinon';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import KubernetesConfigForm from 'vault/forms/secrets/kubernetes/config';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | kubernetes | Page::Configure', function (hooks) {
setupRenderingTest(hooks);
@ -20,20 +21,18 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.newModel = this.store.createRecord('kubernetes/config', { backend: 'kubernetes-new' });
this.backend = 'kubernetes-test';
this.owner.lookup('service:secret-mount-path').update(this.backend);
this.createForm = new KubernetesConfigForm({ disable_local_ca_jwt: false }, { isNew: true });
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');
this.editForm = new KubernetesConfigForm(this.existingConfig);
this.form = this.createForm;
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: 'kubernetes', route: 'overview' },
@ -41,10 +40,14 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
];
this.expectedInferred = {
disable_local_ca_jwt: false,
kubernetes_ca_cert: null,
kubernetes_host: null,
service_account_jwt: null,
};
const { secrets } = this.owner.lookup('service:api');
this.checkStub = sinon.stub(secrets, 'kubernetesCheckConfiguration').resolves();
this.configStub = sinon.stub(secrets, 'kubernetesConfigure').resolves();
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
setRunOptions({
rules: {
// TODO: fix RadioCard component (replace with HDS)
@ -52,12 +55,15 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
'nested-interactive': { enabled: false },
},
});
this.renderComponent = () =>
render(hbs`<Page::Configure @form={{this.form}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
});
test('it should display proper options when toggling radio cards', async function (assert) {
await render(hbs`<Page::Configure @model={{this.newModel}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
assert
.dom('[data-test-radio-card="local"] input')
.isChecked('Local cluster radio card is checked by default');
@ -82,20 +88,11 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
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}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
this.checkStub.rejects(getErrorResponse());
await this.renderComponent();
await click('[data-test-config] button');
assert.true(this.checkStub.calledWith(this.backend), 'Check config request is made');
assert
.dom('[data-test-icon="x-square-fill"]')
.hasClass('has-text-danger', 'Icon is displayed for error state with correct styling');
@ -104,10 +101,11 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
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;
this.checkStub.resolves();
await click('[data-test-radio-card="manual"]');
await click('[data-test-radio-card="local"]');
await click('[data-test-config] button');
assert.true(this.checkStub.calledWith(this.backend), 'Check config request is made');
assert
.dom('[data-test-icon="check-circle-fill"]')
.hasClass('has-text-success', 'Icon is displayed for success state with correct styling');
@ -120,74 +118,53 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
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}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
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 fillIn('[data-test-input="kubernetes_host"]', this.existingConfig.kubernetes_host);
await fillIn('[data-test-input="service_account_jwt"]', this.existingConfig.service_account_jwt);
await fillIn('[data-test-input="kubernetes_ca_cert"]', this.existingConfig.kubernetes_ca_cert);
await click('[data-test-config-save]');
assert.true(
this.configStub.calledWith(this.backend, this.existingConfig),
'Create config request is made'
);
assert.ok(
stub.calledWith('vault.cluster.secrets.backend.kubernetes.configuration'),
this.transitionStub.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);
test('it should render existing manual config data in form', async function (assert) {
assert.expect(5);
const stub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
await render(hbs`<Page::Configure @model={{this.editModel}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
this.form = this.editForm;
await this.renderComponent();
assert.dom('[data-test-radio-card="manual"] input').isChecked('Manual config radio card is checked');
assert
.dom('[data-test-input="kubernetesHost"]')
.dom('[data-test-input="kubernetes_host"]')
.hasValue(this.existingConfig.kubernetes_host, 'Host field is populated');
assert
.dom('[data-test-input="serviceAccountJwt"]')
.dom('[data-test-input="service_account_jwt"]')
.hasValue(this.existingConfig.service_account_jwt, 'JWT field is populated');
assert
.dom('[data-test-input="kubernetesCaCert"]')
.dom('[data-test-input="kubernetes_ca_cert"]')
.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'),
this.transitionStub.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');
this.form = this.editForm;
this.form.data.disable_local_ca_jwt = false;
await render(hbs`<Page::Configure @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
assert.dom('[data-test-radio-card="local"] input').isChecked('Local cluster radio card is checked');
assert
@ -201,17 +178,9 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
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, {});
});
this.form = this.editForm;
await this.renderComponent();
await render(
hbs`
<Page::Configure @model={{this.editModel}} @breadcrumbs={{this.breadcrumbs}} />
`,
{ owner: this.engine }
);
await click('[data-test-config-save]');
assert
.dom('[data-test-edit-config-body]')
@ -220,18 +189,20 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
'Confirm modal renders'
);
await click('[data-test-config-confirm]');
assert.true(
this.configStub.calledWith(this.backend, this.existingConfig),
'Config is saved after confirming'
);
});
test('it should validate form and show errors', async function (assert) {
await render(hbs`<Page::Configure @model={{this.newModel}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
await click('[data-test-radio-card="manual"]');
await click('[data-test-config-save]');
assert
.dom(GENERAL.validationErrorByAttr('kubernetesHost'))
.dom(GENERAL.validationErrorByAttr('kubernetes_host'))
.hasText('Kubernetes host is required', 'Error renders for required field');
assert.dom('[data-test-alert]').hasText('There is an error with this form.', 'Alert renders');
});
@ -239,24 +210,17 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
test('it should save inferred config', async function (assert) {
assert.expect(2);
this.server.get('/:path/check', () => new Response(204, {}));
this.server.post('/:path/config', (schema, req) => {
const json = JSON.parse(req.requestBody);
assert.deepEqual(json, this.expectedInferred, '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}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
await click('[data-test-config] button');
await click('[data-test-config-save]');
assert.true(
this.configStub.calledWith(this.backend, this.expectedInferred),
'Request made to save inferred config values'
);
assert.ok(
stub.calledWith('vault.cluster.secrets.backend.kubernetes.configuration'),
this.transitionStub.calledWith('vault.cluster.secrets.backend.kubernetes.configuration'),
'Transitions to configuration route on save success'
);
});
@ -264,24 +228,20 @@ module('Integration | Component | kubernetes | Page::Configure', function (hooks
test('it should unset manual config values when saving local cluster option', async function (assert) {
assert.expect(1);
this.server.get('/:path/check', () => new Response(204, {}));
this.server.post('/:path/config', (schema, req) => {
const json = JSON.parse(req.requestBody);
assert.deepEqual(json, this.expectedInferred, 'Manual config values are unset in server payload');
return new Response(204, {});
});
await render(hbs`<Page::Configure @model={{this.newModel}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await this.renderComponent();
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 fillIn('[data-test-input="kubernetes_host"]', this.existingConfig.kubernetes_host);
await fillIn('[data-test-input="service_account_jwt"]', this.existingConfig.service_account_jwt);
await fillIn('[data-test-input="kubernetes_ca_cert"]', this.existingConfig.kubernetes_ca_cert);
await click('[data-test-radio-card="local"]');
await click('[data-test-config] button');
await click('[data-test-config-save]');
assert.true(
this.configStub.calledWith(this.backend, this.expectedInferred),
'Manual config values are unset in server payload'
);
});
});

View File

@ -49,7 +49,7 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks)
this.promptConfig = false;
this.renderComponent = () => {
return render(
hbs`<Page::Overview @promptConfig={{this.promptConfig}} @backend={{this.backend}} @roles={{this.roles}} @breadcrumbs={{this.breadcrumbs}} />`,
hbs`<Page::Overview @promptConfig={{this.promptConfig}} @secretsEngine={{this.backend}} @roles={{this.roles}} @breadcrumbs={{this.breadcrumbs}} />`,
{ owner: this.engine }
);
};

View File

@ -44,7 +44,7 @@ module('Integration | Component | kubernetes | Page::Roles', function (hooks) {
this.renderComponent = () => {
return render(
hbs`<Page::Roles @promptConfig={{this.promptConfig}} @backend={{this.backend}} @roles={{this.roles}} @filterValue={{this.filterValue}} @breadcrumbs={{this.breadcrumbs}} />`,
hbs`<Page::Roles @promptConfig={{this.promptConfig}} @secretsEngine={{this.backend}} @roles={{this.roles}} @filterValue={{this.filterValue}} @breadcrumbs={{this.breadcrumbs}} />`,
{ owner: this.engine }
);
};

View File

@ -48,6 +48,8 @@
"kmip/test-support/*": ["lib/kmip/addon-test-support/*"],
"ldap": ["lib/ldap/addon"],
"ldap/*": ["lib/ldap/addon/*"],
"kubernetes": ["lib/kubernetes/addon"],
"kubernetes/*": ["lib/kubernetes/addon/*"],
"kv": ["lib/kv/addon"],
"kv/*": ["lib/kv/addon/*"],
"kv/test-support": ["lib/kv/addon-test-support"],
@ -85,6 +87,7 @@
"lib/css/**/*",
"lib/kmip/**/*",
"lib/ldap/**/*",
"lib/kubernetes/**/*",
"lib/open-api-explorer/**/*",
"lib/pki/**/*",
"lib/replication/**/*",