1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

feat: Add ability to push variants to multiple environments (#2914)

## About the changes
This PR adds the ability to push variants to multiple environments
overriding the existing variants.

Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#2254

**Note:** This won't fail if there are variants in other environments, because the operation wouldn't be idempotent. It should have that property because setting variants to 1 or more environments once or twice should not make a difference
This commit is contained in:
Gastón Fournier 2023-01-20 10:30:20 +01:00 committed by GitHub
parent a67649347a
commit 96c65fc10d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 378 additions and 28 deletions

View File

@ -371,16 +371,30 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
featureName: string, featureName: string,
environment: string, environment: string,
variants: IVariant[], variants: IVariant[],
): Promise<void> {
return this.setVariantsToFeatureEnvironments(
featureName,
[environment],
variants,
);
}
async setVariantsToFeatureEnvironments(
featureName: string,
environments: string[],
variants: IVariant[],
): Promise<void> { ): Promise<void> {
let v = variants || []; let v = variants || [];
v.sort((a, b) => a.name.localeCompare(b.name)); v.sort((a, b) => a.name.localeCompare(b.name));
const variantsString = JSON.stringify(v);
const records = environments.map((env) => ({
variants: variantsString,
enabled: false, // default value for enabled in case it's not set
feature_name: featureName,
environment: env,
}));
await this.db(T.featureEnvs) await this.db(T.featureEnvs)
.insert({ .insert(records)
variants: JSON.stringify(v),
enabled: false,
feature_name: featureName,
environment: environment,
})
.onConflict(['feature_name', 'environment']) .onConflict(['feature_name', 'environment'])
.merge(['variants']); .merge(['variants']);
} }

View File

