1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +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,
"dependentFeatures": false,
"disableBulkToggle": false,
"disableEnvsOnRevive": false,
"disableMetrics": false,
"disableNotifications": false,
"doraMetrics": false,
@ -127,6 +128,7 @@ exports[`should create default config 1`] = `
"demo": false,
"dependentFeatures": false,
"disableBulkToggle": false,
"disableEnvsOnRevive": false,
"disableMetrics": false,
"disableNotifications": false,
"doraMetrics": false,

View File

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

View File

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

View File

@ -575,6 +575,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.where({ name })
.update({ archived_at: null })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
@ -583,9 +584,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.whereIn('name', names)
.update({ archived_at: null })
.returning(FEATURE_COLUMNS);
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[]> {
if (!(await this.exists(featureName))) {
throw new NotFoundError('No feature toggle found');

View File

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

View File

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

View File

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

View File

@ -125,7 +125,14 @@ export default class ProjectApi extends Controller {
this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(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(

View File

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

View File

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

View File

@ -10,13 +10,18 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('archive_test_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
disableEnvsOnRevive: true,
},
},
},
});
db.rawDatabase,
);
});
afterAll(async () => {
@ -142,6 +147,56 @@ test('Should be able to revive toggle', async () => {
.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 () => {
await app.request
.post('/api/admin/archive/revive/non.existing')

View File

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