From 8a9535d352b0c39b42de180ecb15b0aef2c4ab9d Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 2 Jul 2024 12:03:00 +0200 Subject: [PATCH] feat: projects limit (#7514) --- .../__snapshots__/create-config.test.ts.snap | 1 + src/lib/create-config.ts | 1 + .../project/project-service.limit.test.ts | 51 +++++++++++++++++++ src/lib/features/project/project-service.ts | 23 ++++++++- .../openapi/spec/resource-limits-schema.ts | 7 +++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/lib/features/project/project-service.limit.test.ts diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 2784887086..9c9c8e2c7e 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -199,6 +199,7 @@ exports[`should create default config 1`] = ` "constraintValues": 250, "environments": 50, "featureEnvironmentStrategies": 30, + "projects": 500, "segmentValues": 1000, "signalEndpoints": 5, "signalTokensPerEndpoint": 5, diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 0942da29f7..b53f51a70a 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -661,6 +661,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { process.env.UNLEASH_ENVIRONMENTS_LIMIT, 50, ), + projects: parseEnvVarNumber(process.env.UNLEASH_PROJECTS_LIMIT, 500), }; return { diff --git a/src/lib/features/project/project-service.limit.test.ts b/src/lib/features/project/project-service.limit.test.ts new file mode 100644 index 0000000000..974626bc09 --- /dev/null +++ b/src/lib/features/project/project-service.limit.test.ts @@ -0,0 +1,51 @@ +import type { IAuditUser, IFlagResolver, IUnleashConfig } from '../../types'; +import getLogger from '../../../test/fixtures/no-logger'; +import { createFakeProjectService } from './createProjectService'; +import type { IUser } from '../../types'; + +const alwaysOnFlagResolver = { + isEnabled() { + return true; + }, +} as unknown as IFlagResolver; + +test('Should not allow to exceed project limit', async () => { + const LIMIT = 1; + const projectService = createFakeProjectService({ + getLogger, + flagResolver: alwaysOnFlagResolver, + resourceLimits: { + projects: LIMIT, + }, + } as unknown as IUnleashConfig); + + const createProject = (name: string) => + projectService.createProject({ name }, {} as IUser, {} as IAuditUser); + + await createProject('projectA'); + + await expect(() => createProject('projectB')).rejects.toThrow( + "Failed to create project. You can't create more than the established limit of 1.", + ); +}); + +test('Should enforce minimum project limit of 1', async () => { + const INVALID_LIMIT = 0; + const projectService = createFakeProjectService({ + getLogger, + flagResolver: alwaysOnFlagResolver, + resourceLimits: { + projects: INVALID_LIMIT, + }, + } as unknown as IUnleashConfig); + + const createProject = (name: string) => + projectService.createProject({ name }, {} as IUser, {} as IAuditUser); + + // allow to create one project + await createProject('projectA'); + + await expect(() => createProject('projectB')).rejects.toThrow( + "Failed to create project. You can't create more than the established limit of 1.", + ); +}); diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index 2549d9c0f1..c55e2c6d3c 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -70,7 +70,10 @@ import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type'; import { uniqueByKey } from '../../util/unique'; import { BadDataError, PermissionError } from '../../error'; -import type { ProjectDoraMetricsSchema } from '../../openapi'; +import type { + ProjectDoraMetricsSchema, + ResourceLimitsSchema, +} from '../../openapi'; import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming-validation'; import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; import type EventService from '../events/event-service'; @@ -80,6 +83,7 @@ import type { IProjectQuery, } from './project-store-type'; import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type'; +import { ExceedsLimitError } from '../../error/exceeds-limit-error'; type Days = number; type Count = number; @@ -150,6 +154,8 @@ export default class ProjectService { private isEnterprise: boolean; + private resourceLimits: ResourceLimitsSchema; + constructor( { projectStore, @@ -202,6 +208,7 @@ export default class ProjectService { this.logger = config.getLogger('services/project-service.js'); this.flagResolver = config.flagResolver; this.isEnterprise = config.isEnterprise; + this.resourceLimits = config.resourceLimits; } async getProjects( @@ -304,6 +311,18 @@ export default class ProjectService { await this.validateEnvironmentsExist(environments); } } + + async validateProjectLimit() { + if (!this.flagResolver.isEnabled('resourceLimits')) return; + + const limit = Math.max(this.resourceLimits.projects, 1); + const projectCount = await this.projectStore.count(); + + if (projectCount >= limit) { + throw new ExceedsLimitError('project', limit); + } + } + async generateProjectId(name: string): Promise { const slug = createSlug(name).slice(0, 90); const generateUniqueId = async (suffix?: number) => { @@ -329,6 +348,8 @@ export default class ProjectService { return []; }, ): Promise { + await this.validateProjectLimit(); + const validateData = async () => { await this.validateProjectEnvironments(newProject.environments); diff --git a/src/lib/openapi/spec/resource-limits-schema.ts b/src/lib/openapi/spec/resource-limits-schema.ts index 03ff370f9c..6c964d6455 100644 --- a/src/lib/openapi/spec/resource-limits-schema.ts +++ b/src/lib/openapi/spec/resource-limits-schema.ts @@ -16,6 +16,7 @@ export const resourceLimitsSchema = { 'featureEnvironmentStrategies', 'constraintValues', 'environments', + 'projects', ], additionalProperties: false, properties: { @@ -82,6 +83,12 @@ export const resourceLimitsSchema = { example: 50, description: 'The maximum number of environments allowed.', }, + projects: { + type: 'integer', + minimum: 1, + example: 500, + description: 'The maximum number of projects allowed.', + }, }, components: {}, } as const;