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:
parent
a67649347a
commit
96c65fc10d
@ -371,16 +371,30 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
|
||||
featureName: string,
|
||||
environment: string,
|
||||
variants: IVariant[],
|
||||
): Promise<void> {
|
||||
return this.setVariantsToFeatureEnvironments(
|
||||
featureName,
|
||||
[environment],
|
||||
variants,
|
||||
);
|
||||
}
|
||||
|
||||
async setVariantsToFeatureEnvironments(
|
||||
featureName: string,
|
||||
environments: string[],
|
||||
variants: IVariant[],
|
||||
): Promise<void> {
|
||||
let v = variants || [];
|
||||
v.sort((a, b) => a.name.localeCompare(b.name));
|
||||
await this.db(T.featureEnvs)
|
||||
.insert({
|
||||
variants: JSON.stringify(v),
|
||||
enabled: false,
|
||||
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: environment,
|
||||
})
|
||||
environment: env,
|
||||
}));
|
||||
await this.db(T.featureEnvs)
|
||||
.insert(records)
|
||||
.onConflict(['feature_name', 'environment'])
|
||||
.merge(['variants']);
|
||||
}
|
||||
|
@ -5,14 +5,21 @@ class NoAccessError extends Error {
|
||||
|
||||
message: string;
|
||||
|
||||
constructor(permission: string) {
|
||||
environment?: string;
|
||||
|
||||
constructor(permission: string, environment?: string) {
|
||||
super();
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
this.name = this.constructor.name;
|
||||
this.permission = permission;
|
||||
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 {
|
||||
return {
|
||||
|
@ -87,6 +87,7 @@ import {
|
||||
publicSignupTokenSchema,
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
pushVariantsSchema,
|
||||
resetPasswordSchema,
|
||||
requestsPerSecondSchema,
|
||||
requestsPerSecondSegmentedSchema,
|
||||
@ -223,6 +224,7 @@ export const schemas = {
|
||||
publicSignupTokenSchema,
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
pushVariantsSchema,
|
||||
resetPasswordSchema,
|
||||
requestsPerSecondSchema,
|
||||
requestsPerSecondSegmentedSchema,
|
||||
|
@ -124,3 +124,4 @@ export * from './requests-per-second-schema';
|
||||
export * from './requests-per-second-segmented-schema';
|
||||
export * from './export-result-schema';
|
||||
export * from './export-query-schema';
|
||||
export * from './push-variants-schema';
|
||||
|
30
src/lib/openapi/spec/push-variants-schema.ts
Normal file
30
src/lib/openapi/spec/push-variants-schema.ts
Normal 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>;
|
@ -10,12 +10,16 @@ import {
|
||||
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||
UPDATE_FEATURE_VARIANTS,
|
||||
} from '../../../types/permissions';
|
||||
import { IVariant } from '../../../types/model';
|
||||
import { IVariant, WeightType } from '../../../types/model';
|
||||
import { extractUsername } from '../../../util/extract-user';
|
||||
import { IAuthRequest } from '../../unleash-types';
|
||||
import { FeatureVariantsSchema } from '../../../openapi/spec/feature-variants-schema';
|
||||
import { createRequestSchema } from '../../../openapi/util/create-request-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 ENV_PREFIX =
|
||||
@ -37,16 +41,23 @@ export default class VariantsController extends Controller {
|
||||
|
||||
private featureService: FeatureToggleService;
|
||||
|
||||
private accessService: AccessService;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
featureToggleService,
|
||||
openApiService,
|
||||
}: Pick<IUnleashServices, 'featureToggleService' | 'openApiService'>,
|
||||
accessService,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
'featureToggleService' | 'openApiService' | 'accessService'
|
||||
>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('admin-api/project/variants.ts');
|
||||
this.featureService = featureToggleService;
|
||||
this.accessService = accessService;
|
||||
this.route({
|
||||
method: 'get',
|
||||
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(
|
||||
req: Request<FeatureEnvironmentParams, any, any, any>,
|
||||
res: Response<FeatureVariantsSchema>,
|
||||
|
@ -750,13 +750,12 @@ class FeatureToggleService {
|
||||
await this.featureEnvironmentStore.getEnvironmentsForFeature(
|
||||
featureName,
|
||||
);
|
||||
environments.forEach(async (featureEnv) => {
|
||||
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment(
|
||||
|
||||
await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(
|
||||
featureName,
|
||||
featureEnv.environment,
|
||||
environments.map((env) => env.environment),
|
||||
value.variants,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const tags = await this.tagStore.getAllTagsForFeature(featureName);
|
||||
@ -1350,9 +1349,51 @@ class FeatureToggleService {
|
||||
newVariants: fixedVariants,
|
||||
}),
|
||||
);
|
||||
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment(
|
||||
await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(
|
||||
featureName,
|
||||
[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,
|
||||
);
|
||||
return fixedVariants;
|
||||
|
@ -75,6 +75,12 @@ export interface IFeatureEnvironmentStore
|
||||
variants: IVariant[],
|
||||
): Promise<void>;
|
||||
|
||||
setVariantsToFeatureEnvironments(
|
||||
featureName: string,
|
||||
environments: string[],
|
||||
variants: IVariant[],
|
||||
): Promise<void>;
|
||||
|
||||
addFeatureEnvironment(
|
||||
featureEnvironment: IFeatureEnvironment,
|
||||
): Promise<void>;
|
||||
|
@ -10,6 +10,14 @@ let db: ITestDb;
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('project_feature_variants_api_serial', getLogger);
|
||||
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 () => {
|
||||
@ -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', {
|
||||
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 () => {
|
||||
const featureName = 'feature-variants-patch-add';
|
||||
const variantName = 'fancy-variant-patch';
|
||||
|
@ -2766,6 +2766,23 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"pushVariantsSchema": {
|
||||
"properties": {
|
||||
"environments": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"variants": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/variantSchema",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"requestsPerSecondSchema": {
|
||||
"properties": {
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getProjectHealthReport",
|
||||
|
@ -22,12 +22,24 @@ export default class FakeFeatureEnvironmentStore
|
||||
featureName: string,
|
||||
environment: string,
|
||||
variants: IVariant[],
|
||||
): Promise<void> {
|
||||
this.setVariantsToFeatureEnvironments(
|
||||
featureName,
|
||||
[environment],
|
||||
variants,
|
||||
);
|
||||
}
|
||||
|
||||
async setVariantsToFeatureEnvironments(
|
||||
featureName: string,
|
||||
environments: string[],
|
||||
variants: IVariant[],
|
||||
): Promise<void> {
|
||||
this.featureEnvironments
|
||||
.filter(
|
||||
(fe) =>
|
||||
fe.featureName === featureName &&
|
||||
fe.environment === environment,
|
||||
environments.indexOf(fe.environment) !== -1,
|
||||
)
|
||||
.map((fe) => (fe.variants = variants));
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user