1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Chore: add limits to feature flags (#7536)

This PR adds a feature flag limit to Unleash. It's set up to be
overridden in Enterprise, where we turn the limit up.

I've also fixed a couple bugs in the fake feature flag store.
This commit is contained in:
Thomas Heartman 2024-07-04 11:00:11 +02:00 committed by GitHub
parent c93bfafb7f
commit 59d6014853
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 177 additions and 67 deletions

View File

@ -201,6 +201,7 @@ exports[`should create default config 1`] = `
"constraintValues": 250,
"environments": 50,
"featureEnvironmentStrategies": 30,
"featureFlags": 5000,
"projects": 500,
"segmentValues": 1000,
"segments": 300,

View File

@ -679,6 +679,13 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
0,
parseEnvVarNumber(process.env.UNLEASH_SEGMENTS_LIMIT, 300),
),
featureFlags: Math.max(
1,
parseEnvVarNumber(
process.env.UNLEASH_FEATURE_FLAGS_LIMIT,
options?.resourceLimits?.featureFlags || 5000,
),
),
};
return {

View File

@ -219,5 +219,5 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
dependentFeaturesService,
featureLifecycleReadModel,
);
return { featureToggleService, featureToggleStore };
return { featureToggleService, featureToggleStore, projectStore };
};

View File

@ -77,8 +77,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new Error('Method not implemented.');
}
async count(query: Partial<IFeatureToggleStoreQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length;
async count(
query: Partial<IFeatureToggleStoreQuery> = { archived: false },
): Promise<number> {
return this.getAll(query).then((features) => features.length);
}
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
@ -92,16 +94,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
private getFilterQuery(query: Partial<IFeatureToggleStoreQuery>) {
return (f) => {
let projectMatch = true;
if (query.project) {
if ('project' in query) {
projectMatch = f.project === query.project;
}
let archiveMatch = true;
if (query.archived) {
archiveMatch = f.archived === query.archived;
if ('archived' in query) {
archiveMatch = (f.archived ?? false) === query.archived;
}
let staleMatch = true;
if (query.stale) {
staleMatch = f.stale === query.stale;
if ('stale' in query) {
staleMatch = (f.stale ?? false) === query.stale;
}
return projectMatch && archiveMatch && staleMatch;
};
@ -141,8 +143,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new NotFoundError(`Could not find feature with name ${key}`);
}
async getAll(): Promise<FeatureToggle[]> {
return this.features.filter((f) => !f.archived);
async getAll(
query: Partial<IFeatureToggleStoreQuery> = { archived: false },
): Promise<FeatureToggle[]> {
return this.features.filter(this.getFilterQuery(query));
}
async getFeatureMetadata(name: string): Promise<FeatureToggle> {

View File

@ -1171,6 +1171,16 @@ class FeatureToggleService {
);
}
private async validateFeatureFlagLimit() {
if (this.flagResolver.isEnabled('resourceLimits')) {
const currentFlagCount = await this.featureToggleStore.count();
const limit = this.resourceLimits.featureFlags;
if (currentFlagCount >= limit) {
throw new ExceedsLimitError('feature flag', limit);
}
}
}
async createFeatureToggle(
projectId: string,
value: FeatureToggleDTO,
@ -1190,6 +1200,9 @@ class FeatureToggleService {
'You have reached the maximum number of feature flags for this project.',
);
}
await this.validateFeatureFlagLimit();
if (exists) {
let featureData: FeatureToggleInsert;
if (isValidated) {

View File

@ -5,6 +5,7 @@ import type {
IFlagResolver,
IStrategyConfig,
IUnleashConfig,
IUser,
} from '../../../types';
import getLogger from '../../../../test/fixtures/no-logger';
@ -14,71 +15,145 @@ const alwaysOnFlagResolver = {
},
} as unknown as IFlagResolver;
test('Should not allow to exceed strategy limit', async () => {
const LIMIT = 3;
const { featureToggleService, featureToggleStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
featureEnvironmentStrategies: LIMIT,
},
} as unknown as IUnleashConfig);
describe('Strategy limits', () => {
test('Should not allow to exceed strategy limit', async () => {
const LIMIT = 3;
const { featureToggleService, featureToggleStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
featureEnvironmentStrategies: LIMIT,
},
} as unknown as IUnleashConfig);
const addStrategy = () =>
featureToggleService.unprotectedCreateStrategy(
{ name: 'default', featureName: 'feature' } as IStrategyConfig,
{ projectId: 'default', featureName: 'feature' } as any,
{} as IAuditUser,
const addStrategy = () =>
featureToggleService.unprotectedCreateStrategy(
{ name: 'default', featureName: 'feature' } as IStrategyConfig,
{ projectId: 'default', featureName: 'feature' } as any,
{} as IAuditUser,
);
await featureToggleStore.create('default', {
name: 'feature',
createdByUserId: 1,
});
for (let i = 0; i < LIMIT; i++) {
await addStrategy();
}
await expect(addStrategy()).rejects.toThrow(
"Failed to create strategy. You can't create more than the established limit of 3",
);
await featureToggleStore.create('default', {
name: 'feature',
createdByUserId: 1,
});
for (let i = 0; i < LIMIT; i++) {
await addStrategy();
}
test('Should not allow to exceed constraint values limit', async () => {
const LIMIT = 3;
const { featureToggleService, featureToggleStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
constraintValues: LIMIT,
},
} as unknown as IUnleashConfig);
await expect(addStrategy()).rejects.toThrow(
"Failed to create strategy. You can't create more than the established limit of 3",
);
const addStrategyWithConstraints = (constraints: IConstraint[]) =>
featureToggleService.unprotectedCreateStrategy(
{
name: 'default',
featureName: 'feature',
constraints,
} as IStrategyConfig,
{ projectId: 'default', featureName: 'feature' } as any,
{} as IAuditUser,
);
await featureToggleStore.create('default', {
name: 'feature',
createdByUserId: 1,
});
await expect(() =>
addStrategyWithConstraints([
{
contextName: 'userId',
operator: 'IN',
values: ['1', '2', '3', '4'],
},
]),
).rejects.toThrow(
"Failed to create content values for userId. You can't create more than the established limit of 3",
);
});
});
test('Should not allow to exceed constraint values limit', async () => {
const LIMIT = 3;
const { featureToggleService, featureToggleStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
constraintValues: LIMIT,
},
} as unknown as IUnleashConfig);
describe('Flag limits', () => {
test('Should not allow you to exceed the flag limit', async () => {
const LIMIT = 3;
const { featureToggleService, projectStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
featureFlags: LIMIT,
},
} as unknown as IUnleashConfig);
const addStrategyWithConstraints = (constraints: IConstraint[]) =>
featureToggleService.unprotectedCreateStrategy(
{
name: 'default',
featureName: 'feature',
constraints,
} as IStrategyConfig,
{ projectId: 'default', featureName: 'feature' } as any,
await projectStore.create({
name: 'default',
description: 'default',
id: 'default',
});
const createFlag = (name: string) =>
featureToggleService.createFeatureToggle(
'default',
{ name },
{} as IAuditUser,
);
for (let i = 0; i < LIMIT; i++) {
await createFlag(`feature-${i}`);
}
await expect(createFlag('excessive')).rejects.toThrow(
"Failed to create feature flag. You can't create more than the established limit of 3",
);
});
test('Archived flags do not count towards the total', async () => {
const LIMIT = 1;
const { featureToggleService, projectStore } =
createFakeFeatureToggleService({
getLogger,
flagResolver: alwaysOnFlagResolver,
resourceLimits: {
featureFlags: LIMIT,
},
} as unknown as IUnleashConfig);
await projectStore.create({
name: 'default',
description: 'default',
id: 'default',
});
const createFlag = (name: string) =>
featureToggleService.createFeatureToggle(
'default',
{ name },
{} as IAuditUser,
);
await createFlag('to-be-archived');
await featureToggleService.archiveToggle(
'to-be-archived',
{} as IUser,
{} as IAuditUser,
);
await featureToggleStore.create('default', {
name: 'feature',
createdByUserId: 1,
await expect(createFlag('should-be-okay')).resolves.toMatchObject({
name: 'should-be-okay',
});
});
await expect(() =>
addStrategyWithConstraints([
{
contextName: 'userId',
operator: 'IN',
values: ['1', '2', '3', '4'],
},
]),
).rejects.toThrow(
"Failed to create content values for userId. You can't create more than the established limit of 3",
);
});

View File

@ -19,6 +19,7 @@ export const resourceLimitsSchema = {
'projects',
'apiTokens',
'segments',
'featureFlags',
],
additionalProperties: false,
properties: {
@ -103,6 +104,13 @@ export const resourceLimitsSchema = {
example: 300,
description: 'The maximum number of segments allowed.',
},
featureFlags: {
type: 'integer',
minimum: 1,
example: 5000,
description:
'The maximum number of feature flags you can have at the same time. Archived flags do not count towards this limit.',
},
},
components: {},
} as const;

View File

@ -141,7 +141,9 @@ export interface IUnleashOptions {
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
dailyMetricsStorageDays?: number;
rateLimiting?: Partial<IRateLimiting>;
resourceLimits?: Partial<Pick<ResourceLimitsSchema, 'constraintValues'>>;
resourceLimits?: Partial<
Pick<ResourceLimitsSchema, 'constraintValues' | 'featureFlags'>
>;
}
export interface IEmailOption {