diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index f12556a3093d..624da76dae97 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -200,6 +200,7 @@ exports[`should create default config 1`] = ` "constraintValues": 250, "environments": 50, "featureEnvironmentStrategies": 30, + "featureFlags": 5000, "projects": 500, "segmentValues": 1000, "segments": 300, diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 8df4709ca803..6af7cab43a45 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -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 { diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 874a088f9b87..ed5d2fa5fe19 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -219,5 +219,5 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { dependentFeaturesService, featureLifecycleReadModel, ); - return { featureToggleService, featureToggleStore }; + return { featureToggleService, featureToggleStore, projectStore }; }; diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts index cd740bada35e..59c3b37e16ef 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts @@ -77,8 +77,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { throw new Error('Method not implemented.'); } - async count(query: Partial): Promise { - return this.features.filter(this.getFilterQuery(query)).length; + async count( + query: Partial = { archived: false }, + ): Promise { + return this.getAll(query).then((features) => features.length); } async getAllByNames(names: string[]): Promise { @@ -92,16 +94,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { private getFilterQuery(query: Partial) { 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 { - return this.features.filter((f) => !f.archived); + async getAll( + query: Partial = { archived: false }, + ): Promise { + return this.features.filter(this.getFilterQuery(query)); } async getFeatureMetadata(name: string): Promise { diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 20dded06051a..4d51fbf39fe0 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -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) { diff --git a/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts b/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts index 8dcb78ad2740..111692cd3ff8 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggle-service.limit.test.ts @@ -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); - - const addStrategy = () => - featureToggleService.unprotectedCreateStrategy( - { name: 'default', featureName: 'feature' } as IStrategyConfig, - { projectId: 'default', featureName: 'feature' } as any, - {} as IAuditUser, +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, + ); + 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); - - const addStrategyWithConstraints = (constraints: IConstraint[]) => - featureToggleService.unprotectedCreateStrategy( - { - name: 'default', - featureName: 'feature', - constraints, - } as IStrategyConfig, - { projectId: 'default', featureName: 'feature' } as any, +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 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", - ); }); diff --git a/src/lib/openapi/spec/resource-limits-schema.ts b/src/lib/openapi/spec/resource-limits-schema.ts index 2536c548369a..f52281cb7f78 100644 --- a/src/lib/openapi/spec/resource-limits-schema.ts +++ b/src/lib/openapi/spec/resource-limits-schema.ts @@ -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; diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index fc476fed8eb6..ded21301121d 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -141,7 +141,9 @@ export interface IUnleashOptions { metricsRateLimiting?: Partial; dailyMetricsStorageDays?: number; rateLimiting?: Partial; - resourceLimits?: Partial>; + resourceLimits?: Partial< + Pick + >; } export interface IEmailOption {