UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

1,012 lines • 61.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const types_1 = require("../../types"); const error_1 = require("../../error"); const bad_data_error_1 = __importDefault(require("../../error/bad-data-error")); const name_exists_error_1 = __importDefault(require("../../error/name-exists-error")); const invalid_operation_error_1 = __importDefault(require("../../error/invalid-operation-error")); const feature_schema_1 = require("../../schema/feature-schema"); const notfound_error_1 = __importDefault(require("../../error/notfound-error")); const util_1 = require("../../util"); const fast_json_patch_1 = require("fast-json-patch"); const constraint_types_1 = require("../../util/validators/constraint-types"); const helpers_1 = require("../playground/feature-evaluator/helpers"); const unique_1 = require("../../util/unique"); const feature_naming_validation_1 = require("../feature-naming-pattern/feature-naming-validation"); const archivedfeature_error_1 = __importDefault(require("../../error/archivedfeature-error")); const metric_events_1 = require("../../metric-events"); const allSettledWithRejection_1 = require("../../util/allSettledWithRejection"); const exceeds_limit_error_1 = require("../../error/exceeds-limit-error"); const sortStrategies_1 = require("../../util/sortStrategies"); const oneOf = (values, match) => { return values.some((value) => value === match); }; class FeatureToggleService { constructor({ featureStrategiesStore, featureToggleStore, clientFeatureToggleStore, projectStore, featureTagStore, featureEnvironmentStore, contextFieldStore, strategyStore, }, { getLogger, flagResolver, eventBus, resourceLimits, }, segmentService, accessService, eventService, changeRequestAccessReadModel, privateProjectChecker, dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel) { this.logger = getLogger('services/feature-toggle-service.ts'); this.featureStrategiesStore = featureStrategiesStore; this.strategyStore = strategyStore; this.featureToggleStore = featureToggleStore; this.clientFeatureToggleStore = clientFeatureToggleStore; this.tagStore = featureTagStore; this.projectStore = projectStore; this.featureEnvironmentStore = featureEnvironmentStore; this.contextFieldStore = contextFieldStore; this.segmentService = segmentService; this.accessService = accessService; this.eventService = eventService; this.flagResolver = flagResolver; this.changeRequestAccessReadModel = changeRequestAccessReadModel; this.privateProjectChecker = privateProjectChecker; this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesService = dependentFeaturesService; this.featureLifecycleReadModel = featureLifecycleReadModel; this.featureCollaboratorsReadModel = featureCollaboratorsReadModel; this.eventBus = eventBus; this.resourceLimits = resourceLimits; } async validateFeaturesContext(featureNames, projectId) { const features = await this.featureToggleStore.getAllByNames(featureNames); const invalidProjects = (0, unique_1.unique)(features .map((feature) => feature.project) .filter((project) => project !== projectId)); if (invalidProjects.length > 0) { throw new invalid_operation_error_1.default(`The operation could not be completed. The features exist, but the provided project ids ("${invalidProjects.join(',')}") does not match the project provided in request URL ("${projectId}").`); } } async validateFeatureBelongsToProject({ featureName, projectId, }) { const id = await this.featureToggleStore.getProjectId(featureName); if (id !== projectId) { throw new notfound_error_1.default(`There's no feature named "${featureName}" in project "${projectId}"${id === undefined ? '.' : `, but there's a feature with that name in project "${id}"`}`); } } async validateFeatureIsNotArchived(featureName, project) { const toggle = await this.featureToggleStore.get(featureName); if (toggle === undefined) { throw new notfound_error_1.default(`Could not find feature ${featureName}`); } if (toggle.archived || Boolean(toggle.archivedAt)) { throw new archivedfeature_error_1.default(); } } async validateNoChildren(featureName) { const children = await this.dependentFeaturesReadModel.getChildren([ featureName, ]); if (children.length > 0) { throw new invalid_operation_error_1.default('You can not archive/delete this feature since other features depend on it.'); } } async validateNoOrphanParents(featureNames) { if (featureNames.length === 0) return; const parents = await this.dependentFeaturesReadModel.getOrphanParents(featureNames); if (parents.length > 0) { throw new invalid_operation_error_1.default(featureNames.length > 1 ? `You can not archive/delete those features since other features depend on them.` : `You can not archive/delete this feature since other features depend on it.`); } } validateUpdatedProperties({ featureName, projectId }, existingStrategy) { if (existingStrategy.projectId !== projectId) { throw new invalid_operation_error_1.default('You can not change the projectId for an activation strategy.'); } if (existingStrategy.featureName !== featureName) { throw new invalid_operation_error_1.default('You can not change the featureName for an activation strategy.'); } if (existingStrategy.parameters && 'stickiness' in existingStrategy.parameters && existingStrategy.parameters.stickiness === '') { throw new invalid_operation_error_1.default('You can not have an empty string for stickiness.'); } } async validateProjectCanAccessSegments(projectId, segmentIds) { if (segmentIds && segmentIds.length > 0) { await Promise.all(segmentIds.map((segmentId) => this.segmentService.get(segmentId))).then((segments) => segments.map((segment) => { if (segment.project && segment.project !== projectId) { throw new bad_data_error_1.default(`The segment "${segment.name}" with id ${segment.id} does not belong to project "${projectId}".`); } })); } } async validateStrategyLimit(featureEnv) { const limit = this.resourceLimits.featureEnvironmentStrategies; const existingCount = (await this.featureStrategiesStore.getStrategiesForFeatureEnv(featureEnv.projectId, featureEnv.featureName, featureEnv.environment)).length; if (existingCount >= limit) { (0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, { resource: 'strategy', limit, }); } } validateConstraintsLimit(constraints) { const { constraints: constraintsLimit, constraintValues: constraintValuesLimit, } = this.resourceLimits; if (constraints.updated.length > constraintsLimit && constraints.updated.length > constraints.existing.length) { (0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, { resource: 'constraints', limit: constraintsLimit, }); } const isSameLength = constraints.existing.length === constraints.updated.length; const constraintOverLimit = constraints.updated.find((constraint, i) => { const updatedCount = constraint.values?.length ?? 0; const existingCount = constraints.existing[i]?.values?.length ?? 0; const isOverLimit = Array.isArray(constraint.values) && updatedCount > constraintValuesLimit; const allowAnyway = isSameLength && existingCount >= updatedCount; return isOverLimit && !allowAnyway; }); if (constraintOverLimit) { (0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, { resource: `constraint values for ${constraintOverLimit.contextName}`, limit: constraintValuesLimit, resourceNameOverride: 'constraint values', }); } } async validateStrategyType(strategyName) { if (strategyName !== undefined) { const exists = await this.strategyStore.exists(strategyName); if (!exists) { throw new bad_data_error_1.default(`Could not find strategy type with name ${strategyName}`); } } } async validateConstraints(constraints) { const validations = constraints.map((constraint) => { return this.validateConstraint(constraint); }); return Promise.all(validations); } async validateConstraint(input) { const constraint = await feature_schema_1.constraintSchema.validateAsync(input); const { operator } = constraint; const contextDefinition = await this.contextFieldStore.get(constraint.contextName); if (oneOf(util_1.NUM_OPERATORS, operator)) { await (0, constraint_types_1.validateNumber)(constraint.value); } if (oneOf(util_1.STRING_OPERATORS, operator)) { await (0, constraint_types_1.validateString)(constraint.values); } if (oneOf(util_1.SEMVER_OPERATORS, operator)) { // Semver library is not asynchronous, so we do not // need to await here. (0, constraint_types_1.validateSemver)(constraint.value); } if (oneOf(util_1.DATE_OPERATORS, operator)) { await (0, constraint_types_1.validateDate)(constraint.value); } if (contextDefinition?.legalValues && contextDefinition.legalValues.length > 0) { const valuesToValidate = oneOf([...util_1.DATE_OPERATORS, ...util_1.SEMVER_OPERATORS, ...util_1.NUM_OPERATORS], operator) ? constraint.value : constraint.values; (0, constraint_types_1.validateLegalValues)(contextDefinition.legalValues, valuesToValidate); } return constraint; } async patchFeature(project, featureName, operations, auditUser) { const featureToggle = await this.getFeatureMetadata(featureName); if (operations.some((op) => op.path.indexOf('/variants') >= 0)) { throw new error_1.OperationDeniedError(`Changing variants is done via PATCH operation to /api/admin/projects/:project/features/:feature/variants`); } const { newDocument } = (0, fast_json_patch_1.applyPatch)((0, fast_json_patch_1.deepClone)(featureToggle), operations); const updated = await this.updateFeatureToggle(project, newDocument, featureName, auditUser); if (featureToggle.stale !== newDocument.stale) { await this.eventService.storeEvent(new types_1.FeatureStaleEvent({ stale: newDocument.stale, project, featureName, auditUser, })); } return updated; } featureStrategyToPublic(featureStrategy, segments = []) { const result = { id: featureStrategy.id, name: featureStrategy.strategyName, title: featureStrategy.title, disabled: featureStrategy.disabled, constraints: featureStrategy.constraints || [], parameters: featureStrategy.parameters, variants: featureStrategy.variants || [], sortOrder: featureStrategy.sortOrder, segments: segments.map((segment) => segment.id) ?? [], }; return result; } async updateStrategiesSortOrder(context, sortOrders, auditUser, user) { await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user); return this.unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser); } async unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser) { const { featureName, environment, projectId: project } = context; const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment)) .sort(sortStrategies_1.sortStrategies) .map((strategy) => strategy.id); const eventPreData = { strategyIds: existingOrder }; await Promise.all(sortOrders.map(({ id, sortOrder }) => this.featureStrategiesStore.updateSortOrder(id, sortOrder))); const newOrder = (await this.getStrategiesForEnvironment(project, featureName, environment)) .sort(sortStrategies_1.sortStrategies) .map((strategy) => strategy.id); const eventData = { strategyIds: newOrder }; const event = new types_1.StrategiesOrderChangedEvent({ featureName, environment, project, preData: eventPreData, data: eventData, auditUser, }); await this.eventService.storeEvent(event); } async createStrategy(strategyConfig, context, auditUser, user) { await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user); return this.unprotectedCreateStrategy(strategyConfig, context, auditUser); } async unprotectedCreateStrategy(strategyConfig, context, auditUser) { const { featureName, projectId, environment } = context; await this.validateFeatureBelongsToProject(context); await this.validateStrategyType(strategyConfig.name); await this.validateProjectCanAccessSegments(projectId, strategyConfig.segments); if (strategyConfig.constraints && strategyConfig.constraints.length > 0) { this.validateConstraintsLimit({ updated: strategyConfig.constraints, existing: [], }); strategyConfig.constraints = await this.validateConstraints(strategyConfig.constraints); } if (strategyConfig.parameters && 'stickiness' in strategyConfig.parameters && strategyConfig.parameters.stickiness === '') { strategyConfig.parameters.stickiness = 'default'; } if (strategyConfig.variants && strategyConfig.variants.length > 0) { await feature_schema_1.variantsArraySchema.validateAsync(strategyConfig.variants); const fixedVariants = this.fixVariantWeights(strategyConfig.variants); strategyConfig.variants = fixedVariants; } await this.validateStrategyLimit({ featureName, projectId, environment, }); try { const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({ strategyName: strategyConfig.name, title: strategyConfig.title, disabled: strategyConfig.disabled, constraints: strategyConfig.constraints || [], variants: strategyConfig.variants || [], parameters: strategyConfig.parameters || {}, sortOrder: strategyConfig.sortOrder, projectId, featureName, environment, }); if (strategyConfig.segments && Array.isArray(strategyConfig.segments)) { await this.segmentService.updateStrategySegments(newFeatureStrategy.id, strategyConfig.segments); } const segments = await this.segmentService.getByStrategy(newFeatureStrategy.id); const strategy = this.featureStrategyToPublic(newFeatureStrategy, segments); await this.eventService.storeEvent(new types_1.FeatureStrategyAddEvent({ project: projectId, featureName, environment, data: strategy, auditUser, })); return strategy; } catch (e) { if (e.code === error_1.FOREIGN_KEY_VIOLATION) { throw new bad_data_error_1.default('You have not added the current environment to the project'); } throw e; } } /** * PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ? * { * * } * @param id * @param updates * @param context - Which context does this strategy live in (projectId, featureName, environment) * @param auditUser - Audit info about the user performing the update * @param user - Optional User object performing the action */ async updateStrategy(id, updates, context, auditUser, user) { await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user); return this.unprotectedUpdateStrategy(id, updates, context, auditUser, user); } async optionallyDisableFeature(featureName, environment, projectId, auditUser, user) { if (this.flagResolver.isEnabled('simplifyDisableFeature')) { const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(projectId, featureName, environment); const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled); if (hasOnlyDisabledStrategies) { await this.unprotectedUpdateEnabled(projectId, featureName, environment, false, auditUser, user); } return; } const feature = await this.getFeature({ featureName }); const env = feature.environments.find((e) => e.name === environment); const hasOnlyDisabledStrategies = env?.strategies.every((strategy) => strategy.disabled); if (hasOnlyDisabledStrategies) { await this.unprotectedUpdateEnabled(projectId, featureName, environment, false, auditUser, user); } } async unprotectedUpdateStrategy(id, updates, context, auditUser, user) { const { projectId, environment, featureName } = context; const existingStrategy = await this.featureStrategiesStore.get(id); if (existingStrategy === undefined) { throw new notfound_error_1.default(`Could not find strategy with id ${id}`); } this.validateUpdatedProperties(context, existingStrategy); await this.validateStrategyType(updates.name); await this.validateProjectCanAccessSegments(projectId, updates.segments); const existingSegments = await this.segmentService.getByStrategy(id); if (existingStrategy.id === id) { if (updates.constraints && updates.constraints.length > 0) { this.validateConstraintsLimit({ updated: updates.constraints, existing: existingStrategy.constraints, }); updates.constraints = await this.validateConstraints(updates.constraints); } if (updates.variants && updates.variants.length > 0) { await feature_schema_1.variantsArraySchema.validateAsync(updates.variants); const fixedVariants = this.fixVariantWeights(updates.variants); updates.variants = fixedVariants; } const strategy = await this.featureStrategiesStore.updateStrategy(id, updates); if (updates.segments && Array.isArray(updates.segments)) { await this.segmentService.updateStrategySegments(strategy.id, updates.segments); } const segments = await this.segmentService.getByStrategy(strategy.id); // Store event! const data = this.featureStrategyToPublic(strategy, segments); const preData = this.featureStrategyToPublic(existingStrategy, existingSegments); await this.eventService.storeEvent(new types_1.FeatureStrategyUpdateEvent({ project: projectId, featureName, environment, data, preData, auditUser, })); await this.optionallyDisableFeature(featureName, environment, projectId, auditUser, user); return data; } throw new notfound_error_1.default(`Could not find strategy with id ${id}`); } async updateStrategyParameter(id, name, value, context, auditUser) { const { projectId, environment, featureName } = context; const existingStrategy = await this.featureStrategiesStore.get(id); if (existingStrategy === undefined) { throw new notfound_error_1.default(`Could not find strategy with id ${id}`); } this.validateUpdatedProperties(context, existingStrategy); if (existingStrategy.id === id) { existingStrategy.parameters[name] = String(value); const existingSegments = await this.segmentService.getByStrategy(id); const strategy = await this.featureStrategiesStore.updateStrategy(id, existingStrategy); const segments = await this.segmentService.getByStrategy(strategy.id); const data = this.featureStrategyToPublic(strategy, segments); const preData = this.featureStrategyToPublic(existingStrategy, existingSegments); await this.eventService.storeEvent(new types_1.FeatureStrategyUpdateEvent({ featureName, project: projectId, environment, data, preData, auditUser, })); return data; } throw new notfound_error_1.default(`Could not find strategy with id ${id}`); } /** * DELETE /api/admin/projects/:projectId/features/:featureName/environments/:environmentName/strategies/:strategyId * { * * } * @param id - strategy id * @param context - Which context does this strategy live in (projectId, featureName, environment) * @param auditUser - Audit information about user performing the action (userid, username, ip) * @param user */ async deleteStrategy(id, context, auditUser, user) { await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user); return this.unprotectedDeleteStrategy(id, context, auditUser); } async unprotectedDeleteStrategy(id, context, auditUser) { const existingStrategy = await this.featureStrategiesStore.get(id); if (!existingStrategy) { // If the strategy doesn't exist, do nothing. return; } const { featureName, projectId, environment } = context; this.validateUpdatedProperties(context, existingStrategy); await this.featureStrategiesStore.delete(id); // Disable the feature in the environment if it only has disabled strategies await this.optionallyDisableFeature(featureName, environment, projectId, auditUser); const preData = this.featureStrategyToPublic(existingStrategy); await this.eventService.storeEvent(new types_1.FeatureStrategyRemoveEvent({ featureName, project: projectId, environment, auditUser, preData, })); // If there are no strategies left for environment disable it await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(featureName, environment); } async getStrategiesForEnvironment(project, featureName, environment = util_1.DEFAULT_ENV) { this.logger.debug('getStrategiesForEnvironment'); const hasEnv = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName); if (hasEnv) { const featureStrategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment); const result = []; for (const strat of featureStrategies) { const segments = (await this.segmentService.getByStrategy(strat.id)).map((segment) => segment.id) ?? []; result.push({ id: strat.id, name: strat.strategyName, constraints: strat.constraints, parameters: strat.parameters, variants: strat.variants, title: strat.title, disabled: strat.disabled, sortOrder: strat.sortOrder, milestoneId: strat.milestoneId, segments, }); } return result; } throw new notfound_error_1.default(`Feature ${featureName} does not have environment ${environment}`); } /** * GET /api/admin/projects/:project/features/:featureName * @param featureName * @param archived - return archived or non archived toggles * @param projectId - provide if you're requesting the feature in the context of a specific project. * @param userId */ async getFeature({ featureName, archived, projectId, environmentVariants, userId, }) { if (projectId) { await this.validateFeatureBelongsToProject({ featureName, projectId, }); } let dependencies = []; let children = []; let lifecycle = undefined; let collaborators = []; [dependencies, children, lifecycle, collaborators] = await Promise.all([ this.dependentFeaturesReadModel.getParents(featureName), this.dependentFeaturesReadModel.getChildren([featureName]), this.featureLifecycleReadModel.findCurrentStage(featureName), this.featureCollaboratorsReadModel.getFeatureCollaborators(featureName), ]); if (environmentVariants) { const result = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName, userId, archived); return { ...result, dependencies, children, lifecycle, collaborators: { users: collaborators }, }; } else { const result = await this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName, userId, archived); return { ...result, dependencies, children, lifecycle, collaborators: { users: collaborators }, }; } } /** * GET /api/admin/projects/:project/features/:featureName/variants * @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments) * @param featureName * @return The list of variants */ async getVariants(featureName) { return this.featureToggleStore.getVariants(featureName); } async getVariantsForEnv(featureName, environment) { const featureEnvironment = await this.featureEnvironmentStore.get({ featureName, environment, }); return featureEnvironment?.variants || []; } async getFeatureMetadata(featureName) { const metaData = await this.featureToggleStore.get(featureName); if (metaData === undefined) { throw new notfound_error_1.default(`Could find metadata for feature with name ${featureName}`); } return metaData; } async getClientFeatures(query) { const result = await this.clientFeatureToggleStore.getFrontendApiClient(query || {}); return result.map(({ name, type, enabled, project, stale, strategies, variants, description, impressionData, dependencies, }) => ({ name, type, enabled, project, stale: stale || false, strategies, variants, description, impressionData, dependencies, })); } async getPlaygroundFeatures(query) { const features = await this.featureToggleStore.getPlaygroundFeatures(query); return features; } async getFeatureOverview(params) { return this.featureStrategiesStore.getFeatureOverview(params); } async getFeatureTypeCounts(params) { return this.featureToggleStore.getFeatureTypeCounts(params); } async getFeatureToggle(featureName) { return this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName); } async validateFeatureFlagLimit() { const currentFlagCount = await this.featureToggleStore.count(); const limit = this.resourceLimits.featureFlags; if (currentFlagCount >= limit) { (0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, { resource: 'feature flag', limit, }); } } async validateActiveProject(projectId) { const hasActiveProject = await this.projectStore.hasActiveProject(projectId); if (!hasActiveProject) { throw new notfound_error_1.default(`Active project with id ${projectId} does not exist`); } } async createFeatureToggle(projectId, value, auditUser, isValidated = false) { this.logger.info(`${auditUser.username} creates feature flag ${value.name}`); await this.validateName(value.name); await this.validateFeatureFlagNameAgainstPattern(value.name, projectId); const projectExists = await this.projectStore.hasActiveProject(projectId); if (await this.projectStore.isFeatureLimitReached(projectId)) { throw new invalid_operation_error_1.default('You have reached the maximum number of feature flags for this project.'); } await this.validateFeatureFlagLimit(); if (projectExists) { let featureData; if (isValidated) { featureData = { createdByUserId: auditUser.id, ...value }; } else { const validated = await feature_schema_1.featureMetadataSchema.validateAsync(value); featureData = { createdByUserId: auditUser.id, ...validated, }; } const featureName = featureData.name; const createdToggle = await this.featureToggleStore.create(projectId, featureData); await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(featureName, projectId); if (value.variants && value.variants.length > 0) { const environments = await this.featureEnvironmentStore.getEnvironmentsForFeature(featureName); await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(featureName, environments.map((env) => env.environment), value.variants); } if (value.tags && value.tags.length > 0) { const mapTagsToFeatureTagInserts = value.tags.map((tag) => ({ tagValue: tag.value, tagType: tag.type, createdByUserId: auditUser.id, featureName: featureName, })); await this.tagStore.tagFeatures(mapTagsToFeatureTagInserts); } await this.eventService.storeEvent(new types_1.FeatureCreatedEvent({ featureName, project: projectId, data: createdToggle, auditUser, })); return createdToggle; } throw new notfound_error_1.default(`Active project with id ${projectId} does not exist`); } async checkFeatureFlagNamesAgainstProjectPattern(projectId, featureNames) { try { const project = await this.projectStore.get(projectId); if (project === undefined) { throw new notfound_error_1.default(`Could not find project with id: ${projectId}`); } const patternData = project.featureNaming; const namingPattern = patternData?.pattern; if (namingPattern) { const result = (0, feature_naming_validation_1.checkFeatureFlagNamesAgainstPattern)(featureNames, namingPattern); if (result.state === 'invalid') { return { ...result, featureNaming: patternData, }; } } } catch (error) { // the project doesn't exist, so there's nothing to // validate against this.logger.info("Got an error when trying to validate flag naming patterns. It is probably because the target project doesn't exist. Here's the error:", error.message); return { state: 'valid' }; } return { state: 'valid' }; } async validateFeatureFlagNameAgainstPattern(featureName, projectId) { if (projectId) { const result = await this.checkFeatureFlagNamesAgainstProjectPattern(projectId, [featureName]); if (result.state === 'invalid') { const namingPattern = result.featureNaming.pattern; const namingExample = result.featureNaming.example; const namingDescription = result.featureNaming.description; const error = `The feature flag name "${featureName}" does not match the project's naming pattern: "${namingPattern}".`; const example = namingExample ? ` Here's an example of a name that does match the pattern: "${namingExample}"."` : ''; const description = namingDescription ? ` The pattern's description is: "${namingDescription}"` : ''; throw new error_1.PatternError(`${error}${example}${description}`, [ `The flag name does not match the pattern.`, ]); } } } async cloneFeatureToggle(featureName, projectId, newFeatureName, auditUser, replaceGroupId = true) { const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(projectId); if (changeRequestEnabled) { throw new error_1.ForbiddenError(`Cloning not allowed. Project ${projectId} has change requests enabled.`); } this.logger.info(`${auditUser.username} clones feature flag ${featureName} to ${newFeatureName}`); await this.validateName(newFeatureName); const cToggle = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName); const newToggle = { ...cToggle, name: newFeatureName, variants: undefined, createdAt: undefined, }; const created = await this.createFeatureToggle(projectId, newToggle, auditUser); const variantTasks = newToggle.environments.map((e) => { return this.featureEnvironmentStore.addVariantsToFeatureEnvironment(newToggle.name, e.name, e.variants); }); const strategyTasks = newToggle.environments.flatMap((e) => e.strategies.map((s) => { if (replaceGroupId && s.parameters && s.parameters.hasOwnProperty('groupId')) { s.parameters.groupId = newFeatureName; } const context = { projectId, featureName: newFeatureName, environment: e.name, }; return this.createStrategy(s, context, auditUser); })); const cloneDependencies = this.dependentFeaturesService.cloneDependencies({ featureName, newFeatureName, projectId, }, auditUser); await Promise.all([ ...strategyTasks, ...variantTasks, cloneDependencies, ]); return created; } async updateFeatureToggle(projectId, updatedFeature, featureName, auditUser) { await this.validateFeatureBelongsToProject({ featureName, projectId, }); this.logger.info(`${auditUser.username} updates feature flag ${featureName}`); const featureData = await feature_schema_1.featureMetadataSchema.validateAsync(updatedFeature); const preData = await this.featureToggleStore.get(featureName); const featureToggle = await this.featureToggleStore.update(projectId, { ...featureData, name: featureName, }); if (preData === undefined) { throw new notfound_error_1.default(`Could find feature toggle with name ${featureName}`); } await this.eventService.storeEvent(new types_1.FeatureMetadataUpdateEvent({ auditUser, data: featureToggle, preData, featureName, project: projectId, })); return featureToggle; } async getFeatureCountForProject(projectId) { return this.featureToggleStore.count({ archived: false, project: projectId, }); } async removeAllStrategiesForEnv(toggleName, environment = util_1.DEFAULT_ENV) { await this.featureStrategiesStore.removeAllStrategiesForFeatureEnv(toggleName, environment); } async getStrategy(strategyId) { const strategy = await this.featureStrategiesStore.getStrategyById(strategyId); const segments = await this.segmentService.getByStrategy(strategyId); let result = { id: strategy.id, name: strategy.strategyName, constraints: strategy.constraints || [], parameters: strategy.parameters, variants: strategy.variants || [], segments: [], title: strategy.title, disabled: strategy.disabled, sortOrder: strategy.sortOrder, }; if (segments && segments.length > 0) { result = { ...result, segments: segments.map((segment) => segment.id), }; } return result; } async getEnvironmentInfo(project, environment, featureName) { const envMetadata = await this.featureEnvironmentStore.getEnvironmentMetaData(environment, featureName); const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment); const defaultStrategy = await this.projectStore.getDefaultStrategy(project, environment); return { name: featureName, environment, enabled: envMetadata.enabled, strategies, defaultStrategy, }; } // todo: store events for this change. async deleteEnvironment(projectId, environment) { await this.featureStrategiesStore.deleteConfigurationsForProjectAndEnvironment(projectId, environment); await this.projectStore.deleteEnvironmentForProject(projectId, environment); } /** Validations */ async validateName(name) { await feature_schema_1.nameSchema.validateAsync({ name }); await this.validateUniqueFeatureName(name); return name; } async validateUniqueFeatureName(name) { let msg; try { const feature = await this.featureToggleStore.get(name); if (feature === undefined) { return; } msg = feature.archived ? 'An archived flag with that name already exists' : 'A flag with that name already exists'; } catch (error) { return; } throw new name_exists_error_1.default(msg); } async hasFeature(name) { return this.featureToggleStore.exists(name); } async updateStale(featureName, isStale, auditUser) { const feature = await this.featureToggleStore.get(featureName); if (feature === undefined) { throw new notfound_error_1.default(`Could not find feature with name: ${featureName}`); } const { project } = feature; feature.stale = isStale; await this.featureToggleStore.update(project, feature); await this.eventService.storeEvent(new types_1.FeatureStaleEvent({ stale: isStale, project, featureName, auditUser, })); return feature; } async archiveToggle(featureName, user, auditUser, projectId) { if (projectId) { await this.stopWhenChangeRequestsEnabled(projectId, undefined, user); } await this.unprotectedArchiveToggle(featureName, auditUser, projectId); } async unprotectedArchiveToggle(featureName, auditUser, projectId) { const feature = await this.featureToggleStore.get(featureName); if (feature === undefined) { throw new notfound_error_1.default(`Could not find feature with name ${featureName}`); } if (projectId) { await this.validateFeatureBelongsToProject({ featureName, projectId, }); await this.validateNoOrphanParents([featureName]); } await this.validateNoChildren(featureName); await this.featureToggleStore.archive(featureName); if (projectId) { await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies([featureName], projectId, auditUser); } await this.eventService.storeEvent(new types_1.FeatureArchivedEvent({ featureName, auditUser, project: feature.project, })); } async archiveToggles(featureNames, user, auditUser, projectId) { await this.stopWhenChangeRequestsEnabled(projectId, undefined, user); await this.unprotectedArchiveToggles(featureNames, projectId, auditUser); } async validateArchiveToggles(featureNames) { const hasDeletedDependencies = await this.dependentFeaturesReadModel.haveDependencies(featureNames); const parentsWithChildFeatures = await this.dependentFeaturesReadModel.getOrphanParents(featureNames); return { hasDeletedDependencies, parentsWithChildFeatures, }; } async unprotectedArchiveToggles(featureNames, projectId, auditUser) { await Promise.all([ this.validateFeaturesContext(featureNames, projectId), this.validateNoOrphanParents(featureNames), ]); const features = await this.featureToggleStore.getAllByNames(featureNames); await this.featureToggleStore.batchArchive(featureNames); await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies(featureNames, projectId, auditUser); await this.eventService.storeEvents(features.map((feature) => new types_1.FeatureArchivedEvent({ featureName: feature.name, project: feature.project, auditUser, }))); } async setToggleStaleness(featureNames, stale, projectId, auditUser) { await this.validateFeaturesContext(featureNames, projectId); const features = await this.featureToggleStore.getAllByNames(featureNames); const relevantFeatures = features.filter((feature) => feature.stale !== stale); const relevantFeatureNames = relevantFeatures.map((feature) => feature.name); await this.featureToggleStore.batchStale(relevantFeatureNames, stale); await this.eventService.storeEvents(relevantFeatures.map((feature) => new types_1.FeatureStaleEvent({ stale: stale, project: projectId, featureName: feature.name, auditUser, }))); } async bulkUpdateEnabled(project, featureNames, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) { await (0, allSettledWithRejection_1.allSettledWithRejection)(featureNames.map((featureName) => this.updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies))); } async updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) { await this.stopWhenChangeRequestsEnabled(project, environment, user); if (enabled) { await this.stopWhenCannotCreateStrategies(project, environment, featureName, user); } return this.unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies); } async unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) { await this.validateFeatureBelongsToProject({ featureName, projectId: project, }); const hasEnvironment = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName); if (!hasEnvironment) { throw new notfound_error_1.default(`Could not find environment ${environment} for feature: ${featureName}`); } await this.validateFeatureIsNotArchived(featureName, project); if (enabled) { const strategies = await this.getStrategiesForEnvironment(project, featureName, environment); const hasDisabledStrategies = strategies.some((strategy) => strategy.disabled); if (hasDisabledStrategies && shouldActivateDisabledStrategies) { await Promise.all(strategies.map((strategy) => this.updateStrategy(strategy.id, { ...strategy, disabled: false }, { environment, projectId: project, featureName, }, auditUser, user))); } const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled); const shouldCreate = hasOnlyDisabledStrategies && !shouldActivateDisabledStrategies; if (strategies.length === 0 || shouldCreate) { const projectEnvironmentDefaultStrategy = await this.projectStore.getDefaultStrategy(project, environment); const strategy = projectEnvironmentDefaultStrategy != null ? (0, helpers_1.getProjectDefaultStrategy)(projectEnvironmentDefaultStrategy, featureName) : (0, helpers_1.getDefaultStrategy)(featureName); await this.unprotectedCreateStrategy(strategy, { environment, projectId: project, featureName, }, auditUser); } } const updatedEnvironmentStatus = await this.featureEnvironmentStore.setEnvironmentEnabledStatus(environment, featureName, enabled); const feature = await this.featureToggleStore.get(featureName); if (updatedEnvironmentStatus > 0) { await this.eventService.storeEvent(new types_1.FeatureEnvironmentEvent({ enabled, project, featureName, environment, auditUser, })); } return feature; // If we get here we know the toggle exists } async changeProject(featureName, newProject, auditUser) { const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(newProject); if (changeRequestEnabled) { throw new error_1.ForbiddenError(`Changing project not allowed. Project ${newProject} has change requests enabled.`); } if (await this.dependentFeaturesReadModel.haveDependencies([ featureName, ])) { throw new error_1.ForbiddenError('Changing project not allowed. Feature has dependencies.'); } const feature = await this.featureToggleStore.get(featureName); if (feature === undefined) { throw new notfound_error_1.default(`Could not find feature with name ${featureName}`); } const oldProject = feature.project; feature.project = newProject; await this.featureToggleStore.update(newProject, feature); await this.eventService.storeEvent(new types_1.FeatureChangeProjectEvent({ auditUser, oldProject, newProject, featureName, })); } // TODO: add project id. async deleteFeature(featureName, auditUser) { await this.validateNoChildren(featureName); const toggle = await this.featureToggleStore.get(featureName); if (toggle === undefined) { return; /// Do nothing, toggle is already deleted } const tags = await this.tagStore.getAllTagsForFeature(featureName); await this.featureToggleStore.delete(featureName); await this.eventService.storeEvent(new types_1.FeatureDeletedEvent({ featureName, project: toggle.project, auditUser, preData: toggle, tags, })); } async deleteFeatures(featureNames, projectId, auditUser) { await this.validateFeaturesContext(featureNames, projectId); await this.validateNoOrphanParents(featureNames); const features = await this.featureToggleStore.getAllByNames(featureNames); const eligibleFeatures = features.filter((toggle) => toggle.archivedAt !== null); const eligibleFeatureNames = eligibleFeatures.map((toggle) => toggle.name); if (eligibleFeatures.length === 0