1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-02 01:17:58 +02: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, "constraintValues": 250,
"environments": 50, "environments": 50,
"featureEnvironmentStrategies": 30, "featureEnvironmentStrategies": 30,
"featureFlags": 5000,
"projects": 500, "projects": 500,
"segmentValues": 1000, "segmentValues": 1000,
"segments": 300, "segments": 300,

View File

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

View File

@ -219,5 +219,5 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
dependentFeaturesService, dependentFeaturesService,
featureLifecycleReadModel, 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.'); throw new Error('Method not implemented.');
} }
async count(query: Partial<IFeatureToggleStoreQuery>): Promise<number> { async count(
return this.features.filter(this.getFilterQuery(query)).length; query: Partial<IFeatureToggleStoreQuery> = { archived: false },
): Promise<number> {
return this.getAll(query).then((features) => features.length);
} }
async getAllByNames(names: string[]): Promise<FeatureToggle[]> { async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
@ -92,16 +94,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
private getFilterQuery(query: Partial<IFeatureToggleStoreQuery>) { private getFilterQuery(query: Partial<IFeatureToggleStoreQuery>) {
return (f) => { return (f) => {
let projectMatch = true; let projectMatch = true;
if (query.project) { if ('project' in query) {
projectMatch = f.project === query.project; projectMatch = f.project === query.project;
} }
let archiveMatch = true; let archiveMatch = true;
if (query.archived) { if ('archived' in query) {
archiveMatch = f.archived === query.archived; archiveMatch = (f.archived ?? false) === query.archived;
} }
let staleMatch = true; let staleMatch = true;
if (query.stale) { if ('stale' in query) {
staleMatch = f.stale === query.stale; staleMatch = (f.stale ?? false) === query.stale;
} }
return projectMatch && archiveMatch && staleMatch; return projectMatch && archiveMatch && staleMatch;
}; };
@ -141,8 +143,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
throw new NotFoundError(`Could not find feature with name ${key}`); throw new NotFoundError(`Could not find feature with name ${key}`);
} }
async getAll(): Promise<FeatureToggle[]> { async getAll(
return this.features.filter((f) => !f.archived); query: Partial<IFeatureToggleStoreQuery> = { archived: false },
): Promise<FeatureToggle[]> {
return this.features.filter(this.getFilterQuery(query));
} }
async getFeatureMetadata(name: string): Promise<FeatureToggle> { 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( async createFeatureToggle(
projectId: string, projectId: string,
value: FeatureToggleDTO, value: FeatureToggleDTO,
@ -1190,6 +1200,9 @@ class FeatureToggleService {
'You have reached the maximum number of feature flags for this project.', 'You have reached the maximum number of feature flags for this project.',
); );
} }
await this.validateFeatureFlagLimit();
if (exists) { if (exists) {
let featureData: FeatureToggleInsert; let featureData: FeatureToggleInsert;
if (isValidated) { if (isValidated) {

View File

@ -5,6 +5,7 @@ import type {
IFlagResolver, IFlagResolver,
IStrategyConfig, IStrategyConfig,
IUnleashConfig, IUnleashConfig,
IUser,
} from '../../../types'; } from '../../../types';
import getLogger from '../../../../test/fixtures/no-logger'; import getLogger from '../../../../test/fixtures/no-logger';
@ -14,6 +15,7 @@ const alwaysOnFlagResolver = {
}, },
} as unknown as IFlagResolver; } as unknown as IFlagResolver;
describe('Strategy limits', () => {
test('Should not allow to exceed strategy limit', async () => { test('Should not allow to exceed strategy limit', async () => {
const LIMIT = 3; const LIMIT = 3;
const { featureToggleService, featureToggleStore } = const { featureToggleService, featureToggleStore } =
@ -82,3 +84,76 @@ test('Should not allow to exceed constraint values limit', async () => {
"Failed to create content values for userId. You can't create more than the established limit of 3", "Failed to create content values for userId. You can't create more than the established limit of 3",
); );
}); });
});
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);
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 expect(createFlag('should-be-okay')).resolves.toMatchObject({
name: 'should-be-okay',
});
});
});

View File

@ -19,6 +19,7 @@ export const resourceLimitsSchema = {
'projects', 'projects',
'apiTokens', 'apiTokens',
'segments', 'segments',
'featureFlags',
], ],
additionalProperties: false, additionalProperties: false,
properties: { properties: {
@ -103,6 +104,13 @@ export const resourceLimitsSchema = {
example: 300, example: 300,
description: 'The maximum number of segments allowed.', 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: {}, components: {},
} as const; } as const;

View File

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