Rename feature-flag service and include activation-flags state (#26476)

* rename

* move activation-flags state to the flags service

* clean up descriptions of services

* fix naming that I missed

* Update secrets.ts

* add test coverage

* services/flags: rearrage getters

---------

Co-authored-by: Noelle Daley <noelledaley@users.noreply.github.com>
This commit is contained in:
Angel Garbarino
2024-04-22 16:37:11 -06:00
committed by GitHub
parent 4cf7a4464f
commit 069975413f
17 changed files with 211 additions and 108 deletions

View File

@@ -114,7 +114,7 @@ export default class App extends Application {
},
sync: {
dependencies: {
services: ['flash-messages', 'feature-flag', 'router', 'store', 'version'],
services: ['flash-messages', 'flags', 'router', 'store', 'version'],
externalRoutes: {
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
clientCountOverview: 'vault.cluster.clients',

View File

@@ -19,7 +19,7 @@ export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN };
export default Component.extend({
store: service(),
featureFlagService: service('featureFlag'),
flagsService: service('flags'),
selectedAuthPath: null,
selectedAuthType: null,
@@ -133,7 +133,7 @@ export default Component.extend({
// The namespace can be either be passed as a query parameter, or be embedded
// in the state param in the format `<state_id>,ns=<namespace>`. So if
// `namespace` is empty, check for namespace in state as well.
if (namespace === '' || this.featureFlagService.managedNamespaceRoot) {
if (namespace === '' || this.flagsService.managedNamespaceRoot) {
const i = state.indexOf(',ns=');
if (i >= 0) {
// ",ns=" is 4 characters

View File

@@ -21,7 +21,7 @@ export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS };
export default class AuthSaml extends Component {
@service store;
@service featureFlag;
@service flags;
@tracked errorMessage;

View File

@@ -8,7 +8,7 @@ import { service } from '@ember/service';
export default class SidebarNavClusterComponent extends Component {
@service currentCluster;
@service featureFlag;
@service flags;
@service version;
@service auth;
@service namespace;
@@ -24,7 +24,7 @@ export default class SidebarNavClusterComponent extends Component {
get showSync() {
// Only show sync if cluster is not managed
return this.featureFlag.managedNamespaceRoot === null;
return this.flags.managedNamespaceRoot === null;
}
get syncBadge() {

View File

@@ -13,7 +13,7 @@ export default Controller.extend({
clusterController: controller('vault.cluster'),
flashMessages: service(),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),
flagsService: service('flags'),
version: service(),
auth: service(),
router: service(),
@@ -22,7 +22,7 @@ export default Controller.extend({
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
wrappedToken: alias('vaultController.wrappedToken'),
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
managedNamespaceRoot: alias('flagsService.managedNamespaceRoot'),
authMethod: '',
oidcProvider: '',

View File

@@ -11,7 +11,7 @@ export default Route.extend({
controlGroup: service(),
routing: service('router'),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),
flagsService: service('flags'),
actions: {
willTransition() {
@@ -72,7 +72,7 @@ export default Route.extend({
if (result.status === 200) {
const body = await result.json();
const flags = body.feature_flags || [];
this.featureFlagService.setFeatureFlags(flags);
this.flagsService.setFeatureFlags(flags);
}
},
});

View File

@@ -31,7 +31,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
auth: service(),
currentCluster: service(),
customMessages: service(),
featureFlagService: service('featureFlag'),
flagsService: service('flags'),
namespaceService: service('namespace'),
permissions: service(),
router: service(),
@@ -58,7 +58,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
const params = this.paramsFor(this.routeName);
let namespace = params.namespaceQueryParam;
const currentTokenName = this.auth.currentTokenName;
const managedRoot = this.featureFlagService.managedNamespaceRoot;
const managedRoot = this.flagsService.managedNamespaceRoot;
assert(
'Cannot use VAULT_CLOUD_ADMIN_NAMESPACE flag with non-enterprise Vault version',
!(managedRoot && this.version.isCommunity)

View File

@@ -1,33 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
const FLAGS = {
vaultCloudNamespace: 'VAULT_CLOUD_ADMIN_NAMESPACE',
};
/**
* This service is for managing feature flags relevant to the Vault Cluster.
* For now, the only available feature flag is VAULT_CLOUD_ADMIN_NAMESPACE
* indicates that the Vault cluster is managed rather than self-managed.
* Flags are fetched in the application route once from sys/internal/ui/feature-flags
* and then stored here for use throughout the application.
*/
export default class FeatureFlagService extends Service {
@tracked featureFlags: string[] = [];
setFeatureFlags(flags: string[]) {
this.featureFlags = flags;
}
get managedNamespaceRoot() {
if (this.featureFlags && this.featureFlags.includes(FLAGS.vaultCloudNamespace)) {
return 'admin';
}
return null;
}
}

64
ui/app/services/flags.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { keepLatestTask } from 'ember-concurrency';
import { DEBUG } from '@glimmer/env';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
const FLAGS = {
vaultCloudNamespace: 'VAULT_CLOUD_ADMIN_NAMESPACE',
};
/**
* This service returns information about cluster flags. For now, the two available flags are from sys/internal/ui/feature-flags and sys/activation-flags.
* The feature-flags endpoint returns VAULT_CLOUD_ADMIN_NAMESPACE which indicates that the Vault cluster is managed rather than self-managed.
* The activation-flags endpoint returns which features are enabled.
*/
export default class flagsService extends Service {
@service declare readonly version: VersionService;
@service declare readonly store: StoreService;
@tracked flags: string[] = [];
@tracked activatedFlags: string[] = [];
setFeatureFlags(flags: string[]) {
this.flags = flags;
}
get managedNamespaceRoot() {
if (this.flags && this.flags.includes(FLAGS.vaultCloudNamespace)) {
return 'admin';
}
return null;
}
// TODO getter will be used in the upcoming persona service
get secretsSyncIsActivated() {
return this.activatedFlags.includes('secrets-sync');
}
getActivatedFlags = keepLatestTask(async () => {
if (this.version.isCommunity) return;
// Response could change between user sessions.
// Fire off endpoint without checking if activated features are already set.
try {
const response = await this.store
.adapterFor('application')
.ajax('/v1/sys/activation-flags', 'GET', { unauthenticated: true, namespace: null });
this.activatedFlags = response.data?.activated;
return;
} catch (error) {
if (DEBUG) console.error(error); // eslint-disable-line no-console
}
});
fetchActivatedFlags() {
return this.getActivatedFlags.perform();
}
}

View File

@@ -7,9 +7,13 @@ import Service, { inject as service } from '@ember/service';
import { keepLatestTask, task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
/**
* This service returns information about a cluster's license/features, version and type (community vs enterprise).
*/
export default class VersionService extends Service {
@service store;
@service featureFlag;
@service flags;
@tracked features = [];
@tracked version = null;
@tracked type = null;
@@ -44,7 +48,8 @@ export default class VersionService extends Service {
}
get hasSecretsSync() {
if (this.featureFlag.managedNamespaceRoot !== null) return false;
// TODO remove this conditional when we allow secrets sync in managed clusters
if (this.flags.managedNamespaceRoot !== null) return false;
return this.features.includes('Secrets Sync');
}

View File

@@ -14,7 +14,7 @@ export default class SyncEngine extends Engine {
modulePrefix = modulePrefix;
Resolver = Resolver;
dependencies = {
services: ['flash-messages', 'feature-flag', 'router', 'store', 'version'],
services: ['flash-messages', 'flags', 'router', 'store', 'version'],
externalRoutes: ['kvSecretDetails', 'clientCountOverview'],
};
}

View File

@@ -7,39 +7,20 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import { DEBUG } from '@glimmer/env';
interface ActivationFlagsResponse {
data: {
activated: Array<string>;
unactivated: Array<string>;
};
}
import type FlagService from 'vault/services/flags';
export default class SyncSecretsRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flags: FlagService;
async fetchActivatedFeatures() {
// The read request to the activation-flags endpoint is unauthenticated and root namespace
// but the POST is not which is why it's not in the NAMESPACE_ROOT_URLS list
return await this.store
.adapterFor('application')
.ajax('/v1/sys/activation-flags', 'GET', { unauthenticated: true, namespace: null })
.then((resp: ActivationFlagsResponse) => {
return resp.data?.activated;
})
.catch((error: unknown) => {
if (DEBUG) console.error(error); // eslint-disable-line no-console
return [];
});
beforeModel() {
return this.flags.fetchActivatedFlags();
}
async model() {
const activatedFeatures = await this.fetchActivatedFeatures();
model() {
return {
activatedFeatures,
// TODO will modify when we use the persona service.
activatedFeatures: this.flags.activatedFlags,
};
}

View File

@@ -8,16 +8,16 @@ import { service } from '@ember/service';
import { hash } from 'rsvp';
import type RouterService from '@ember/routing/router-service';
import type FeatureFlagService from 'vault/services/feature-flag';
import type FlagsService from 'vault/services/flags';
import type StoreService from 'vault/services/store';
export default class SyncSecretsOverviewRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly featureFlag: FeatureFlagService;
@service declare readonly flags: FlagsService;
beforeModel(): void | Promise<unknown> {
if (this.featureFlag.managedNamespaceRoot !== null) {
if (this.flags.managedNamespaceRoot !== null) {
this.router.transitionTo('vault.cluster.dashboard');
}
}

View File

@@ -180,7 +180,7 @@ module('Acceptance | sync | overview', function (hooks) {
test.skip('it should make activation-flag requests to correct namespace when managed', async function (assert) {
// TODO: unskip for 1.16.1 when managed is supported
assert.expect(3);
this.owner.lookup('service:feature-flag').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
this.owner.lookup('service:flags').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
this.server.get('/sys/activation-flags', (_, req) => {
assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace');

View File

@@ -136,7 +136,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
});
test('it should not show sync links for managed cluster', async function (assert) {
this.owner.lookup('service:feature-flag').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
this.owner.lookup('service:flags').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
stubFeaturesAndPermissions(this.owner, true, true, ['Secrets Sync']);
await renderComponent();

View File

@@ -1,29 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | feature-flag', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
const service = this.owner.lookup('service:feature-flag');
assert.ok(service);
});
test('it returns the namespace root when flag is present', function (assert) {
const service = this.owner.lookup('service:feature-flag');
assert.strictEqual(service.managedNamespaceRoot, null, 'Managed namespace root is null by default');
service.setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
assert.strictEqual(service.managedNamespaceRoot, 'admin', 'Managed namespace is admin when flag present');
service.setFeatureFlags(['SOMETHING_ELSE']);
assert.strictEqual(
service.managedNamespaceRoot,
null,
'Flags were overwritten and root namespace is null again'
);
});
});

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
const ACTIVATED_FLAGS_RESPONSE = {
data: {
activated: ['secrets-sync'],
unactivated: [],
},
};
module('Unit | Service | flags', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.service = this.owner.lookup('service:flags');
});
test('it loads with defaults', function (assert) {
assert.deepEqual(this.service.flags, [], 'Flags are empty until fetched');
assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty until fetched');
});
test('#setFeatureFlags: it can set feature flags', function (assert) {
this.service.setFeatureFlags(['foo', 'bar']);
assert.deepEqual(this.service.flags, ['foo', 'bar'], 'Flags are set');
});
module('#fetchActivatedFlags', function (hooks) {
hooks.beforeEach(function () {
this.owner.lookup('service:version').type = 'enterprise';
});
test('it returns activated flags', async function (assert) {
assert.expect(2);
this.server.get('sys/activation-flags', () => {
assert.true(true, 'GET request made to activation-flags endpoint');
return ACTIVATED_FLAGS_RESPONSE;
});
await this.service.fetchActivatedFlags();
assert.deepEqual(
this.service.activatedFlags,
ACTIVATED_FLAGS_RESPONSE.data.activated,
'Activated flags are fetched and set'
);
});
test('it returns an empty array if no flags are activated', async function (assert) {
this.server.get('sys/activation-flags', () => {
return {
data: {
activated: [],
unactivated: [],
},
};
});
await this.service.fetchActivatedFlags();
assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty');
});
test('it returns an empty array if the cluster is OSS', async function (assert) {
this.owner.lookup('service:version').type = 'community';
await this.service.fetchActivatedFlags();
assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty');
});
});
module('#managedNamespaceRoot', function () {
test('it returns null when flag is not present', function (assert) {
assert.strictEqual(this.service.managedNamespaceRoot, null);
});
test('it returns the namespace root when flag is present', function (assert) {
this.service.setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
assert.strictEqual(
this.service.managedNamespaceRoot,
'admin',
'Managed namespace is admin when flag present'
);
this.service.setFeatureFlags(['SOMETHING_ELSE']);
assert.strictEqual(
this.service.managedNamespaceRoot,
null,
'Flags were overwritten and root namespace is null again'
);
});
});
module('#secretsSyncActivated', function (hooks) {
hooks.beforeEach(function () {
this.owner.lookup('service:version').type = 'enterprise';
this.service.activatedFlags = ACTIVATED_FLAGS_RESPONSE.data.activated;
});
test('it returns true when secrets sync is activated', function (assert) {
assert.true(this.service.secretsSyncIsActivated);
});
test('it returns false when secrets sync is not activated', function (assert) {
this.service.activatedFlags = [];
assert.false(this.service.secretsSyncIsActivated);
});
});
});