UI: Glimmerize replication enable form (#26417)

* Glimmerize replication controllers

* Add enable-replication-form component with tests

* use EnableReplicationForm in index and mode routes

* clean up enable action from replication-actions mixin

* fix test failure for structuredClone

* stabilize tests, remove enable action from replication-actions and replication-summary

* Update ui/lib/replication/addon/controllers/replication-mode.js

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* address PR comments

* stabilize oidc test?

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Chelsea Shaw
2024-04-15 15:30:33 -05:00
committed by GitHub
parent 2560beea8e
commit 349e449d49
19 changed files with 829 additions and 422 deletions

View File

@@ -169,7 +169,7 @@ export default ApplicationAdapter.extend({
urlFor(endpoint) {
if (!ENDPOINTS.includes(endpoint)) {
throw new Error(
`Calls to a ${endpoint} endpoint are not currently allowed in the vault cluster adapater`
`Calls to a ${endpoint} endpoint are not currently allowed in the vault cluster adapter`
);
}
return `${this.buildURL()}/${endpoint}`;

View File

@@ -13,7 +13,6 @@ export default Mixin.create({
store: service(),
router: service(),
loading: or('save.isRunning', 'submitSuccess.isRunning'),
onEnable() {},
onDisable() {},
onPromote() {},
submitHandler: task(function* (action, clusterMode, data, event) {
@@ -53,10 +52,9 @@ export default Mixin.create({
return yield this.submitSuccess.perform(resp, action, clusterMode);
}).drop(),
submitSuccess: task(function* (resp, action, mode) {
submitSuccess: task(function* (resp, action) {
// enable action is handled separately in EnableReplicationForm component
const cluster = this.cluster;
const replicationMode = this.selectedReplicationMode || this.replicationMode;
const store = this.store;
if (!cluster) {
return;
}
@@ -75,20 +73,6 @@ export default Mixin.create({
if (this.reset) {
this.reset();
}
if (action === 'enable') {
// do something to show model is pending
cluster.set(
replicationMode,
store.createRecord('replication-attributes', {
mode: 'bootstrapping',
})
);
if (mode === 'secondary' && replicationMode === 'performance') {
// if we're enabing a secondary, there could be mount filtering,
// so we should unload all of the backends
store.unloadAll('secret-engine');
}
}
try {
yield cluster.reload();
} catch (e) {
@@ -101,11 +85,6 @@ export default Mixin.create({
if (action === 'promote') {
yield this.onPromote();
}
if (action === 'enable') {
/// onEnable is a method available only to route vault.cluster.replication.index
// if action 'enable' is called from vault.cluster.replication.mode.index this method is not called
yield this.onEnable(replicationMode, mode);
}
}).drop(),
submitError(e) {

View File

@@ -0,0 +1,178 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" (fn this.onSubmit this.data)}} data-test-replication-enable-form>
<MessageError @errorMessage={{this.error}} />
<div class="box is-sideless is-fullwidth is-marginless">
<label for="replication-mode" class="is-label">
Cluster mode
</label>
<div class="field is-expanded">
<div class="control select is-fullwidth">
<select {{on "change" this.inputChange}} id="replication-mode" name="mode" data-test-replication-cluster-mode-select>
{{#each (array "primary" "secondary") as |modeOption|}}
<option selected={{eq this.data.mode modeOption}} value={{modeOption}}>
{{modeOption}}
</option>
{{/each}}
</select>
</div>
{{#if (eq this.data.mode "secondary")}}
<AlertInline
class="has-top-margin-xs"
@type="warning"
@message="This will immediately clear all data in this cluster!"
/>
{{/if}}
</div>
{{#if (eq this.data.mode "primary")}}
{{#if @canEnablePrimary}}
<div class="field">
<label for="primary_cluster_addr" class="is-label">
Primary cluster address
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<input
class="input"
id="primary_cluster_addr"
name="primary_cluster_addr"
value={{this.data.primary_cluster_addr}}
{{on "change" this.inputChange}}
data-test-input="primary_cluster_addr"
/>
</div>
<p class="help has-text-grey">
Overrides the cluster address that the primary gives to secondary nodes.
</p>
</div>
{{else}}
<p data-test-not-allowed>
The token you are using is not authorized to enable primary replication.
</p>
{{/if}}
{{else}}
{{#if @canEnableSecondary}}
{{#if (and (eq @replicationMode "dr") this.performanceReplicationEnabled (has-feature "Performance Replication"))}}
<div>
<ToggleButton
@isOpen={{this.showExplanation}}
@openLabel="Disable Performance Replication in order to enable this cluster as a DR secondary."
@closedLabel="Disable Performance Replication in order to enable this cluster as a DR secondary."
@onClick={{fn (mut this.showExplanation)}}
class="has-text-danger"
data-test-disable-to-continue
/>
{{#if this.showExplanation}}
<p data-test-disable-explanation>
When running as a DR Secondary Vault is read only. For this reason, we don't allow other Replication modes to
operate at the same time. This cluster is also currently operating as a Performance
{{capitalize @performanceMode}}.
</p>
{{/if}}
</div>
{{else}}
<div class="field">
<label for="secondary-token" class="is-label">
Secondary activation token
</label>
<div class="control">
<Textarea
@value={{this.data.token}}
id="secondary-token"
name="secondary-token"
class="textarea"
data-test-textarea="secondary-token"
/>
</div>
</div>
<div class="field">
<label for="primary_api_addr" class="is-label">
Primary API address
{{#if (not (and this.data.token (not this.data.tokenIncludesAPIAddr)))}}
<em class="is-optional">(optional)</em>
{{/if}}
</label>
<div class="control">
<input
id="primary_api_addr"
name="primary_api_addr"
class="input"
value={{this.data.primary_api_addr}}
{{on "change" this.inputChange}}
data-test-input="primary_api_addr"
/>
</div>
<p class="help {{if (and this.data.token (not this.data.tokenIncludesAPIAddr)) 'is-danger' 'has-text-grey'}}">
{{#if (and this.data.token (not this.data.tokenIncludesAPIAddr))}}
The supplied token does not contain an embedded address for the primary cluster. Please enter the primary
cluster's API address (normal Vault address).
{{else}}
Set this to the API address (normal Vault address) to override the value embedded in the token.
{{/if}}
</p>
</div>
<div class="field">
<label for="ca_file" class="is-label">
CA file
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<input
id="ca_file"
name="ca_file"
class="input"
value={{this.data.ca_file}}
{{on "change" this.inputChange}}
data-test-input="ca_file"
/>
</div>
<p class="help has-text-grey">
Specifies the path to a CA root file (PEM format) that the secondary can use when unwrapping the token from the
primary.
</p>
</div>
<div class="field">
<label for="ca_path" class="is-label">
CA path
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<input
id="ca_path"
name="ca_path"
class="input"
value={{this.data.ca_path}}
{{on "change" this.inputChange}}
data-test-input="ca_path"
/>
</div>
<p class="help has-text-grey">
Specifies the path to a CA root directory containing PEM-format files that the secondary can use when
unwrapping the token from the primary.
</p>
</div>
<p>
Note: If both
<code>CA file</code>
and
<code>CA path</code>
are not given, they default to system CA roots.
</p>
{{/if}}
{{else}}
<p data-test-not-allowed>The token you are using is not authorized to enable secondary replication.</p>
{{/if}}
{{/if}}
</div>
{{#if
(or (and (eq this.data.mode "primary") @canEnablePrimary) (and (eq this.data.mode "secondary") @canEnableSecondary))
}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<Hds::Button @text="Enable Replication" type="submit" disabled={{this.disallowEnable}} data-test-replication-enable />
</div>
{{/if}}
</form>

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import decodeConfigFromJwt from 'replication/utils/decode-config-from-jwt';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import { isPresent } from '@ember/utils';
import { waitFor } from '@ember/test-waiters';
/**
* @module EnableReplicationFormComponent
* EnableReplicationForm component is used in the replication engine to enable replication. It must be passed the replicationMode,
* but otherwise it handles the rest of the form inputs. On success it will clear the form and call the onSuccess callback.
*
* @example
* ```js
* <EnableReplicationForm @replicationMode="dr" @canEnablePrimary={{true}} @canEnableSecondary={{false}} @performanceReplicationDisabled={{false}} @onSuccess={{this.reloadCluster}} />
* @param {string} replicationMode - should be one of "dr" or "performance"
* @param {boolean} canEnablePrimary - if the capabilities allow the user to enable a primary cluster
* @param {boolean} canEnableSecondary - if the capabilities allow the user to enable a secondary cluster
* @param {boolean} performanceMode - should be "primary", "secondary", or "disabled". If enabled, form will show a warning when attempting to enable DR secondary
* @param {Promise} onSuccess - (optional) callback called after successful replication enablement. Must be a promise.
* @param {boolean} doTransition - (optional) if provided, passed to onSuccess callback to determine if a transition should be done
* />
* ```
*/
export default class EnableReplicationFormComponent extends Component {
@service version;
@service store;
@tracked error = '';
@tracked showExplanation = false;
data = new EnablePayload();
get performanceReplicationEnabled() {
return this.args.performanceMode !== 'disabled';
}
get tokenIncludesAPIAddr() {
const config = decodeConfigFromJwt(this.token);
return config && config.addr ? true : false;
}
get disallowEnable() {
if (this.args.replicationMode === 'performance' && this.version.hasPerfReplication === false) {
return true;
}
const { mode, tokenIncludesAPIAddr, primary_api_addr } = this.data;
if (mode !== 'secondary' || tokenIncludesAPIAddr || (!tokenIncludesAPIAddr && primary_api_addr)) {
return false;
}
return true;
}
async onSuccess(resp, clusterMode) {
// clear form
this.data.reset();
// call callback
if (this.args.onSuccess) {
await this.args.onSuccess(resp, this.args.replicationMode, clusterMode, this.args.doTransition);
}
}
@action inputChange(evt) {
const name = evt.target.name;
const val = evt.target.value;
this.data[name] = val;
}
@task
@waitFor
*enableReplication(replicationMode, clusterMode, data) {
const payload = data.allKeys.reduce((newData, key) => {
var val = data[key];
if (isPresent(val)) {
newData[key] = val;
}
return newData;
}, {});
delete payload.mode;
try {
const resp = yield this.store
.adapterFor('cluster')
.replicationAction('enable', replicationMode, clusterMode, payload);
yield this.onSuccess(resp, clusterMode);
} catch (e) {
this.error = errorMessage(e, 'Enable replication failed. Check Vault logs for details.');
}
}
@action onSubmit(payload, evt) {
evt.preventDefault();
this.error = '';
this.enableReplication.perform(this.args.replicationMode, this.data.mode, payload);
}
}
class EnablePayload {
@tracked mode = 'primary';
@tracked token = '';
@tracked primary_api_addr = '';
@tracked primary_cluster_addr = '';
@tracked ca_file = '';
@tracked ca_path = '';
get tokenIncludesAPIAddr() {
const config = decodeConfigFromJwt(this.token);
return config && config.addr ? true : false;
}
get allKeys() {
return ['mode', 'token', 'primary_api_addr', 'primary_cluster_addr', 'ca_file', 'ca_path'];
}
reset() {
// reset all but mode
this.token = '';
this.primary_api_addr = '';
this.primary_cluster_addr = '';
this.ca_file = '';
this.ca_path = '';
}
}

View File

@@ -6,23 +6,9 @@
import { service } from '@ember/service';
import { computed } from '@ember/object';
import Component from '@ember/component';
import decodeConfigFromJWT from 'replication/utils/decode-config-from-jwt';
import ReplicationActions from 'core/mixins/replication-actions';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
const DEFAULTS = {
token: null,
id: null,
loading: false,
errors: null,
primary_api_addr: null,
primary_cluster_addr: null,
ca_file: null,
ca_path: null,
};
export default Component.extend(ReplicationActions, DEFAULTS, {
export default Component.extend(ReplicationActions, {
replicationMode: 'dr',
mode: 'primary',
version: service(),
@@ -41,54 +27,4 @@ export default Component.extend(ReplicationActions, DEFAULTS, {
attrsForCurrentMode: computed('cluster', 'rm.mode', function () {
return this.cluster[this.rm.mode];
}),
tokenIncludesAPIAddr: computed('token', function () {
const config = decodeConfigFromJWT(this.token);
return config && config.addr ? true : false;
}),
disallowEnable: computed(
'replicationMode',
'version.hasPerfReplication',
'mode',
'tokenIncludesAPIAddr',
'primary_api_addr',
function () {
const inculdesAPIAddr = this.tokenIncludesAPIAddr;
if (this.replicationMode === 'performance' && this.version.hasPerfReplication === false) {
return true;
}
if (this.mode !== 'secondary' || inculdesAPIAddr || (!inculdesAPIAddr && this.primary_api_addr)) {
return false;
}
return true;
}
),
reset() {
this.setProperties(DEFAULTS);
},
submit: task(
waitFor(function* () {
try {
yield this.submitHandler.perform(...arguments);
} catch (e) {
// do not handle error
}
})
),
actions: {
onSubmit(/*action, mode, data, event*/) {
this.submit.perform(...arguments);
},
clear() {
this.reset();
this.setProperties({
token: null,
id: null,
});
},
},
});

View File

@@ -56,7 +56,7 @@ export default Controller.extend(structuredClone(DEFAULTS), {
},
reset() {
this.setProperties(structuredClone(DEFAULTS, true));
this.setProperties(structuredClone(DEFAULTS));
},
submitSuccess(resp, action) {

View File

@@ -3,6 +3,9 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from './replication-mode';
import ReplicationModeBaseController from './replication-mode';
import { tracked } from '@glimmer/tracking';
export default Controller.extend();
export default class ReplicationIndexController extends ReplicationModeBaseController {
@tracked modeSelection = 'dr';
}

View File

@@ -3,6 +3,6 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from './replication-mode';
import ReplicationModeBaseController from './replication-mode';
export default Controller.extend();
export default class ReplicationModeController extends ReplicationModeBaseController {}

View File

@@ -3,6 +3,6 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '../replication-mode';
import ReplicationModeBaseController from '../replication-mode';
export default Controller.extend();
export default class ReplicationModeIndexController extends ReplicationModeBaseController {}

View File

@@ -3,6 +3,6 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '../replication-mode';
import ReplicationModeBaseController from '../replication-mode';
export default Controller.extend();
export default class ReplicationModeManageController extends ReplicationModeBaseController {}

View File

@@ -3,6 +3,6 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '../replication-mode';
import ReplicationModeBaseController from '../replication-mode';
export default Controller.extend();
export default class ReplicationModeSecondariesController extends ReplicationModeBaseController {}

View File

@@ -3,36 +3,74 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { alias } from '@ember/object/computed';
import { service } from '@ember/service';
import Controller from '@ember/controller';
import { task, timeout } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { action } from '@ember/object';
export default Controller.extend({
router: service(),
rm: service('replication-mode'),
replicationMode: alias('rm.mode'),
waitForNewClusterToInit: task(
waitFor(function* (replicationMode) {
// waiting for the newly enabled cluster to init
// this ensures we don't hit a capabilities-self error, called in the model of the mode/index route
yield timeout(1000);
this.router.transitionTo('vault.cluster.replication.mode', replicationMode);
})
),
actions: {
onEnable(replicationMode, mode) {
if (replicationMode == 'dr' && mode === 'secondary') {
export default class ReplicationModeBaseController extends Controller {
@service('replication-mode') rm;
@service router;
@service store;
get replicationMode() {
return this.rm.mode;
}
get replicationForMode() {
if (!this.replicationMode || !this.model) return null;
return this.model[this.replicationMode];
}
@task
@waitFor
*waitForNewClusterToInit(replicationMode) {
// waiting for the newly enabled cluster to init
// this ensures we don't hit a capabilities-self error, called in the model of the mode/index route
yield timeout(1000);
this.router.transitionTo('vault.cluster.replication.mode', replicationMode);
}
@action
onDisable() {
this.router.transitionTo('vault.cluster.replication.index');
}
@action
async onEnableSuccess(resp, replicationMode, clusterMode, doTransition = false) {
// this is extrapolated from the replication-actions mixin "submitSuccess"
const cluster = this.model;
if (!cluster) {
return;
}
// do something to show model is pending
cluster.set(
replicationMode,
this.store.createRecord('replication-attributes', {
mode: 'bootstrapping',
})
);
if (clusterMode === 'secondary' && replicationMode === 'performance') {
// if we're enabing a secondary, there could be mount filtering,
// so we should unload all of the backends
this.store.unloadAll('secret-engine');
}
try {
await cluster.reload();
} catch (e) {
// no error handling here
}
cluster.rollbackAttributes();
// we should only do the transitions if called from vault.cluster.replication.index
if (doTransition) {
if (replicationMode == 'dr' && clusterMode === 'secondary') {
this.router.transitionTo('vault.cluster');
} else if (replicationMode === 'dr') {
this.router.transitionTo('vault.cluster.replication.mode', replicationMode);
} else {
this.waitForNewClusterToInit.perform(replicationMode);
}
},
onDisable() {
this.router.transitionTo('vault.cluster.replication.index');
},
},
});
}
}
}

View File

@@ -5,289 +5,6 @@
{{#if (not (has-feature "DR Replication"))}}
<UpgradePage @title="Replication" />
{{else if (or this.cluster.allReplicationDisabled this.cluster.replicationAttrs.replicationDisabled)}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3" data-test-replication-title>
{{#if this.initialReplicationMode}}
{{#if (eq this.initialReplicationMode "dr")}}
Enable Disaster Recovery Replication
{{else if (eq this.initialReplicationMode "performance")}}
Enable Performance Replication
{{/if}}
{{else}}
Enable Replication
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
<form
onsubmit={{action
"onSubmit"
"enable"
(or this.mode "primary")
(hash
token=this.token
primary_cluster_addr=this.primary_cluster_addr
primary_api_addr=this.primary_api_addr
ca_file=this.ca_file
ca_path=this.ca_path
replicationMode=this.replicationMode
)
}}
data-test-replication-enable-form
>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @errors={{this.errors}} />
{{#if this.initialReplicationMode}}
{{#if (eq this.initialReplicationMode "dr")}}
<h3 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-direct" />
Disaster Recovery (DR) Replication
</h3>
<p class="help has-text-grey-dark">
{{replication-mode-description "dr"}}
</p>
{{else if (eq this.initialReplicationMode "performance")}}
<h3 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-perf" />
Performance Replication
</h3>
{{#if (has-feature "Performance Replication")}}
<p class="help has-text-grey-dark">
{{replication-mode-description "performance"}}
</p>
{{else}}
<p class="help has-text-grey-dark">
Performance Replication is a feature of Vault Enterprise Premium
</p>
{{/if}}
{{/if}}
{{else}}
<p class="has-text-grey-dark box is-shadowless is-fullwidth has-slim-padding">
<label for="replication-mode" class="is-label is-block">
Type of replication
</label>
In both Performance and Disaster Recovery (DR) Replication, secondaries share the underlying configuration,
policies, and supporting secrets as their primary cluster.
</p>
<div class="columns">
<div class="column is-flex">
<label for="dr" class="box-label is-column {{if (eq this.replicationMode 'dr') 'is-selected'}}">
<div>
<h3 class="box-label-header title is-6">
<Icon @size="24" @name="replication-direct" />
Disaster Recovery (DR)
</h3>
<p class="help has-text-grey-dark">
{{replication-mode-description "dr"}}
</p>
</div>
<div>
<RadioButton
id="dr"
name="replication-mode"
@value="dr"
@groupValue={{this.replicationMode}}
@onChange={{fn (mut this.replicationMode)}}
/>
<label for="dr" data-test-replication-type-select="dr"></label>
</div>
</label>
</div>
<div class="column is-flex">
<label
for="performance"
class="box-label is-column {{if (eq this.replicationMode 'performance') 'is-selected'}}"
>
<div>
<h3 class="box-label-header title is-6">
<Icon @size="24" @name="replication-perf" />
Performance
</h3>
{{#if (not (has-feature "Performance Replication"))}}
<p class="help has-text-grey-dark">
Performance Replication is a feature of Vault Enterprise Premium
</p>
{{else}}
<p class="help has-text-grey-dark">
{{replication-mode-description "performance"}}
</p>
{{/if}}
</div>
<div>
{{#if (has-feature "Performance Replication")}}
<RadioButton
id="performance"
name="replication-mode"
@value="performance"
@groupValue={{this.replicationMode}}
@onChange={{fn (mut this.replicationMode)}}
/>
<label for="performance" data-test-replication-type-select="performance"></label>
{{/if}}
</div>
</label>
</div>
</div>
{{/if}}
</div>
<div class="box is-sideless is-fullwidth is-marginless">
<label for="replication-mode" class="is-label">
Cluster mode
</label>
<div class="field is-expanded">
<div class="control select is-fullwidth">
<select
onchange={{action (mut this.mode) value="target.value"}}
id="replication-mode"
name="replication-mode"
data-test-replication-cluster-mode-select={{true}}
>
{{#each (array "primary" "secondary") as |modeOption|}}
<option selected={{if this.mode (eq this.mode modeOption) (eq modeOption "primary")}} value={{modeOption}}>
{{modeOption}}
</option>
{{/each}}
</select>
</div>
{{#if (eq this.mode "secondary")}}
<AlertInline @class="has-top" @type="warning" @message="This will immediately clear all data in this cluster!" />
{{/if}}
</div>
{{#if (eq this.mode "primary")}}
{{#if this.cluster.canEnablePrimary}}
<div class="field">
<label for="primary_cluster_addr" class="is-label">
Primary cluster address
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<Input
class="input"
id="primary_cluster_addr"
name="primary_cluster_addr"
@value={{this.primary_cluster_addr}}
/>
</div>
<p class="help has-text-grey">
Overrides the cluster address that the primary gives to secondary nodes.
</p>
</div>
{{else}}
<p>
The token you are using is not authorized to enable primary replication.
</p>
{{/if}}
{{else}}
{{#if this.cluster.canEnableSecondary}}
{{#if
(and
(eq this.replicationMode "dr")
(not this.cluster.performance.replicationDisabled)
(has-feature "Performance Replication")
)
}}
<div>
<ToggleButton
@isOpen={{this.showExplanation}}
@openLabel="Disable Performance Replication in order to enable this cluster as a DR secondary."
@closedLabel="Disable Performance Replication in order to enable this cluster as a DR secondary."
@onClick={{fn (mut this.showExplanation)}}
class="has-text-danger"
/>
{{#if this.showExplanation}}
<p>
When running as a DR Secondary Vault is read only. For this reason, we don't allow other Replication modes
to operate at the same time. This cluster is also currently operating as a Performance
{{capitalize this.cluster.performance.modeForUrl}}.
</p>
{{/if}}
</div>
{{else}}
<div class="field">
<label for="secondary-token" class="is-label">
Secondary activation token
</label>
<div class="control">
<Textarea @value={{this.token}} id="secondary-token" name="secondary-token" class="textarea" />
</div>
</div>
<div class="field">
<label for="primary_api_addr" class="is-label">
Primary API address
{{#if (not (and this.token (not this.tokenIncludesAPIAddr)))}}
<em class="is-optional">(optional)</em>
{{/if}}
</label>
<div class="control">
<Input @value={{this.primary_api_addr}} id="primary_api_addr" name="primary_api_addr" class="input" />
</div>
<p class="help {{if (and this.token (not this.tokenIncludesAPIAddr)) 'is-danger' 'has-text-grey'}}">
{{#if (and this.token (not this.tokenIncludesAPIAddr))}}
The supplied token does not contain an embedded address for the primary cluster. Please enter the primary
cluster's API address (normal Vault address).
{{else}}
Set this to the API address (normal Vault address) to override the value embedded in the token.
{{/if}}
</p>
</div>
<div class="field">
<label for="ca_file" class="is-label">
CA file
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<Input @value={{this.ca_file}} id="ca_file" name="ca_file" class="input" />
</div>
<p class="help has-text-grey">
Specifies the path to a CA root file (PEM format) that the secondary can use when unwrapping the token from
the primary.
</p>
</div>
<div class="field">
<label for="ca_path" class="is-label">
CA path
<em class="is-optional">(optional)</em>
</label>
<div class="control">
<Input @value={{this.ca_path}} id="ca_path" name="ca_file" class="input" />
</div>
<p class="help has-text-grey">
Specifies the path to a CA root directory containing PEM-format files that the secondary can use when
unwrapping the token from the primary.
</p>
</div>
<p>
Note: If both
<code>CA file</code>
and
<code>CA path</code>
are not given, they default to system CA roots.
</p>
{{/if}}
{{else}}
<p>The token you are using is not authorized to enable secondary replication.</p>
{{/if}}
{{/if}}
</div>
{{#if
(or
(and (eq this.mode "primary") this.cluster.canEnablePrimary)
(and (eq this.mode "secondary") this.cluster.canEnableSecondary)
)
}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<Hds::Button
@text="Enable Replication"
type="submit"
disabled={{this.disallowEnable}}
data-test-replication-enable
/>
</div>
{{/if}}
</form>
{{else if this.showModeSummary}}
{{#if (not (and this.cluster.dr.replicationEnabled this.cluster.performance.replicationEnabled))}}
<PageHeader as |p|>

View File

@@ -16,13 +16,90 @@
<EmptyState @title="The current cluster configuration does not support replication" />
{{else if this.model.replicationIsInitializing}}
<LayoutLoading />
{{else}}
<ReplicationSummary
@cluster={{this.model}}
@showModeSummary={{true}}
@onEnable={{action "onEnable"}}
@onDisable={{action "onDisable"}}
{{else if this.model.allReplicationDisabled}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3" data-test-replication-title>
Enable Replication
</h1>
</p.levelLeft>
</PageHeader>
<div class="box is-sideless is-fullwidth is-marginless">
<p class="has-text-grey-dark box is-shadowless is-fullwidth has-slim-padding">
<label for="replication-mode" class="is-label is-block">
Type of replication
</label>
In both Performance and Disaster Recovery (DR) Replication, secondaries share the underlying configuration,
policies, and supporting secrets as their primary cluster.
</p>
<div class="columns">
<div class="column is-flex">
<label for="dr" class="box-label is-column {{if (eq this.replicationMode 'dr') 'is-selected'}}">
<div>
<h3 class="box-label-header title is-6">
<Icon @size="24" @name="replication-direct" />
Disaster Recovery (DR)
</h3>
<p class="help has-text-grey-dark">
{{replication-mode-description "dr"}}
</p>
</div>
<div>
<RadioButton
id="dr"
name="replication-mode"
@value="dr"
@groupValue={{this.modeSelection}}
@onChange={{fn (mut this.modeSelection)}}
/>
<label for="dr" data-test-replication-type-select="dr"></label>
</div>
</label>
</div>
<div class="column is-flex">
<label for="performance" class="box-label is-column {{if (eq this.modeSelection 'performance') 'is-selected'}}">
<div>
<h3 class="box-label-header title is-6">
<Icon @size="24" @name="replication-perf" />
Performance
</h3>
{{#if (not (has-feature "Performance Replication"))}}
<p class="help has-text-grey-dark">
Performance Replication is a feature of Vault Enterprise Premium
</p>
{{else}}
<p class="help has-text-grey-dark">
{{replication-mode-description "performance"}}
</p>
{{/if}}
</div>
<div>
{{#if (has-feature "Performance Replication")}}
<RadioButton
id="performance"
name="replication-mode"
@value="performance"
@groupValue={{this.modeSelection}}
@onChange={{fn (mut this.modeSelection)}}
/>
<label for="performance" data-test-replication-type-select="performance"></label>
{{/if}}
</div>
</label>
</div>
</div>
</div>
<EnableReplicationForm
@replicationMode={{this.modeSelection}}
@canEnablePrimary={{this.model.canEnablePrimary}}
@canEnableSecondary={{this.model.canEnableSecondary}}
@performanceReplicationDisabled={{this.model.performance.replicationDisabled}}
@performanceMode={{if this.model.performance.replicationDisabled "disabled" this.model.performance.modeForUrl}}
@onSuccess={{this.onEnableSuccess}}
@doTransition={{true}}
/>
{{else}}
<ReplicationSummary @cluster={{this.model}} @showModeSummary={{true}} @onDisable={{this.onDisable}} />
{{/if}}
</div>
</section>

View File

@@ -3,4 +3,54 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<ReplicationSummary @cluster={{this.model}} @initialReplicationMode={{this.replicationMode}} />
{{#if this.replicationForMode.replicationDisabled}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3" data-test-replication-title>
{{#if (eq this.replicationMode "dr")}}
Enable Disaster Recovery Replication
{{else if (eq this.replicationMode "performance")}}
Enable Performance Replication
{{else}}
{{! should never get here, but have safe fallback just in case }}
Enable Replication
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="box is-sideless is-fullwidth is-marginless">
{{#if (eq this.replicationMode "dr")}}
<h2 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-direct" />
Disaster Recovery (DR) Replication
</h2>
<p class="help has-text-grey-dark">
{{replication-mode-description "dr"}}
</p>
{{else if (eq this.replicationMode "performance")}}
<h2 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-perf" />
Performance Replication
</h2>
{{#if (has-feature "Performance Replication")}}
<p class="help has-text-grey-dark">
{{replication-mode-description "performance"}}
</p>
{{else}}
<p class="help has-text-grey-dark">
Performance Replication is a feature of Vault Enterprise Premium
</p>
{{/if}}
{{/if}}
</div>
<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{this.model.canEnablePrimary}}
@canEnableSecondary={{this.model.canEnableSecondary}}
@performanceReplicationDisabled={{this.model.performance.replicationDisabled}}
@performanceMode={{if this.model.performance.replicationDisabled "disabled" this.model.performance.modeForUrl}}
@onSuccess={{this.onEnableSuccess}}
/>
{{else}}
<ReplicationSummary @cluster={{this.model}} @initialReplicationMode={{this.replicationMode}} />
{{/if}}

View File

@@ -46,7 +46,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
});
test('replication', async function (assert) {
assert.expect(17);
assert.expect(18);
const secondaryName = 'firstSecondary';
const mode = 'deny';
@@ -91,7 +91,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
await click('#deny');
await clickTrigger();
await searchSelect.options.objectAt(0).click();
const mountPath = find('[data-test-selected-option="0"]').textContent.trim();
const mountPath = find('[data-test-selected-option="0"]').innerText?.trim();
await click('[data-test-secondary-add]');
await pollCluster(this.owner);
@@ -315,11 +315,16 @@ module('Acceptance | Enterprise | replication', function (hooks) {
// enable DR primary replication
await click('[data-test-replication-details-link="dr"]');
// eslint-disable-next-line ember/no-settled-after-test-helper
await settled(); // let the controller set replicationMode in afterModel
assert.dom('[data-test-replication-title]').hasText('Enable Disaster Recovery Replication');
await click('[data-test-replication-enable]');
await pollCluster(this.owner);
await settled();
// Breadcrumbs only load once we're in the summary mode after enabling
await waitFor('[data-test-replication-breadcrumb]');
// navigate using breadcrumbs back to replication.index
assert.dom('[data-test-replication-breadcrumb]').exists('shows the replication breadcrumb (flaky)');
await click('[data-test-replication-breadcrumb] a');

View File

@@ -12,7 +12,7 @@ import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import authForm from 'vault/tests/pages/components/auth-form';
import enablePage from 'vault/tests/pages/settings/auth/enable';
import { visit, settled, currentURL, waitFor } from '@ember/test-helpers';
import { visit, settled, currentURL, waitFor, currentRouteName } from '@ember/test-helpers';
import { clearRecord } from 'vault/tests/helpers/oidc-config';
import { runCmd } from 'vault/tests/helpers/commands';
@@ -219,7 +219,7 @@ module('Acceptance | oidc provider', function (hooks) {
currentURL().startsWith('/vault/auth'),
'Does not redirect to auth because user is already logged in'
);
await waitFor('[data-test-consent-form]');
assert.strictEqual(currentRouteName(), 'vault.cluster.oidc-provider');
assert.dom('[data-test-consent-form]').exists('Consent form exists');
//* clean up test state

View File

@@ -4,6 +4,7 @@
*/
import { click, fillIn, findAll, currentURL, visit, settled, waitUntil } from '@ember/test-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
export const disableReplication = async (type, assert) => {
// disable performance replication
@@ -20,12 +21,11 @@ export const disableReplication = async (type, assert) => {
await settled(); // eslint-disable-line
if (assert) {
// bypassing for now -- remove if tests pass reliably
// assert.strictEqual(
// flash.latestMessage,
// 'This cluster is having replication disabled. Vault will be unavailable for a brief period and will resume service shortly.',
// 'renders info flash when disabled'
// );
assert
.dom(GENERAL.latestFlashContent)
.hasText(
'This cluster is having replication disabled. Vault will be unavailable for a brief period and will resume service shortly.'
);
assert.ok(
await waitUntil(() => currentURL() === '/vault/replication'),
'redirects to the replication page'

View File

@@ -0,0 +1,298 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import sinon from 'sinon';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { render, fillIn, click, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { overrideResponse } from 'vault/tests/helpers/stubs';
const ENABLE_FORM = {
clusterMode: '[data-test-replication-cluster-mode-select]',
clusterAddr: '[data-test-input="primary_cluster_addr"]',
secondaryToken: '[data-test-textarea="secondary-token"]',
primaryAddr: '[data-test-input="primary_api_addr"]',
caFile: '[data-test-input="ca_file"]',
caPath: '[data-test-input="ca_path"]',
submitButton: '[data-test-replication-enable]',
notAllowed: '[data-test-not-allowed]',
inlineMessage: '[data-test-inline-error-message]',
cannotEnable: '[data-test-disable-to-continue]',
cannotEnableExplanation: '[data-test-disable-explanation]',
error: '[data-test-message-error-description]',
};
module('Integration | Component | enable-replication-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
setupEngine(hooks, 'replication');
hooks.beforeEach(function () {
this.context = { owner: this.engine };
this.version = this.owner.lookup('service:version');
});
['performance', 'dr'].forEach((replicationMode) => {
test(`it renders correct form inputs when ${replicationMode} replication mode`, async function (assert) {
assert.expect(10);
this.version.features = ['Performance Replication', 'DR Replication'];
this.set('replicationMode', replicationMode);
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
/>`,
this.context
);
assert.dom(ENABLE_FORM.clusterMode).hasValue('primary');
['clusterAddr'].forEach((field) => {
assert.dom(ENABLE_FORM[field]).hasNoValue();
});
assert.dom(ENABLE_FORM.submitButton).isNotDisabled();
await fillIn(ENABLE_FORM.clusterMode, 'secondary');
assert.dom(ENABLE_FORM.inlineMessage).hasText('This will immediately clear all data in this cluster!');
['secondaryToken', 'primaryAddr', 'caFile', 'caPath'].forEach((field) => {
assert.dom(ENABLE_FORM[field]).hasNoValue();
});
assert.dom(ENABLE_FORM.submitButton).isDisabled();
await fillIn(ENABLE_FORM.secondaryToken, 'some-token');
await fillIn(ENABLE_FORM.primaryAddr, 'some-addr');
assert.dom(ENABLE_FORM.submitButton).isNotDisabled();
});
test(`it shows warning when capabilities restricted for ${replicationMode} replication mode`, async function (assert) {
assert.expect(10);
this.version.features = ['Performance Replication', 'DR Replication'];
this.set('replicationMode', replicationMode);
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{false}}
@canEnableSecondary={{false}}
@performanceMode="disabled"
/>`,
this.context
);
assert.dom(ENABLE_FORM.clusterMode).hasValue('primary');
assert
.dom(ENABLE_FORM.notAllowed)
.hasText('The token you are using is not authorized to enable primary replication.');
['clusterAddr', 'submitButton'].forEach((field) => {
assert.dom(ENABLE_FORM[field]).doesNotExist();
});
await fillIn(ENABLE_FORM.clusterMode, 'secondary');
assert
.dom(ENABLE_FORM.notAllowed)
.hasText('The token you are using is not authorized to enable secondary replication.');
['secondaryToken', 'primaryAddr', 'caFile', 'caPath', 'submitButton'].forEach((field) => {
assert.dom(ENABLE_FORM[field]).doesNotExist();
});
});
});
test('enable DR when cluster is perf primary', async function (assert) {
this.version.features = ['Performance Replication', 'DR Replication'];
this.set('replicationMode', 'dr');
this.set('performanceMode', 'primary');
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode={{this.performanceMode}}
/>`,
this.context
);
assert.dom(ENABLE_FORM.clusterMode).hasValue('primary');
['clusterAddr'].forEach((field) => {
assert.dom(ENABLE_FORM[field]).hasNoValue();
});
assert.dom(ENABLE_FORM.submitButton).isNotDisabled();
await fillIn(ENABLE_FORM.clusterMode, 'secondary');
assert
.dom(ENABLE_FORM.cannotEnable)
.hasText('Disable Performance Replication in order to enable this cluster as a DR secondary.');
await click(ENABLE_FORM.cannotEnable);
assert
.dom(ENABLE_FORM.cannotEnableExplanation)
.hasText(
"When running as a DR Secondary Vault is read only. For this reason, we don't allow other Replication modes to operate at the same time. This cluster is also currently operating as a Performance Primary."
);
assert.dom(ENABLE_FORM.submitButton).isDisabled();
this.set('performanceMode', 'secondary');
await settled();
assert
.dom(ENABLE_FORM.cannotEnableExplanation)
.hasText(
"When running as a DR Secondary Vault is read only. For this reason, we don't allow other Replication modes to operate at the same time. This cluster is also currently operating as a Performance Secondary."
);
});
module('only DR replication in features', function (hooks) {
hooks.beforeEach(function () {
this.version.features = ['DR Replication'];
});
test('attempting to enable performance replication', async function (assert) {
await render(
hbs`<EnableReplicationForm
@replicationMode="performance"
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
/>`,
this.context
);
assert.dom(ENABLE_FORM.submitButton).isDisabled();
});
});
module('successful enable', function (hooks) {
hooks.beforeEach(function () {
this.version.features = ['Performance Replication', 'DR Replication'];
this.successSpy = sinon.spy();
this.set('onSuccess', this.successSpy);
});
['dr', 'performance'].forEach((replicationMode) => {
test(`${replicationMode} primary`, async function (assert) {
assert.expect(4);
this.set('replicationMode', replicationMode);
this.server.post(`/sys/replication/${replicationMode}/primary/enable`, (_, req) => {
const body = JSON.parse(req.requestBody);
assert.deepEqual(body, {
primary_cluster_addr: 'some-addr',
});
return {
returned: 'value',
};
});
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
@onSuccess={{this.onSuccess}}
@doTransition={{false}}
/>`,
this.context
);
await fillIn(ENABLE_FORM.clusterAddr, 'some-addr');
await click(ENABLE_FORM.submitButton);
// after success
assert.dom(ENABLE_FORM.clusterAddr).hasNoValue();
assert.true(this.successSpy.calledOnce, 'called once');
assert.deepEqual(
this.successSpy.getCall(0).args,
[{ returned: 'value' }, replicationMode, 'primary', false],
'called with correct args'
);
});
test(`${replicationMode} secondary`, async function (assert) {
assert.expect(5);
this.set('replicationMode', replicationMode);
this.server.post(`/sys/replication/${replicationMode}/secondary/enable`, (_, req) => {
const body = JSON.parse(req.requestBody);
assert.deepEqual(
body,
{
primary_api_addr: 'http://127.0.0.1:8200',
token: 'some-token-value',
},
'does not include empty values'
);
return {
returned: 'value',
};
});
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
@onSuccess={{this.onSuccess}}
@doTransition={{true}}
/>`,
this.context
);
await fillIn(ENABLE_FORM.clusterMode, 'secondary');
await fillIn(ENABLE_FORM.secondaryToken, 'some-token-value');
await fillIn(ENABLE_FORM.primaryAddr, 'http://127.0.0.1:8200');
// Fill in then clear ca path
await fillIn(ENABLE_FORM.caPath, 'some-path');
await fillIn(ENABLE_FORM.caPath, '');
await click(ENABLE_FORM.submitButton);
// after success
assert.dom(ENABLE_FORM.secondaryToken).hasValue('');
assert.dom(ENABLE_FORM.primaryAddr).hasNoValue();
assert.true(this.successSpy.calledOnce, 'called once');
assert.deepEqual(
this.successSpy.getCall(0).args,
[{ returned: 'value' }, replicationMode, 'secondary', true],
'called with correct args'
);
});
});
});
module('shows API errors', function (hooks) {
hooks.beforeEach(function () {
this.version.features = ['Performance Replication', 'DR Replication'];
this.successSpy = sinon.spy();
this.set('onSuccess', this.successSpy);
});
['dr', 'performance'].forEach((replicationMode) => {
test(`${replicationMode} primary`, async function (assert) {
this.set('replicationMode', replicationMode);
this.server.post(`/sys/replication/${replicationMode}/primary/enable`, overrideResponse(403));
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
@onSuccess={{this.onSuccess}}
/>`,
this.context
);
await fillIn(ENABLE_FORM.clusterAddr, 'some-addr');
await click(ENABLE_FORM.submitButton);
assert.dom(ENABLE_FORM.error).hasText('permission denied', 'shows error returned from API');
assert.dom(ENABLE_FORM.clusterAddr).hasValue('some-addr', 'does not clear form');
assert.false(this.successSpy.calledOnce, 'success spy not called');
});
test(`${replicationMode} secondary`, async function (assert) {
this.set('replicationMode', replicationMode);
this.server.post(`/sys/replication/${replicationMode}/secondary/enable`, overrideResponse(403));
await render(
hbs`<EnableReplicationForm
@replicationMode={{this.replicationMode}}
@canEnablePrimary={{true}}
@canEnableSecondary={{true}}
@performanceMode="disabled"
@onSuccess={{this.onSuccess}}
/>`,
this.context
);
await fillIn(ENABLE_FORM.clusterMode, 'secondary');
await fillIn(ENABLE_FORM.secondaryToken, 'some-token-value');
await fillIn(ENABLE_FORM.primaryAddr, 'http://127.0.0.1:8200');
await click(ENABLE_FORM.submitButton);
// after error
assert.dom(ENABLE_FORM.error).hasText('permission denied', 'shows error returned from API');
assert.dom(ENABLE_FORM.secondaryToken).hasValue('some-token-value', 'does not clear form');
assert.false(this.successSpy.calledOnce, 'success spy not called');
});
});
});
});