1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-11-01 19:07:38 +01:00
unleash.unleash/src/lib/services/state-service.ts
Gastón Fournier 005e5b1d15
fix: found an edge case exporting variants (#2900)
## About the changes
When exporting v3, for variants backward compatibility, we need to find
one featureEnvironment and fetch variants from there.
In cases where the default environment is disabled (therefore does not
get variants per environment when added), it can be still be selected
for the export process. Therefore variants don't appear in the feature
when they should be there.

An e2e test that fails with the previous implementation was added to
validate the behavior

This comes from our support ticket 404
2023-01-13 14:55:57 +01:00

846 lines
28 KiB
TypeScript

import { stateSchema } from './state-schema';
import {
DROP_ENVIRONMENTS,
DROP_FEATURE_TAGS,
DROP_FEATURES,
DROP_PROJECTS,
DROP_STRATEGIES,
DROP_TAG_TYPES,
DROP_TAGS,
ENVIRONMENT_IMPORT,
FEATURE_IMPORT,
FEATURE_TAG_IMPORT,
PROJECT_IMPORT,
STRATEGY_IMPORT,
TAG_IMPORT,
TAG_TYPE_IMPORT,
} from '../types/events';
import { filterEqual, filterExisting, parseFile, readFile } from './state-util';
import { IUnleashConfig } from '../types/option';
import {
FeatureToggle,
IEnvironment,
IFeatureEnvironment,
IFeatureStrategy,
IImportData,
IImportFile,
IProject,
ISegment,
IStrategyConfig,
ITag,
} from '../types/model';
import { Logger } from '../logger';
import {
IFeatureTag,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
import { IProjectStore } from '../types/stores/project-store';
import { ITagType, ITagTypeStore } from '../types/stores/tag-type-store';
import { ITagStore } from '../types/stores/tag-store';
import { IEventStore } from '../types/stores/event-store';
import { IStrategy, IStrategyStore } from '../types/stores/strategy-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IUnleashStores } from '../types/stores';
import { DEFAULT_ENV } from '../util/constants';
import { GLOBAL_ENV } from '../types/environment';
import { ISegmentStore } from '../types/stores/segment-store';
import { PartialSome } from '../types/partial';
import { IFlagResolver } from 'lib/types';
export interface IBackupOption {
includeFeatureToggles: boolean;
includeStrategies: boolean;
includeProjects: boolean;
includeTags: boolean;
}
interface IExportIncludeOptions {
includeFeatureToggles?: boolean;
includeStrategies?: boolean;
includeProjects?: boolean;
includeTags?: boolean;
includeEnvironments?: boolean;
includeSegments?: boolean;
}
export default class StateService {
private logger: Logger;
private toggleStore: IFeatureToggleStore;
private featureStrategiesStore: IFeatureStrategiesStore;
private strategyStore: IStrategyStore;
private eventStore: IEventStore;
private tagStore: ITagStore;
private tagTypeStore: ITagTypeStore;
private projectStore: IProjectStore;
private featureEnvironmentStore: IFeatureEnvironmentStore;
private featureTagStore: IFeatureTagStore;
private environmentStore: IEnvironmentStore;
private segmentStore: ISegmentStore;
private flagResolver: IFlagResolver;
constructor(
stores: IUnleashStores,
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
) {
this.eventStore = stores.eventStore;
this.toggleStore = stores.featureToggleStore;
this.strategyStore = stores.strategyStore;
this.tagStore = stores.tagStore;
this.featureStrategiesStore = stores.featureStrategiesStore;
this.featureEnvironmentStore = stores.featureEnvironmentStore;
this.tagTypeStore = stores.tagTypeStore;
this.projectStore = stores.projectStore;
this.featureTagStore = stores.featureTagStore;
this.environmentStore = stores.environmentStore;
this.segmentStore = stores.segmentStore;
this.flagResolver = flagResolver;
this.logger = getLogger('services/state-service.js');
}
async importFile({
file,
dropBeforeImport = false,
userName = 'import-user',
keepExisting = true,
}: IImportFile): Promise<void> {
return readFile(file)
.then((data) => parseFile(file, data))
.then((data) =>
this.import({
data,
userName,
dropBeforeImport,
keepExisting,
}),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
replaceGlobalEnvWithDefaultEnv(data: any) {
data.environments?.forEach((e) => {
if (e.name === GLOBAL_ENV) {
e.name = DEFAULT_ENV;
}
});
data.featureEnvironments?.forEach((fe) => {
if (fe.environment === GLOBAL_ENV) {
// eslint-disable-next-line no-param-reassign
fe.environment = DEFAULT_ENV;
}
});
data.featureStrategies?.forEach((fs) => {
if (fs.environment === GLOBAL_ENV) {
// eslint-disable-next-line no-param-reassign
fs.environment = DEFAULT_ENV;
}
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
moveVariantsToFeatureEnvironments(data: any) {
data.featureEnvironments?.forEach((featureEnvironment) => {
let feature = data.features?.find(
(f) => f.name === featureEnvironment.featureName,
);
if (feature) {
featureEnvironment.variants = feature.variants || [];
}
});
}
async import({
data,
userName = 'importUser',
dropBeforeImport = false,
keepExisting = true,
}: IImportData): Promise<void> {
if (data.version === 2) {
this.replaceGlobalEnvWithDefaultEnv(data);
}
if (!data.version || data.version < 4) {
this.moveVariantsToFeatureEnvironments(data);
}
const importData = await stateSchema.validateAsync(data);
let importedEnvironments: IEnvironment[] = [];
if (importData.environments) {
importedEnvironments = await this.importEnvironments({
environments: data.environments,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.projects) {
await this.importProjects({
projects: data.projects,
importedEnvironments,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.features) {
let projectData;
if (!importData.version || importData.version === 1) {
projectData = await this.convertLegacyFeatures(importData);
} else {
projectData = importData;
}
const { features, featureStrategies, featureEnvironments } =
projectData;
await this.importFeatures({
features,
userName,
dropBeforeImport,
keepExisting,
featureEnvironments,
});
if (featureEnvironments) {
await this.importFeatureEnvironments({
featureEnvironments,
});
}
await this.importFeatureStrategies({
featureStrategies,
dropBeforeImport,
keepExisting,
});
}
if (importData.strategies) {
await this.importStrategies({
strategies: data.strategies,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.tagTypes && importData.tags) {
await this.importTagData({
tagTypes: data.tagTypes,
tags: data.tags,
featureTags:
(data.featureTags || [])
.filter((t) =>
(data.features || []).some(
(f) => f.name === t.featureName,
),
)
.map((t) => ({
featureName: t.featureName,
tagValue: t.tagValue || t.value,
tagType: t.tagType || t.type,
})) || [],
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.segments) {
await this.importSegments(
data.segments,
userName,
dropBeforeImport,
);
}
if (importData.featureStrategySegments) {
await this.importFeatureStrategySegments(
data.featureStrategySegments,
);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
enabledIn(feature: string, env) {
const config = {};
env.filter((e) => e.featureName === feature).forEach((e) => {
config[e.environment] = e.enabled || false;
});
return config;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureEnvironments({ featureEnvironments }): Promise<void> {
await Promise.all(
featureEnvironments
.filter(async (env) => {
await this.environmentStore.exists(env.environment);
})
.map(async (featureEnvironment) =>
this.featureEnvironmentStore.addFeatureEnvironment(
featureEnvironment,
),
),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureStrategies({
featureStrategies,
dropBeforeImport,
keepExisting,
}): Promise<void> {
const oldFeatureStrategies = dropBeforeImport
? []
: await this.featureStrategiesStore.getAll();
if (dropBeforeImport) {
this.logger.info(
'Dropping existing strategies for feature toggles',
);
await this.featureStrategiesStore.deleteAll();
}
const strategiesToImport = keepExisting
? featureStrategies.filter(
(s) => !oldFeatureStrategies.some((o) => o.id === s.id),
)
: featureStrategies;
await Promise.all(
strategiesToImport.map((featureStrategy) =>
this.featureStrategiesStore.createStrategyFeatureEnv(
featureStrategy,
),
),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async convertLegacyFeatures({
features,
}): Promise<{ features; featureStrategies; featureEnvironments }> {
const strategies = features.flatMap((f) =>
f.strategies.map((strategy: IStrategyConfig) => ({
featureName: f.name,
projectId: f.project,
constraints: strategy.constraints || [],
parameters: strategy.parameters || {},
environment: DEFAULT_ENV,
strategyName: strategy.name,
})),
);
const newFeatures = features;
const featureEnvironments = features.map((feature) => ({
featureName: feature.name,
environment: DEFAULT_ENV,
enabled: feature.enabled,
variants: feature.variants || [],
}));
return {
features: newFeatures,
featureStrategies: strategies,
featureEnvironments,
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatures({
features,
userName,
dropBeforeImport,
keepExisting,
featureEnvironments,
}): Promise<void> {
this.logger.info(`Importing ${features.length} feature toggles`);
const oldToggles = dropBeforeImport
? []
: await this.toggleStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing feature toggles');
await this.toggleStore.deleteAll();
await this.eventStore.store({
type: DROP_FEATURES,
createdBy: userName,
data: { name: 'all-features' },
});
}
await Promise.all(
features
.filter(filterExisting(keepExisting, oldToggles))
.filter(filterEqual(oldToggles))
.map(async (feature) => {
await this.toggleStore.create(feature.project, feature);
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
feature.name,
feature.project,
this.enabledIn(feature.name, featureEnvironments),
);
await this.eventStore.store({
type: FEATURE_IMPORT,
createdBy: userName,
data: feature,
});
}),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importStrategies({
strategies,
userName,
dropBeforeImport,
keepExisting,
}): Promise<void> {
this.logger.info(`Importing ${strategies.length} strategies`);
const oldStrategies = dropBeforeImport
? []
: await this.strategyStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing strategies');
await this.strategyStore.dropCustomStrategies();
await this.eventStore.store({
type: DROP_STRATEGIES,
createdBy: userName,
data: { name: 'all-strategies' },
});
}
await Promise.all(
strategies
.filter(filterExisting(keepExisting, oldStrategies))
.filter(filterEqual(oldStrategies))
.map((strategy) =>
this.strategyStore.importStrategy(strategy).then(() => {
this.eventStore.store({
type: STRATEGY_IMPORT,
createdBy: userName,
data: strategy,
});
}),
),
);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importEnvironments({
environments,
userName,
dropBeforeImport,
keepExisting,
}): Promise<IEnvironment[]> {
this.logger.info(`Import ${environments.length} projects`);
const oldEnvs = dropBeforeImport
? []
: await this.environmentStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing environments');
await this.environmentStore.deleteAll();
await this.eventStore.store({
type: DROP_ENVIRONMENTS,
createdBy: userName,
data: { name: 'all-environments' },
});
}
const envsImport = environments.filter((env) =>
keepExisting ? !oldEnvs.some((old) => old.name === env.name) : true,
);
let importedEnvs = [];
if (envsImport.length > 0) {
importedEnvs = await this.environmentStore.importEnvironments(
envsImport,
);
const importedEnvironmentEvents = importedEnvs.map((env) => ({
type: ENVIRONMENT_IMPORT,
createdBy: userName,
data: env,
}));
await this.eventStore.batchStore(importedEnvironmentEvents);
}
return importedEnvs;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importProjects({
projects,
importedEnvironments,
userName,
dropBeforeImport,
keepExisting,
}): Promise<void> {
this.logger.info(`Import ${projects.length} projects`);
const oldProjects = dropBeforeImport
? []
: await this.projectStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing projects');
await this.projectStore.deleteAll();
await this.eventStore.store({
type: DROP_PROJECTS,
createdBy: userName,
data: { name: 'all-projects' },
});
}
const projectsToImport = projects.filter((project) =>
keepExisting
? !oldProjects.some((old) => old.id === project.id)
: true,
);
if (projectsToImport.length > 0) {
const importedProjects = await this.projectStore.importProjects(
projectsToImport,
importedEnvironments,
);
const importedProjectEvents = importedProjects.map((project) => ({
type: PROJECT_IMPORT,
createdBy: userName,
data: project,
}));
await this.eventStore.batchStore(importedProjectEvents);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importTagData({
tagTypes,
tags,
featureTags,
userName,
dropBeforeImport,
keepExisting,
}): Promise<void> {
this.logger.info(
`Importing ${tagTypes.length} tagtypes, ${tags.length} tags and ${featureTags.length} feature tags`,
);
const oldTagTypes = dropBeforeImport
? []
: await this.tagTypeStore.getAll();
const oldTags = dropBeforeImport ? [] : await this.tagStore.getAll();
const oldFeatureTags = dropBeforeImport
? []
: await this.featureTagStore.getAll();
if (dropBeforeImport) {
this.logger.info(
'Dropping all existing featuretags, tags and tagtypes',
);
await this.featureTagStore.deleteAll();
await this.tagStore.deleteAll();
await this.tagTypeStore.deleteAll();
await this.eventStore.batchStore([
{
type: DROP_FEATURE_TAGS,
createdBy: userName,
data: { name: 'all-feature-tags' },
},
{
type: DROP_TAGS,
createdBy: userName,
data: { name: 'all-tags' },
},
{
type: DROP_TAG_TYPES,
createdBy: userName,
data: { name: 'all-tag-types' },
},
]);
}
await this.importTagTypes(
tagTypes,
keepExisting,
oldTagTypes,
userName,
);
await this.importTags(tags, keepExisting, oldTags, userName);
await this.importFeatureTags(
featureTags,
keepExisting,
oldFeatureTags,
userName,
);
}
compareFeatureTags: (old: IFeatureTag, tag: IFeatureTag) => boolean = (
old,
tag,
) =>
old.featureName === tag.featureName &&
old.tagValue === tag.tagValue &&
old.tagType === tag.tagType;
async importFeatureTags(
featureTags: IFeatureTag[],
keepExisting: boolean,
oldFeatureTags: IFeatureTag[],
userName: string,
): Promise<void> {
const featureTagsToInsert = featureTags.filter((tag) =>
keepExisting
? !oldFeatureTags.some((old) =>
this.compareFeatureTags(old, tag),
)
: true,
);
if (featureTagsToInsert.length > 0) {
const importedFeatureTags =
await this.featureTagStore.importFeatureTags(
featureTagsToInsert,
);
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
type: FEATURE_TAG_IMPORT,
createdBy: userName,
data: tag,
}));
await this.eventStore.batchStore(importedFeatureTagEvents);
}
}
compareTags = (old: ITag, tag: ITag): boolean =>
old.type === tag.type && old.value === tag.value;
async importTags(
tags: ITag[],
keepExisting: boolean,
oldTags: ITag[],
userName: string,
): Promise<void> {
const tagsToInsert = tags.filter((tag) =>
keepExisting
? !oldTags.some((old) => this.compareTags(old, tag))
: true,
);
if (tagsToInsert.length > 0) {
const importedTags = await this.tagStore.bulkImport(tagsToInsert);
const importedTagEvents = importedTags.map((tag) => ({
type: TAG_IMPORT,
createdBy: userName,
data: tag,
}));
await this.eventStore.batchStore(importedTagEvents);
}
}
async importTagTypes(
tagTypes: ITagType[],
keepExisting: boolean,
oldTagTypes: ITagType[] = [], // eslint-disable-line
userName: string,
): Promise<void> {
const tagTypesToInsert = tagTypes.filter((tagType) =>
keepExisting
? !oldTagTypes.some((t) => t.name === tagType.name)
: true,
);
if (tagTypesToInsert.length > 0) {
const importedTagTypes = await this.tagTypeStore.bulkImport(
tagTypesToInsert,
);
const importedTagTypeEvents = importedTagTypes.map((tagType) => ({
type: TAG_TYPE_IMPORT,
createdBy: userName,
data: tagType,
}));
await this.eventStore.batchStore(importedTagTypeEvents);
}
}
async importSegments(
segments: PartialSome<ISegment, 'id'>[],
userName: string,
dropBeforeImport: boolean,
): Promise<void> {
if (dropBeforeImport) {
await this.segmentStore.deleteAll();
}
await Promise.all(
segments.map((segment) =>
this.segmentStore.create(segment, { username: userName }),
),
);
}
async importFeatureStrategySegments(
featureStrategySegments: {
featureStrategyId: string;
segmentId: number;
}[],
): Promise<void> {
await Promise.all(
featureStrategySegments.map(({ featureStrategyId, segmentId }) =>
this.segmentStore.addToStrategy(segmentId, featureStrategyId),
),
);
}
async export(opts: IExportIncludeOptions): Promise<{
features: FeatureToggle[];
strategies: IStrategy[];
version: number;
projects: IProject[];
tagTypes: ITagType[];
tags: ITag[];
featureTags: IFeatureTag[];
featureStrategies: IFeatureStrategy[];
environments: IEnvironment[];
featureEnvironments: IFeatureEnvironment[];
}> {
if (this.flagResolver.isEnabled('variantsPerEnvironment')) {
return this.exportV4(opts);
}
// adapt v4 to v3. We need includeEnvironments set to true to filter the
// best environment from where we'll pick variants (cause now they are stored
// per environment despite being displayed as if they belong to the feature)
const v4 = await this.exportV4({ ...opts, includeEnvironments: true });
// undefined defaults to true
if (opts.includeFeatureToggles !== false) {
const enabledEnvironments = v4.environments.filter(
(env) => env.enabled !== false,
);
const featureAndEnvs = v4.featureEnvironments.map((fE) => {
const envDetails = enabledEnvironments.find(
(env) => fE.environment === env.name,
);
return { ...fE, ...envDetails, active: fE.enabled };
});
v4.features = v4.features.map((f) => {
const variants = featureAndEnvs
.sort((e1, e2) => {
if (e1.active !== e2.active) {
return e1.active ? -1 : 1;
}
if (
e1.type !== 'production' ||
e2.type !== 'production'
) {
if (e1.type === 'production') {
return -1;
} else if (e2.type === 'production') {
return 1;
}
}
return e1.sortOrder - e2.sortOrder;
})
.find((fe) => fe.featureName === f.name)?.variants;
return { ...f, variants };
});
v4.featureEnvironments = v4.featureEnvironments.map((fe) => {
delete fe.variants;
return fe;
});
}
// only if explicitly set to false (i.e. undefined defaults to true)
if (opts.includeEnvironments === false) {
delete v4.environments;
} else {
if (v4.environments.length === 0) {
throw Error('Unable to export without enabled environments');
}
}
v4.version = 3;
return v4;
}
async exportV4({
includeFeatureToggles = true,
includeStrategies = true,
includeProjects = true,
includeTags = true,
includeEnvironments = true,
includeSegments = true,
}: IExportIncludeOptions): Promise<{
features: FeatureToggle[];
strategies: IStrategy[];
version: number;
projects: IProject[];
tagTypes: ITagType[];
tags: ITag[];
featureTags: IFeatureTag[];
featureStrategies: IFeatureStrategy[];
environments: IEnvironment[];
featureEnvironments: IFeatureEnvironment[];
}> {
return Promise.all([
includeFeatureToggles
? this.toggleStore.getAll({ archived: false })
: Promise.resolve([]),
includeStrategies
? this.strategyStore.getEditableStrategies()
: Promise.resolve([]),
this.projectStore && includeProjects
? this.projectStore.getAll()
: Promise.resolve([]),
includeTags ? this.tagTypeStore.getAll() : Promise.resolve([]),
includeTags ? this.tagStore.getAll() : Promise.resolve([]),
includeTags && includeFeatureToggles
? this.featureTagStore.getAll()
: Promise.resolve([]),
includeFeatureToggles
? this.featureStrategiesStore.getAll()
: Promise.resolve([]),
includeEnvironments
? this.environmentStore.getAll()
: Promise.resolve([]),
includeFeatureToggles
? this.featureEnvironmentStore.getAll()
: Promise.resolve([]),
includeSegments ? this.segmentStore.getAll() : Promise.resolve([]),
includeSegments
? this.segmentStore.getAllFeatureStrategySegments()
: Promise.resolve([]),
]).then(
([
features,
strategies,
projects,
tagTypes,
tags,
featureTags,
featureStrategies,
environments,
featureEnvironments,
segments,
featureStrategySegments,
]) => ({
version: 4,
features,
strategies,
projects,
tagTypes,
tags,
featureTags,
featureStrategies: featureStrategies.filter((fS) =>
features.some((f) => fS.featureName === f.name),
),
environments,
featureEnvironments: featureEnvironments.filter((fE) =>
features.some((f) => fE.featureName === f.name),
),
segments,
featureStrategySegments,
}),
);
}
}
module.exports = StateService;