UNPKG

unleash-server

Version:

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

1,024 lines (1,023 loc) • 60.9 kB
import { CREATE_FEATURE_STRATEGY, EnvironmentVariantEvent, FeatureArchivedEvent, FeatureChangeProjectEvent, FeatureCreatedEvent, FeatureDeletedEvent, FeatureEnvironmentEvent, FeatureMetadataUpdateEvent, FeatureRevivedEvent, FeatureStaleEvent, FeatureStrategyAddEvent, FeatureStrategyRemoveEvent, FeatureStrategyUpdateEvent, PotentiallyStaleOnEvent, SKIP_CHANGE_REQUEST, StrategiesOrderChangedEvent, SYSTEM_USER_AUDIT, WeightType, } from '../../types/index.js'; import { ForbiddenError, FOREIGN_KEY_VIOLATION, OperationDeniedError, PatternError, PermissionError, BadDataError, NameExistsError, InvalidOperationError, } from '../../error/index.js'; import { constraintSchema, featureMetadataSchema, nameSchema, variantsArraySchema, } from '../../schema/feature-schema.js'; import NotFoundError from '../../error/notfound-error.js'; import { DATE_OPERATORS, DEFAULT_ENV, NUM_OPERATORS, SEMVER_OPERATORS, STRING_OPERATORS, } from '../../util/index.js'; import fastJsonPatch from 'fast-json-patch'; const { applyPatch, deepClone } = fastJsonPatch; import { validateDate, validateLegalValues, validateNumber, validateSemver, validateString, } from '../../util/validators/constraint-types.js'; import { getDefaultStrategy, getProjectDefaultStrategy, } from '../playground/feature-evaluator/helpers.js'; import { unique } from '../../util/unique.js'; import { checkFeatureFlagNamesAgainstPattern } from '../feature-naming-pattern/feature-naming-validation.js'; import ArchivedFeatureError from '../../error/archivedfeature-error.js'; import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events.js'; import { allSettledWithRejection } from '../../util/allSettledWithRejection.js'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error.js'; import { sortStrategies } from '../../util/sortStrategies.js'; const oneOf = (values, match) => { return values.some((value) => value === match); }; export class FeatureToggleService { constructor({ featureStrategiesStore, featureToggleStore, clientFeatureToggleStore, projectStore, featureTagStore, featureEnvironmentStore, contextFieldStore, strategyStore, }, { getLogger, flagResolver, eventBus }, { segmentService, accessService, eventService, changeRequestAccessReadModel, dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel, featureLinksReadModel, featureLinkService, resourceLimitsService, }) { 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.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesService = dependentFeaturesService; this.featureLifecycleReadModel = featureLifecycleReadModel; this.featureCollaboratorsReadModel = featureCollaboratorsReadModel; this.featureLinksReadModel = featureLinksReadModel; this.featureLinkService = featureLinkService; this.eventBus = eventBus; this.resourceLimitsService = resourceLimitsService; } async validateFeaturesContext(featureNames, projectId) { const features = await this.featureToggleStore.getAllByNames(featureNames); const invalidProjects = unique(features .map((feature) => feature.project) .filter((project) => project !== projectId)); if (invalidProjects.length > 0) { throw new InvalidOperationError(`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 NotFoundError(`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 NotFoundError(`Could not find feature ${featureName}`); } if (toggle.archived || Boolean(toggle.archivedAt)) { throw new ArchivedFeatureError(); } } async validateNoChildren(featureName) { const children = await this.dependentFeaturesReadModel.getChildren([ featureName, ]); if (children.length > 0) { throw new InvalidOperationError('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 InvalidOperationError(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 InvalidOperationError('You can not change the projectId for an activation strategy.'); } if (existingStrategy.featureName !== featureName) { throw new InvalidOperationError('You can not change the featureName for an activation strategy.'); } } async validateProjectCanAccessSegments(projectId, segmentIds) { if (segmentIds && segmentIds.length > 0) { await Promise.all(segmentIds.map((segmentId) => this.segmentService.get(segmentId))).then((segments) => { const mismatchedSegments = segments.filter((segment) => segment?.project && segment.project !== projectId); if (mismatchedSegments.length > 0) { throw new BadDataError(`The segments ${mismatchedSegments.map((s) => `${s.name} with id ${s.id}`).join(',')} does not belong to project "${projectId}"`); } }); } } async validateStrategyLimit(featureEnv) { const { featureEnvironmentStrategies: limit } = await this.resourceLimitsService.getResourceLimits(); const existingCount = (await this.featureStrategiesStore.getStrategiesForFeatureEnv(featureEnv.projectId, featureEnv.featureName, featureEnv.environment)).length; if (existingCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'strategy', limit, }); } } async validateConstraintsLimit(constraints) { const { constraints: constraintsLimit, constraintValues: constraintValuesLimit, } = await this.resourceLimitsService.getResourceLimits(); if (constraints.updated.length > constraintsLimit && constraints.updated.length > constraints.existing.length) { 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) { 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 BadDataError(`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 constraintSchema.validateAsync(input); const { operator } = constraint; if (oneOf(NUM_OPERATORS, operator)) { await validateNumber(constraint.value); } if (oneOf(STRING_OPERATORS, operator)) { await validateString(constraint.values); } if (oneOf(SEMVER_OPERATORS, operator)) { // Semver library is not asynchronous, so we do not // need to await here. validateSemver(constraint.value); } if (oneOf(DATE_OPERATORS, operator)) { await validateDate(constraint.value); } if (await this.contextFieldStore.exists(constraint.contextName)) { const contextDefinition = await this.contextFieldStore.get(constraint.contextName); if (contextDefinition?.legalValues && contextDefinition.legalValues.length > 0) { const valuesToValidate = oneOf([...DATE_OPERATORS, ...SEMVER_OPERATORS, ...NUM_OPERATORS], operator) ? constraint.value : constraint.values; 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 OperationDeniedError(`Changing variants is done via PATCH operation to /api/admin/projects/:project/features/:feature/variants`); } const { newDocument } = applyPatch(deepClone(featureToggle), operations); const updated = await this.updateFeatureToggle(project, newDocument, featureName, auditUser); if (featureToggle.stale !== newDocument.stale) { await this.eventService.storeEvent(new 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) .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) .map((strategy) => strategy.id); const eventData = { strategyIds: newOrder }; const event = new 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 parametersWithDefaults(projectId, featureName, strategyName, params) { if (strategyName === 'flexibleRollout') { const stickiness = !params?.stickiness || params?.stickiness === '' ? await this.featureStrategiesStore.getDefaultStickiness(projectId) : params?.stickiness; return { ...params, rollout: params?.rollout ?? '100', stickiness, groupId: params?.groupId ?? featureName, }; } else { // We don't really have good defaults for the other kinds of known strategies, so return an empty map. return params ?? {}; } } async standardizeStrategyConfig(projectId, featureName, strategyConfig, existing) { const { name, title, disabled, sortOrder } = strategyConfig; let { constraints, parameters, variants } = strategyConfig; if (constraints && constraints.length > 0) { await this.validateConstraintsLimit({ updated: constraints, existing: existing?.constraints ?? [], }); constraints = await this.validateConstraints(constraints); } parameters = await this.parametersWithDefaults(projectId, featureName, name, strategyConfig.parameters); if (variants && variants.length > 0) { await variantsArraySchema.validateAsync(variants); const fixedVariants = this.fixVariantWeights(variants); variants = fixedVariants; } return { name, title, disabled, sortOrder, constraints, variants, parameters, }; } 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); const standardizedConfig = await this.standardizeStrategyConfig(projectId, featureName, strategyConfig); await this.validateStrategyLimit({ featureName, projectId, environment, }); try { const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({ ...standardizedConfig, strategyName: standardizedConfig.name, constraints: standardizedConfig.constraints || [], variants: standardizedConfig.variants || [], parameters: standardizedConfig.parameters || {}, 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 FeatureStrategyAddEvent({ project: projectId, featureName, environment, data: strategy, auditUser, })); return strategy; } catch (e) { if (e.code === FOREIGN_KEY_VIOLATION) { throw new BadDataError('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) { 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); } } async unprotectedUpdateStrategy(id, updates, context, auditUser, user) { const { projectId, environment, featureName } = context; const existingStrategy = await this.featureStrategiesStore.get(id); if (existingStrategy === undefined) { throw new NotFoundError(`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) { const standardizedUpdates = await this.standardizeStrategyConfig(projectId, featureName, { ...updates, name: updates.name }, existingStrategy); const strategy = await this.featureStrategiesStore.updateStrategy(id, standardizedUpdates); 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 FeatureStrategyUpdateEvent({ project: projectId, featureName, environment, data, preData, auditUser, })); await this.optionallyDisableFeature(featureName, environment, projectId, auditUser, user); return data; } throw new NotFoundError(`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 NotFoundError(`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 FeatureStrategyUpdateEvent({ featureName, project: projectId, environment, data, preData, auditUser, })); return data; } throw new NotFoundError(`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 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 = 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 NotFoundError(`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; let collaborators = []; let links = []; [dependencies, children, lifecycle, collaborators, links] = await Promise.all([ this.dependentFeaturesReadModel.getParents(featureName), this.dependentFeaturesReadModel.getChildren([featureName]), this.featureLifecycleReadModel.findCurrentStage(featureName), this.featureCollaboratorsReadModel.getFeatureCollaborators(featureName), this.featureLinksReadModel.getLinks(featureName), ]); if (environmentVariants) { const result = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName, userId, archived); return { ...result, dependencies, children, lifecycle, links: links.map((link) => ({ id: link.id, url: link.url, title: link.title ?? null, })), collaborators: { users: collaborators }, }; } else { const result = await this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName, userId, archived); return { ...result, dependencies, children, lifecycle, links, collaborators: { users: collaborators }, }; } } 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 NotFoundError(`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 { featureFlags: limit } = await this.resourceLimitsService.getResourceLimits(); if (currentFlagCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'feature flag', limit, }); } } async validateActiveProject(projectId) { const hasActiveProject = await this.projectStore.hasActiveProject(projectId); if (!hasActiveProject) { throw new NotFoundError(`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 InvalidOperationError('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 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 FeatureCreatedEvent({ featureName, project: projectId, data: createdToggle, auditUser, })); await this.addLinksFromTemplates(projectId, featureName, auditUser); return createdToggle; } throw new NotFoundError(`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 NotFoundError(`Could not find project with id: ${projectId}`); } const patternData = project.featureNaming; const namingPattern = patternData?.pattern; if (namingPattern) { const result = 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 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 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 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 NotFoundError(`Could find feature toggle with name ${featureName}`); } await this.eventService.storeEvent(new 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 = 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 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 NameExistsError(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 NotFoundError(`Could not find feature with name: ${featureName}`); } const { project } = feature; feature.stale = isStale; await this.featureToggleStore.update(project, feature); await this.eventService.storeEvent(new 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 NotFoundError(`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 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 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 FeatureStaleEvent({ stale: stale, project: projectId, featureName: feature.name, auditUser, }))); } async bulkUpdateEnabled(project, featureNames, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) { await 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 NotFoundError(`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 ? getProjectDefaultStrategy(projectEnvironmentDefaultStrategy, featureName) : 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 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 ForbiddenError(`Changing project not allowed. Project ${newProject} has change requests enabled.`); } if (await this.dependentFeaturesReadModel.haveDependencies([ featureName, ])) { throw new ForbiddenError('Changing project not allowed. Feature has dependencies.'); } const feature = await this.featureToggleStore.get(featureName); if (feature === undefined) { throw new NotFoundError(`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 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 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) { return; } const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames); await this.featureToggleStore.batchDelete(eligibleFeatureNames); await this.eventService.storeEvents(eligibleFeatures.map((feature) => new FeatureDeletedEvent({ featureName: feature.name, auditUser, project: feature.project, preData: feature, tags: tags .filter((tag) => tag.featureName === feature.name) .map((tag) => ({ value: tag.tagValue, type: tag.tagType,