mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: projects limit (#7514)
This commit is contained in:
		
							parent
							
								
									95cfbe5ccf
								
							
						
					
					
						commit
						8a9535d352
					
				@ -199,6 +199,7 @@ exports[`should create default config 1`] = `
 | 
				
			|||||||
    "constraintValues": 250,
 | 
					    "constraintValues": 250,
 | 
				
			||||||
    "environments": 50,
 | 
					    "environments": 50,
 | 
				
			||||||
    "featureEnvironmentStrategies": 30,
 | 
					    "featureEnvironmentStrategies": 30,
 | 
				
			||||||
 | 
					    "projects": 500,
 | 
				
			||||||
    "segmentValues": 1000,
 | 
					    "segmentValues": 1000,
 | 
				
			||||||
    "signalEndpoints": 5,
 | 
					    "signalEndpoints": 5,
 | 
				
			||||||
    "signalTokensPerEndpoint": 5,
 | 
					    "signalTokensPerEndpoint": 5,
 | 
				
			||||||
 | 
				
			|||||||
@ -661,6 +661,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
 | 
				
			|||||||
            process.env.UNLEASH_ENVIRONMENTS_LIMIT,
 | 
					            process.env.UNLEASH_ENVIRONMENTS_LIMIT,
 | 
				
			||||||
            50,
 | 
					            50,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        projects: parseEnvVarNumber(process.env.UNLEASH_PROJECTS_LIMIT, 500),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										51
									
								
								src/lib/features/project/project-service.limit.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/lib/features/project/project-service.limit.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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.",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@ -70,7 +70,10 @@ import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production
 | 
				
			|||||||
import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
 | 
					import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
 | 
				
			||||||
import { uniqueByKey } from '../../util/unique';
 | 
					import { uniqueByKey } from '../../util/unique';
 | 
				
			||||||
import { BadDataError, PermissionError } from '../../error';
 | 
					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 { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming-validation';
 | 
				
			||||||
import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
 | 
					import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
 | 
				
			||||||
import type EventService from '../events/event-service';
 | 
					import type EventService from '../events/event-service';
 | 
				
			||||||
@ -80,6 +83,7 @@ import type {
 | 
				
			|||||||
    IProjectQuery,
 | 
					    IProjectQuery,
 | 
				
			||||||
} from './project-store-type';
 | 
					} from './project-store-type';
 | 
				
			||||||
import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type';
 | 
					import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type';
 | 
				
			||||||
 | 
					import { ExceedsLimitError } from '../../error/exceeds-limit-error';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Days = number;
 | 
					type Days = number;
 | 
				
			||||||
type Count = number;
 | 
					type Count = number;
 | 
				
			||||||
@ -150,6 +154,8 @@ export default class ProjectService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private isEnterprise: boolean;
 | 
					    private isEnterprise: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private resourceLimits: ResourceLimitsSchema;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    constructor(
 | 
					    constructor(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            projectStore,
 | 
					            projectStore,
 | 
				
			||||||
@ -202,6 +208,7 @@ export default class ProjectService {
 | 
				
			|||||||
        this.logger = config.getLogger('services/project-service.js');
 | 
					        this.logger = config.getLogger('services/project-service.js');
 | 
				
			||||||
        this.flagResolver = config.flagResolver;
 | 
					        this.flagResolver = config.flagResolver;
 | 
				
			||||||
        this.isEnterprise = config.isEnterprise;
 | 
					        this.isEnterprise = config.isEnterprise;
 | 
				
			||||||
 | 
					        this.resourceLimits = config.resourceLimits;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async getProjects(
 | 
					    async getProjects(
 | 
				
			||||||
@ -304,6 +311,18 @@ export default class ProjectService {
 | 
				
			|||||||
            await this.validateEnvironmentsExist(environments);
 | 
					            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<string> {
 | 
					    async generateProjectId(name: string): Promise<string> {
 | 
				
			||||||
        const slug = createSlug(name).slice(0, 90);
 | 
					        const slug = createSlug(name).slice(0, 90);
 | 
				
			||||||
        const generateUniqueId = async (suffix?: number) => {
 | 
					        const generateUniqueId = async (suffix?: number) => {
 | 
				
			||||||
@ -329,6 +348,8 @@ export default class ProjectService {
 | 
				
			|||||||
            return [];
 | 
					            return [];
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    ): Promise<ProjectCreated> {
 | 
					    ): Promise<ProjectCreated> {
 | 
				
			||||||
 | 
					        await this.validateProjectLimit();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const validateData = async () => {
 | 
					        const validateData = async () => {
 | 
				
			||||||
            await this.validateProjectEnvironments(newProject.environments);
 | 
					            await this.validateProjectEnvironments(newProject.environments);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,7 @@ export const resourceLimitsSchema = {
 | 
				
			|||||||
        'featureEnvironmentStrategies',
 | 
					        'featureEnvironmentStrategies',
 | 
				
			||||||
        'constraintValues',
 | 
					        'constraintValues',
 | 
				
			||||||
        'environments',
 | 
					        'environments',
 | 
				
			||||||
 | 
					        'projects',
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    additionalProperties: false,
 | 
					    additionalProperties: false,
 | 
				
			||||||
    properties: {
 | 
					    properties: {
 | 
				
			||||||
@ -82,6 +83,12 @@ export const resourceLimitsSchema = {
 | 
				
			|||||||
            example: 50,
 | 
					            example: 50,
 | 
				
			||||||
            description: 'The maximum number of environments allowed.',
 | 
					            description: 'The maximum number of environments allowed.',
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        projects: {
 | 
				
			||||||
 | 
					            type: 'integer',
 | 
				
			||||||
 | 
					            minimum: 1,
 | 
				
			||||||
 | 
					            example: 500,
 | 
				
			||||||
 | 
					            description: 'The maximum number of projects allowed.',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    components: {},
 | 
					    components: {},
 | 
				
			||||||
} as const;
 | 
					} as const;
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user