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:
parent
a7dd0d6c1a
commit
a9a75d5e82
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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');
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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(
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 = {
|
||||||
|
@ -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')
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user