1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

fix: disable all environments when reviving a feature (#4999)

Disable all environments when reviving a feature

Closes #
[SR-93](https://linear.app/unleash/issue/SR-93/disable-all-environments-when-reviving-a-feature)

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-10-13 10:38:18 +03:00 committed by GitHub
parent a7dd0d6c1a
commit a9a75d5e82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 170 additions and 20 deletions

View File

@ -83,6 +83,7 @@ exports[`should create default config 1`] = `
"demo": false, "demo": false,
"dependentFeatures": false, "dependentFeatures": false,
"disableBulkToggle": false, "disableBulkToggle": false,
"disableEnvsOnRevive": false,
"disableMetrics": false, "disableMetrics": false,
"disableNotifications": false, "disableNotifications": false,
"doraMetrics": false, "doraMetrics": false,
@ -127,6 +128,7 @@ exports[`should create default config 1`] = `
"demo": false, "demo": false,
"dependentFeatures": false, "dependentFeatures": false,
"disableBulkToggle": false, "disableBulkToggle": false,
"disableEnvsOnRevive": false,
"disableMetrics": false, "disableMetrics": false,
"disableNotifications": false, "disableNotifications": false,
"doraMetrics": false, "doraMetrics": false,

View File

@ -1,6 +1,6 @@
import { import {
IFeatureToggleStoreQuery,
IFeatureToggleStore, IFeatureToggleStore,
IFeatureToggleStoreQuery,
} from '../types/feature-toggle-store-type'; } from '../types/feature-toggle-store-type';
import NotFoundError from '../../../error/notfound-error'; import NotFoundError from '../../../error/notfound-error';
import { import {
@ -67,6 +67,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return features; return features;
} }
disableAllEnvironmentsForFeatures(names: string[]): Promise<void> {
throw new Error('Method not implemented.');
}
async count(query: Partial<IFeatureToggleStoreQuery>): Promise<number> { async count(query: Partial<IFeatureToggleStoreQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length; return this.features.filter(this.getFilterQuery(query)).length;
} }

View File

@ -1915,6 +1915,12 @@ class FeatureToggleService {
); );
await this.featureToggleStore.batchRevive(eligibleFeatureNames); await this.featureToggleStore.batchRevive(eligibleFeatureNames);
if (this.flagResolver.isEnabled('disableEnvsOnRevive')) {
await this.featureToggleStore.disableAllEnvironmentsForFeatures(
eligibleFeatureNames,
);
}
await this.eventService.storeEvents( await this.eventService.storeEvents(
eligibleFeatures.map( eligibleFeatures.map(
(feature) => (feature) =>
@ -1931,6 +1937,11 @@ class FeatureToggleService {
async reviveFeature(featureName: string, createdBy: string): Promise<void> { async reviveFeature(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.revive(featureName); const toggle = await this.featureToggleStore.revive(featureName);
if (this.flagResolver.isEnabled('disableEnvsOnRevive')) {
await this.featureToggleStore.disableAllEnvironmentsForFeatures([
featureName,
]);
}
await this.eventService.storeEvent( await this.eventService.storeEvent(
new FeatureRevivedEvent({ new FeatureRevivedEvent({
createdBy, createdBy,

View File

@ -575,6 +575,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.where({ name }) .where({ name })
.update({ archived_at: null }) .update({ archived_at: null })
.returning(FEATURE_COLUMNS); .returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]); return this.rowToFeature(row[0]);
} }
@ -583,9 +584,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.whereIn('name', names) .whereIn('name', names)
.update({ archived_at: null }) .update({ archived_at: null })
.returning(FEATURE_COLUMNS); .returning(FEATURE_COLUMNS);
return rows.map((row) => this.rowToFeature(row)); return rows.map((row) => this.rowToFeature(row));
} }
async disableAllEnvironmentsForFeatures(names: string[]): Promise<void> {
await this.db(FEATURE_ENVIRONMENTS_TABLE)
.whereIn('feature_name', names)
.update({ enabled: false });
}
async getVariants(featureName: string): Promise<IVariant[]> { async getVariants(featureName: string): Promise<IVariant[]> {
if (!(await this.exists(featureName))) { if (!(await this.exists(featureName))) {
throw new NotFoundError('No feature toggle found'); throw new NotFoundError('No feature toggle found');

View File

@ -66,4 +66,6 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
featureName: string, featureName: string,
newVariants: IVariant[], newVariants: IVariant[],
): Promise<FeatureToggle>; ): Promise<FeatureToggle>;
disableAllEnvironmentsForFeatures(names: string[]): Promise<void>;
} }

View File

@ -17,22 +17,36 @@ import {
emptyResponse, emptyResponse,
getStandardResponses, getStandardResponses,
} from '../../openapi/util/standard-responses'; } from '../../openapi/util/standard-responses';
import { TransactionCreator, UnleashTransaction } from '../../db/transaction';
export default class ArchiveController extends Controller { export default class ArchiveController extends Controller {
private featureService: FeatureToggleService; private featureService: FeatureToggleService;
private transactionalFeatureToggleService: (
db: UnleashTransaction,
) => FeatureToggleService;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
private openApiService: OpenApiService; private openApiService: OpenApiService;
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
transactionalFeatureToggleService,
featureToggleServiceV2, featureToggleServiceV2,
openApiService, openApiService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'openApiService'>, }: Pick<
IUnleashServices,
| 'transactionalFeatureToggleService'
| 'featureToggleServiceV2'
| 'openApiService'
>,
startTransaction: TransactionCreator<UnleashTransaction>,
) { ) {
super(config); super(config);
this.featureService = featureToggleServiceV2; this.featureService = featureToggleServiceV2;
this.openApiService = openApiService; this.openApiService = openApiService;
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.startTransaction = startTransaction;
this.route({ this.route({
method: 'get', method: 'get',
@ -172,7 +186,13 @@ export default class ArchiveController extends Controller {
): Promise<void> { ): Promise<void> {
const userName = extractUsername(req); const userName = extractUsername(req);
const { featureName } = req.params; const { featureName } = req.params;
await this.featureService.reviveFeature(featureName, userName);
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).reviveFeature(
featureName,
userName,
),
);
res.status(200).end(); res.status(200).end();
} }
} }

View File

@ -49,7 +49,11 @@ class AdminApi extends Controller {
); );
this.app.use( this.app.use(
'/archive', '/archive',
new ArchiveController(config, services).router, new ArchiveController(
config,
services,
createKnexTransactionStarter(db),
).router,
); );
this.app.use( this.app.use(
'/strategies', '/strategies',

View File

@ -125,7 +125,14 @@ export default class ProjectApi extends Controller {
this.use('/', new ProjectHealthReport(config, services).router); this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router); this.use('/', new VariantsController(config, services).router);
this.use('/', new ProjectApiTokenController(config, services).router); this.use('/', new ProjectApiTokenController(config, services).router);
this.use('/', new ProjectArchiveController(config, services).router); this.use(
'/',
new ProjectArchiveController(
config,
services,
createKnexTransactionStarter(db),
).router,
);
} }
async getProjects( async getProjects(

View File

@ -18,6 +18,10 @@ import {
} from '../../../openapi/util/standard-responses'; } from '../../../openapi/util/standard-responses';
import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi'; import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi';
import Controller from '../../controller'; import Controller from '../../controller';
import {
TransactionCreator,
UnleashTransaction,
} from '../../../db/transaction';
const PATH = '/:projectId'; const PATH = '/:projectId';
const PATH_ARCHIVE = `${PATH}/archive`; const PATH_ARCHIVE = `${PATH}/archive`;
@ -29,6 +33,11 @@ export default class ProjectArchiveController extends Controller {
private featureService: FeatureToggleService; private featureService: FeatureToggleService;
private transactionalFeatureToggleService: (
db: UnleashTransaction,
) => FeatureToggleService;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
private openApiService: OpenApiService; private openApiService: OpenApiService;
private flagResolver: IFlagResolver; private flagResolver: IFlagResolver;
@ -36,15 +45,25 @@ export default class ProjectArchiveController extends Controller {
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
transactionalFeatureToggleService,
featureToggleServiceV2, featureToggleServiceV2,
openApiService, openApiService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'openApiService'>, }: Pick<
IUnleashServices,
| 'transactionalFeatureToggleService'
| 'featureToggleServiceV2'
| 'openApiService'
>,
startTransaction: TransactionCreator<UnleashTransaction>,
) { ) {
super(config); super(config);
this.logger = config.getLogger('/admin-api/archive.js'); this.logger = config.getLogger('/admin-api/archive.js');
this.featureService = featureToggleServiceV2; this.featureService = featureToggleServiceV2;
this.openApiService = openApiService; this.openApiService = openApiService;
this.flagResolver = config.flagResolver; this.flagResolver = config.flagResolver;
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.startTransaction = startTransaction;
this.route({ this.route({
method: 'post', method: 'post',
@ -130,7 +149,13 @@ export default class ProjectArchiveController extends Controller {
const { projectId } = req.params; const { projectId } = req.params;
const { features } = req.body; const { features } = req.body;
const user = extractUsername(req); const user = extractUsername(req);
await this.featureService.reviveFeatures(features, projectId, user); await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).reviveFeatures(
features,
projectId,
user,
),
);
res.status(200).end(); res.status(200).end();
} }

View File

@ -37,7 +37,8 @@ export type IFlagKey =
| 'useLastSeenRefactor' | 'useLastSeenRefactor'
| 'internalMessageBanners' | 'internalMessageBanners'
| 'internalMessageBanner' | 'internalMessageBanner'
| 'separateAdminClientApi'; | 'separateAdminClientApi'
| 'disableEnvsOnRevive';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -173,6 +174,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_SEPARATE_ADMIN_CLIENT_API, process.env.UNLEASH_EXPERIMENTAL_SEPARATE_ADMIN_CLIENT_API,
false, false,
), ),
disableEnvsOnRevive: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_DISABLE_ENVS_ON_REVIVE,
false,
),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -10,13 +10,18 @@ let db: ITestDb;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('archive_test_serial', getLogger); db = await dbInit('archive_test_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, { app = await setupAppWithCustomConfig(
experimental: { db.stores,
flags: { {
strictSchemaValidation: true, experimental: {
flags: {
strictSchemaValidation: true,
disableEnvsOnRevive: true,
},
}, },
}, },
}); db.rawDatabase,
);
}); });
afterAll(async () => { afterAll(async () => {
@ -142,6 +147,56 @@ test('Should be able to revive toggle', async () => {
.expect(200); .expect(200);
}); });
test('Should disable all environments when reviving a toggle', async () => {
await db.stores.featureToggleStore.deleteAll();
await db.stores.featureToggleStore.create('default', {
name: 'feat-proj-1',
archived: true,
});
await db.stores.environmentStore.create({
name: 'development',
enabled: true,
type: 'development',
sortOrder: 1,
});
await db.stores.environmentStore.create({
name: 'production',
enabled: true,
type: 'production',
sortOrder: 2,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
'feat-proj-1',
'default',
true,
);
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
'feat-proj-1',
'production',
true,
);
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
'feat-proj-1',
'development',
true,
);
await app.request
.post('/api/admin/archive/revive/feat-proj-1')
.send({})
.expect(200);
const { body } = await app.request
.get(
'/api/admin/projects/default/features/feat-proj-1?variantEnvironments=true',
)
.expect(200);
expect(body.environments.every((env) => !env.enabled));
});
test('Reviving a non-existing toggle should yield 404', async () => { test('Reviving a non-existing toggle should yield 404', async () => {
await app.request await app.request
.post('/api/admin/archive/revive/non.existing') .post('/api/admin/archive/revive/non.existing')

View File

@ -11,13 +11,18 @@ let db: ITestDb;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('archive_serial', getLogger); db = await dbInit('archive_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, { app = await setupAppWithCustomConfig(
experimental: { db.stores,
flags: { {
strictSchemaValidation: true, experimental: {
flags: {
strictSchemaValidation: true,
disableEnvsOnRevive: true,
},
}, },
}, },
}); db.rawDatabase,
);
await app.createFeature({ await app.createFeature({
name: 'featureX', name: 'featureX',
description: 'the #1 feature', description: 'the #1 feature',
@ -212,9 +217,11 @@ test('can bulk revive features', async () => {
.send({ features }) .send({ features })
.expect(200); .expect(200);
for (const feature of features) { for (const feature of features) {
await app.request const { body } = await app.request
.get(`/api/admin/projects/default/features/${feature}`) .get(`/api/admin/projects/default/features/${feature}`)
.expect(200); .expect(200);
expect(body.environments.every((env) => !env.enabled));
} }
}); });