@ -5,13 +5,20 @@ class NoAccessError extends Error {
message: string; message: string;
constructor(permission: string) { environment?: string;
constructor(permission: string, environment?: string) {
super(); super();
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name; this.name = this.constructor.name;
this.permission = permission; this.permission = permission;
this.message = `You need permission=${permission} to perform this action`; this.environment = environment;
if (environment) {
this.message = `You need permission=${permission} to perform this action on environment=${environment}`;
} else {
this.message = `You need permission=${permission} to perform this action`;
}
} }
toJSON(): any { toJSON(): any {

View File

@ -87,6 +87,7 @@ import {
publicSignupTokenSchema, publicSignupTokenSchema,
publicSignupTokensSchema, publicSignupTokensSchema,
publicSignupTokenUpdateSchema, publicSignupTokenUpdateSchema,
pushVariantsSchema,
resetPasswordSchema, resetPasswordSchema,
requestsPerSecondSchema, requestsPerSecondSchema,
requestsPerSecondSegmentedSchema, requestsPerSecondSegmentedSchema,
@ -223,6 +224,7 @@ export const schemas = {
publicSignupTokenSchema, publicSignupTokenSchema,
publicSignupTokensSchema, publicSignupTokensSchema,
publicSignupTokenUpdateSchema, publicSignupTokenUpdateSchema,
pushVariantsSchema,
resetPasswordSchema, resetPasswordSchema,
requestsPerSecondSchema, requestsPerSecondSchema,
requestsPerSecondSegmentedSchema, requestsPerSecondSegmentedSchema,

View File

@ -124,3 +124,4 @@ export * from './requests-per-second-schema';
export * from './requests-per-second-segmented-schema'; export * from './requests-per-second-segmented-schema';
export * from './export-result-schema'; export * from './export-result-schema';
export * from './export-query-schema'; export * from './export-query-schema';
export * from './push-variants-schema';

View File

@ -0,0 +1,30 @@
import { variantSchema } from './variant-schema';
import { FromSchema } from 'json-schema-to-ts';
import { overrideSchema } from './override-schema';
export const pushVariantsSchema = {
$id: '#/components/schemas/pushVariantsSchema',
type: 'object',
properties: {
variants: {
type: 'array',
items: {
$ref: '#/components/schemas/variantSchema',
},
},
environments: {
type: 'array',
items: {
type: 'string',
},
},
},
components: {
schemas: {
variantSchema,
overrideSchema,
},
},
} as const;
export type PushVariantsSchema = FromSchema<typeof pushVariantsSchema>;

View File

@ -10,12 +10,16 @@ import {
UPDATE_FEATURE_ENVIRONMENT_VARIANTS, UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_FEATURE_VARIANTS, UPDATE_FEATURE_VARIANTS,
} from '../../../types/permissions'; } from '../../../types/permissions';
import { IVariant } from '../../../types/model'; import { IVariant, WeightType } from '../../../types/model';
import { extractUsername } from '../../../util/extract-user'; import { extractUsername } from '../../../util/extract-user';
import { IAuthRequest } from '../../unleash-types'; import { IAuthRequest } from '../../unleash-types';
import { FeatureVariantsSchema } from '../../../openapi/spec/feature-variants-schema'; import { FeatureVariantsSchema } from '../../../openapi/spec/feature-variants-schema';
import { createRequestSchema } from '../../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../../openapi/util/create-request-schema';
import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema';
import { AccessService } from '../../../services';
import { BadDataError, NoAccessError } from '../../../../lib/error';
import { User } from 'lib/server-impl';
import { PushVariantsSchema } from 'lib/openapi/spec/push-variants-schema';
const PREFIX = '/:projectId/features/:featureName/variants'; const PREFIX = '/:projectId/features/:featureName/variants';
const ENV_PREFIX = const ENV_PREFIX =
@ -37,16 +41,23 @@ export default class VariantsController extends Controller {
private featureService: FeatureToggleService; private featureService: FeatureToggleService;
private accessService: AccessService;
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
featureToggleService, featureToggleService,
openApiService, openApiService,
}: Pick<IUnleashServices, 'featureToggleService' | 'openApiService'>, accessService,
}: Pick<
IUnleashServices,
'featureToggleService' | 'openApiService' | 'accessService'
>,
) { ) {
super(config); super(config);
this.logger = config.getLogger('admin-api/project/variants.ts'); this.logger = config.getLogger('admin-api/project/variants.ts');
this.featureService = featureToggleService; this.featureService = featureToggleService;
this.accessService = accessService;
this.route({ this.route({
method: 'get', method: 'get',
path: PREFIX, path: PREFIX,
@ -141,6 +152,22 @@ export default class VariantsController extends Controller {
}), }),
], ],
}); });
this.route({
method: 'put',
path: `${PREFIX}-batch`,
permission: NONE,
handler: this.pushVariantsToEnvironments,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'overwriteFeatureVariantsOnEnvironments',
requestBody: createRequestSchema('pushVariantsSchema'),
responses: {
200: createResponseSchema('featureVariantsSchema'),
},
}),
],
});
} }
/** /**
@ -193,6 +220,72 @@ export default class VariantsController extends Controller {
}); });
} }
async pushVariantsToEnvironments(
req: IAuthRequest<
FeatureEnvironmentParams,
any,
PushVariantsSchema,
any
>,
res: Response<FeatureVariantsSchema>,
): Promise<void> {
const { projectId, featureName } = req.params;
const { environments, variants } = req.body;
const userName = extractUsername(req);
if (environments === undefined || environments.length === 0) {
throw new BadDataError('No environments provided');
}
await this.checkAccess(
req.user,
projectId,
environments,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
);
const variantsWithDefaults = variants.map((variant) => ({
weightType: WeightType.VARIABLE,
stickiness: 'default',
...variant,
}));
await this.featureService.setVariantsOnEnvs(
projectId,
featureName,
environments,
variantsWithDefaults,
userName,
);
res.status(200).json({
version: 1,
variants: variantsWithDefaults,
});
}
async checkAccess(
user: User,
projectId: string,
environments: string[],
permission: string,
): Promise<void> {
for (const environment of environments) {
if (
!(await this.accessService.hasPermission(
user,
permission,
projectId,
environment,
))
) {
throw new NoAccessError(
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
environment,
);
}
}
}
async getVariantsOnEnv( async getVariantsOnEnv(
req: Request<FeatureEnvironmentParams, any, any, any>, req: Request<FeatureEnvironmentParams, any, any, any>,
res: Response<FeatureVariantsSchema>, res: Response<FeatureVariantsSchema>,

View File

@ -750,13 +750,12 @@ class FeatureToggleService {
await this.featureEnvironmentStore.getEnvironmentsForFeature( await this.featureEnvironmentStore.getEnvironmentsForFeature(
featureName, featureName,
); );
environments.forEach(async (featureEnv) => {
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment( await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(
featureName, featureName,
featureEnv.environment, environments.map((env) => env.environment),
value.variants, value.variants,
); );
});
} }
const tags = await this.tagStore.getAllTagsForFeature(featureName); const tags = await this.tagStore.getAllTagsForFeature(featureName);
@ -1350,9 +1349,51 @@ class FeatureToggleService {
newVariants: fixedVariants, newVariants: fixedVariants,
}), }),
); );
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment( await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(
featureName, featureName,
environment, [environment],
fixedVariants,
);
return fixedVariants;
}
async setVariantsOnEnvs(
projectId: string,
featureName: string,
environments: string[],
newVariants: IVariant[],
createdBy: string,
): Promise<IVariant[]> {
await variantsArraySchema.validateAsync(newVariants);
const fixedVariants = this.fixVariantWeights(newVariants);
const oldVariants: { [env: string]: IVariant[] } = environments.reduce(
async (result, environment) => {
result[environment] = await this.featureEnvironmentStore.get({
featureName,
environment,
});
return result;
},
{},
);
await this.eventStore.batchStore(
environments.map(
(environment) =>
new EnvironmentVariantEvent({
featureName,
environment,
project: projectId,
createdBy,
oldVariants: oldVariants[environment],
newVariants: fixedVariants,
}),
),
);
await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(
featureName,
environments,
fixedVariants, fixedVariants,
); );
return fixedVariants; return fixedVariants;

View File

@ -75,6 +75,12 @@ export interface IFeatureEnvironmentStore
variants: IVariant[], variants: IVariant[],
): Promise<void>; ): Promise<void>;
setVariantsToFeatureEnvironments(
featureName: string,
environments: string[],
variants: IVariant[],
): Promise<void>;
addFeatureEnvironment( addFeatureEnvironment(
featureEnvironment: IFeatureEnvironment, featureEnvironment: IFeatureEnvironment,
): Promise<void>; ): Promise<void>;

View File

@ -10,6 +10,14 @@ let db: ITestDb;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('project_feature_variants_api_serial', getLogger); db = await dbInit('project_feature_variants_api_serial', getLogger);
app = await setupApp(db.stores); app = await setupApp(db.stores);
await db.stores.environmentStore.create({
name: 'development',
type: 'development',
});
await db.stores.environmentStore.create({
name: 'production',
type: 'production',
});
}); });
afterAll(async () => { afterAll(async () => {
@ -132,14 +140,6 @@ test('Can patch variants for a feature patches all environments independently',
}, },
]; ];
await db.stores.environmentStore.create({
name: 'development',
type: 'development',
});
await db.stores.environmentStore.create({
name: 'production',
type: 'production',
});
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
@ -218,6 +218,84 @@ test('Can patch variants for a feature patches all environments independently',
}); });
}); });
test('Can push variants to multiple environments', async () => {
const featureName = 'feature-to-override';
const variant = (name: string, weight: number) => ({
name,
stickiness: 'default',
weight,
weightType: WeightType.VARIABLE,
});
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'development',
true,
);
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'production',
true,
);
await db.stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
'development',
[
variant('dev-variant-1', 250),
variant('dev-variant-2', 250),
variant('dev-variant-3', 250),
variant('dev-variant-4', 250),
],
);
await db.stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
'production',
[variant('prod-variant', 1000)],
);
const overrideWith = {
variants: [
variant('new-variant-1', 500),
variant('new-variant-2', 500),
],
environments: ['development', 'production'],
};
await app.request
.put(
`/api/admin/projects/default/features/${featureName}/variants-batch`,
)
.send(overrideWith)
.expect(200)
.expect((res) => {
expect(res.body.version).toBe(1);
expect(res.body.variants).toHaveLength(2);
expect(res.body.variants[0].name).toBe('new-variant-1');
expect(res.body.variants[1].name).toBe('new-variant-2');
});
await app.request
.get(
`/api/admin/projects/default/features/${featureName}?variantEnvironments=true`,
)
.expect((res) => {
const environments = res.body.environments;
expect(environments).toHaveLength(2);
const developmentVariants = environments.find(
(x) => x.name === 'development',
).variants;
const productionVariants = environments.find(
(x) => x.name === 'production',
).variants;
expect(developmentVariants).toHaveLength(2);
expect(productionVariants).toHaveLength(2);
expect(developmentVariants[0].name).toBe('new-variant-1');
expect(developmentVariants[1].name).toBe('new-variant-2');
});
});
test('Can add variant for a feature', async () => { test('Can add variant for a feature', async () => {
const featureName = 'feature-variants-patch-add'; const featureName = 'feature-variants-patch-add';
const variantName = 'fancy-variant-patch'; const variantName = 'fancy-variant-patch';

View File

@ -2766,6 +2766,23 @@ exports[`should serve the OpenAPI spec 1`] = `
], ],
"type": "object", "type": "object",
}, },
"pushVariantsSchema": {
"properties": {
"environments": {
"items": {
"type": "string",
},
"type": "array",
},
"variants": {
"items": {
"$ref": "#/components/schemas/variantSchema",
},
"type": "array",
},
},
"type": "object",
},
"requestsPerSecondSchema": { "requestsPerSecondSchema": {
"properties": { "properties": {
"data": { "data": {
@ -6600,6 +6617,55 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/projects/{projectId}/features/{featureName}/variants-batch": {
"put": {
"operationId": "overwriteFeatureVariantsOnEnvironments",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/pushVariantsSchema",
},
},
},
"description": "pushVariantsSchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/featureVariantsSchema",
},
},
},
"description": "featureVariantsSchema",
},
},
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/health-report": { "/api/admin/projects/{projectId}/health-report": {
"get": { "get": {
"operationId": "getProjectHealthReport", "operationId": "getProjectHealthReport",

View File

@ -22,12 +22,24 @@ export default class FakeFeatureEnvironmentStore
featureName: string, featureName: string,
environment: string, environment: string,
variants: IVariant[], variants: IVariant[],
): Promise<void> {
this.setVariantsToFeatureEnvironments(
featureName,
[environment],
variants,
);
}
async setVariantsToFeatureEnvironments(
featureName: string,
environments: string[],
variants: IVariant[],
): Promise<void> { ): Promise<void> {
this.featureEnvironments this.featureEnvironments
.filter( .filter(
(fe) => (fe) =>
fe.featureName === featureName && fe.featureName === featureName &&
fe.environment === environment, environments.indexOf(fe.environment) !== -1,
) )
.map((fe) => (fe.variants = variants)); .map((fe) => (fe.variants = variants));
} }