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:
parent
a7dd0d6c1a
commit
a9a75d5e82
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
|
@ -66,4 +66,6 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
featureName: string,
|
||||
newVariants: IVariant[],
|
||||
): Promise<FeatureToggle>;
|
||||
|
||||
disableAllEnvironmentsForFeatures(names: string[]): Promise<void>;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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')
|
||||
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user