diff --git a/frontend/src/component/changeRequest/ChangeRequest.test.tsx b/frontend/src/component/changeRequest/ChangeRequest.test.tsx index 736115063b..7c3a2230c7 100644 --- a/frontend/src/component/changeRequest/ChangeRequest.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest.test.tsx @@ -86,6 +86,9 @@ const uiConfigForEnterprise = () => flags: { changeRequests: true, }, + resourceLimits: { + featureEnvironmentStrategies: 10, + }, slogan: 'getunleash.io - All rights reserved', name: 'Unleash enterprise', links: [ diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.test.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.test.tsx index 009db24cdc..e3f39815fb 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.test.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.test.tsx @@ -8,11 +8,16 @@ import type { IFeatureStrategy } from 'interfaces/strategy'; const server = testServerSetup(); +const LIMIT = 3; + const setupApi = () => { testServerRoute(server, '/api/admin/ui-config', { flags: { resourceLimits: true, }, + resourceLimits: { + featureEnvironmentStrategies: LIMIT, + }, }); testServerRoute( @@ -45,7 +50,7 @@ const environmentWithManyStrategies = { name: 'production', enabled: true, type: 'production', - strategies: [...Array(30).keys()].map(() => strategy), + strategies: [...Array(LIMIT).keys()].map(() => strategy), }; test('should allow to add strategy when no strategies', async () => { diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx index 4dada03ff1..a14391e41e 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx @@ -23,6 +23,7 @@ import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureS import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage'; import { Badge } from 'component/common/Badge/Badge'; import { useUiFlag } from 'hooks/useUiFlag'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; interface IFeatureOverviewEnvironmentProps { env: IFeatureEnvironment; @@ -116,6 +117,19 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({ }, })); +const useStrategyLimit = (strategyCount: number) => { + const resourceLimitsEnabled = useUiFlag('resourceLimits'); + const { uiConfig } = useUiConfig(); + const featureEnvironmentStrategiesLimit = + uiConfig.resourceLimits?.featureEnvironmentStrategies || 100; + const limitReached = + resourceLimitsEnabled && + strategyCount >= featureEnvironmentStrategiesLimit; + const limitMessage = `Limit of ${featureEnvironmentStrategiesLimit} strategies reached`; + + return { limitReached, limitMessage }; +}; + const FeatureOverviewEnvironment = ({ env, }: IFeatureOverviewEnvironmentProps) => { @@ -132,11 +146,10 @@ const FeatureOverviewEnvironment = ({ const featureEnvironment = feature?.environments.find( (featureEnvironment) => featureEnvironment.name === env.name, ); - const resourceLimitsEnabled = useUiFlag('resourceLimits'); - const limitReached = - resourceLimitsEnabled && - Array.isArray(featureEnvironment?.strategies) && - featureEnvironment?.strategies.length >= 30; + + const { limitMessage, limitReached } = useStrategyLimit( + featureEnvironment?.strategies.length || 0, + ); return ( @@ -234,7 +247,7 @@ const FeatureOverviewEnvironment = ({ environmentId={env.name} disableReason={ limitReached - ? 'Limit reached' + ? limitMessage : undefined } /> diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx index 51902037e4..22216af732 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx @@ -28,4 +28,15 @@ export const defaultValue: IUiConfig = { }, ], networkViewEnabled: false, + resourceLimits: { + segmentValues: 1000, + strategySegments: 5, + signalEndpoints: 5, + actionSetActions: 10, + actionSetsPerProject: 5, + actionSetFilters: 5, + actionSetFilterValues: 25, + signalTokensPerEndpoint: 5, + featureEnvironmentStrategies: 30, + }, }; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 6a565db72a..f3154381bf 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { Variant } from 'utils/variants'; +import type { ResourceLimitsSchema } from '../openapi'; export interface IUiConfig { authenticationType?: string; @@ -28,6 +29,7 @@ export interface IUiConfig { segmentValuesLimit?: number; strategySegmentsLimit?: number; frontendApiOrigins?: string[]; + resourceLimits: ResourceLimitsSchema; } export interface IProclamationToast { diff --git a/frontend/src/openapi/models/resourceLimitsSchema.ts b/frontend/src/openapi/models/resourceLimitsSchema.ts index 38ea977a12..d709a472dd 100644 --- a/frontend/src/openapi/models/resourceLimitsSchema.ts +++ b/frontend/src/openapi/models/resourceLimitsSchema.ts @@ -24,4 +24,6 @@ export interface ResourceLimitsSchema { signalTokensPerEndpoint: number; /** The maximum number of strategy segments allowed. */ strategySegments: number; + /** The maximum number of feature environment strategies allowed. */ + featureEnvironmentStrategies: number; } diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index f0879a37a5..ed50e35aa9 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -196,6 +196,7 @@ exports[`should create default config 1`] = ` "actionSetFilterValues": 25, "actionSetFilters": 5, "actionSetsPerProject": 5, + "featureEnvironmentStrategies": 30, "segmentValues": 1000, "signalEndpoints": 5, "signalTokensPerEndpoint": 5, diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 2929f048ef..c589442b33 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -649,6 +649,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { process.env.UNLEASH_SIGNAL_TOKENS_PER_ENDPOINT_LIMIT, 5, ), + featureEnvironmentStrategies: parseEnvVarNumber( + process.env.UNLEASH_FEATURE_ENVIRONMENT_STRATEGIES_LIMIT, + 30, + ), }; return { diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 414d369859..874a088f9b 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -60,7 +60,7 @@ export const createFeatureToggleService = ( db: Db, config: IUnleashConfig, ): FeatureToggleService => { - const { getLogger, eventBus, flagResolver } = config; + const { getLogger, eventBus, flagResolver, resourceLimits } = config; const featureStrategiesStore = new FeatureStrategiesStore( db, eventBus, @@ -142,7 +142,7 @@ export const createFeatureToggleService = ( contextFieldStore, strategyStore, }, - { getLogger, flagResolver, eventBus }, + { getLogger, flagResolver, eventBus, resourceLimits }, segmentService, accessService, eventService, @@ -156,7 +156,7 @@ export const createFeatureToggleService = ( }; export const createFakeFeatureToggleService = (config: IUnleashConfig) => { - const { getLogger, flagResolver } = config; + const { getLogger, flagResolver, resourceLimits } = config; const eventStore = new FakeEventStore(); const strategyStore = new FakeStrategiesStore(); const featureStrategiesStore = new FakeFeatureStrategiesStore(); @@ -204,7 +204,12 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { contextFieldStore, strategyStore, }, - { getLogger, flagResolver, eventBus: new EventEmitter() }, + { + getLogger, + flagResolver, + eventBus: new EventEmitter(), + resourceLimits, + }, segmentService, accessService, eventService, diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 473371a73a..9ccfe90105 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -108,6 +108,7 @@ import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events'; import { allSettledWithRejection } from '../../util/allSettledWithRejection'; import type EventEmitter from 'node:events'; import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model-type'; +import type { ResourceLimitsSchema } from '../../openapi'; interface IFeatureContext { featureName: string; @@ -179,6 +180,8 @@ class FeatureToggleService { private eventBus: EventEmitter; + private resourceLimits: ResourceLimitsSchema; + constructor( { featureStrategiesStore, @@ -204,7 +207,11 @@ class FeatureToggleService { getLogger, flagResolver, eventBus, - }: Pick, + resourceLimits, + }: Pick< + IUnleashConfig, + 'getLogger' | 'flagResolver' | 'eventBus' | 'resourceLimits' + >, segmentService: ISegmentService, accessService: AccessService, eventService: EventService, @@ -233,6 +240,7 @@ class FeatureToggleService { this.dependentFeaturesService = dependentFeaturesService; this.featureLifecycleReadModel = featureLifecycleReadModel; this.eventBus = eventBus; + this.resourceLimits = resourceLimits; } async validateFeaturesContext( @@ -647,7 +655,7 @@ class FeatureToggleService { await this.validateStrategyLimit( { featureName, projectId, environment }, - 30, + this.resourceLimits.featureEnvironmentStrategies, ); try { 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 7013a29681..d0ade8abd3 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 @@ -14,10 +14,14 @@ 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 = () => @@ -31,11 +35,11 @@ test('Should not allow to exceed strategy limit', async () => { createdByUserId: 1, }); - for (let i = 0; i < 30; i++) { + for (let i = 0; i < LIMIT; i++) { await addStrategy(); } await expect(addStrategy()).rejects.toThrow( - 'Strategy limit of 30 exceeded', + `Strategy limit of ${LIMIT} exceeded`, ); }); diff --git a/src/lib/openapi/spec/resource-limits-schema.ts b/src/lib/openapi/spec/resource-limits-schema.ts index ea295ed996..cfdc90a406 100644 --- a/src/lib/openapi/spec/resource-limits-schema.ts +++ b/src/lib/openapi/spec/resource-limits-schema.ts @@ -13,6 +13,7 @@ export const resourceLimitsSchema = { 'actionSetFilterValues', 'signalEndpoints', 'signalTokensPerEndpoint', + 'featureEnvironmentStrategies', ], additionalProperties: false, properties: { @@ -61,6 +62,12 @@ export const resourceLimitsSchema = { description: 'The maximum number of signal tokens per endpoint allowed.', }, + featureEnvironmentStrategies: { + type: 'integer', + example: 30, + description: + 'The maximum number of feature environment strategies allowed.', + }, }, components: {}, } as const;