1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

Merge branch 'master' into fix/sort-order

This commit is contained in:
Fredrik Strand Oseberg 2021-10-06 09:25:48 +02:00 committed by GitHub
commit 240786ea37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 955 additions and 11036 deletions

View File

@ -12,7 +12,7 @@
"ecmaVersion": 2019,
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint","prettier"],
"plugins": ["@typescript-eslint", "prettier", "import"],
"root": true,
"rules": {
"@typescript-eslint/no-var-requires": 0,

View File

@ -1,6 +1,6 @@
<div align="center">
![Build & Tests](https://github.com/Unleash/unleash/workflows/Build%20%26%20Tests/badge.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash/badge.svg?branch=master)](https://coveralls.io/github/Unleash/unleash?branch=master) [![npm](https://img.shields.io/npm/v/unleash-server)](https://www.npmjs.com/package/unleash-server) [![Docker Pulls](https://img.shields.io/docker/pulls/unleashorg/unleash-server)](https://hub.docker.com/r/unleashorg/unleash-server)
![Build & Tests](https://github.com/Unleash/unleash/workflows/Build%20%26%20Tests/badge.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash/badge.svg?branch=master&)](https://coveralls.io/github/Unleash/unleash?branch=master) [![npm](https://img.shields.io/npm/v/unleash-server)](https://www.npmjs.com/package/unleash-server) [![Docker Pulls](https://img.shields.io/docker/pulls/unleashorg/unleash-server)](https://hub.docker.com/r/unleashorg/unleash-server)
[![Deploy to Heroku](./.github/deploy-heroku-20.png)](https://www.heroku.com/deploy/?template=https://github.com/Unleash/unleash) [![Deploy to DigitalOcean](./.github/deploy-digital.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Unleash/unleash/tree/master&refcode=0e1d75187044) [![Twitter Follow](https://img.shields.io/twitter/follow/getunleash)](https://twitter.com/intent/follow?screen_name=getunleash)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "4.2.0-0",
"version": "4.2.0-2",
"keywords": [
"unleash",
"feature toggle",
@ -90,7 +90,7 @@
"gravatar-url": "^3.1.0",
"helmet": "^4.1.0",
"joi": "^17.3.0",
"js-yaml": "^3.14.0",
"js-yaml": "^4.1.0",
"knex": "0.95.11",
"log4js": "^6.0.0",
"memoizee": "^0.4.15",
@ -109,7 +109,7 @@
"response-time": "^2.3.2",
"serve-favicon": "^2.5.0",
"stoppable": "^1.1.0",
"unleash-frontend": "4.2.0",
"unleash-frontend": "4.2.2",
"uuid": "^8.3.2"
},
"devDependencies": {
@ -118,7 +118,7 @@
"@types/express-session": "1.17.4",
"@types/faker": "5.5.8",
"@types/jest": "27.0.2",
"@types/js-yaml": "3.12.7",
"@types/js-yaml": "4.0.3",
"@types/memoizee": "0.4.6",
"@types/node": "16.6.1",
"@types/node-fetch": "2.5.12",
@ -127,23 +127,23 @@
"@types/stoppable": "1.1.1",
"@types/supertest": "2.0.11",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "4.32.0",
"@typescript-eslint/parser": "4.32.0",
"@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/parser": "4.33.0",
"copyfiles": "2.4.1",
"coveralls": "3.1.1",
"del-cli": "4.0.1",
"eslint": "7.32.0",
"eslint-config-airbnb-base": "14.2.1",
"eslint-config-airbnb-typescript": "12.3.1",
"eslint-config-airbnb-typescript": "14.0.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.24.2",
"eslint-plugin-prettier": "4.0.0",
"faker": "5.5.3",
"fetch-mock": "9.11.0",
"husky": "7.0.2",
"jest": "27.2.2",
"jest": "27.2.4",
"jest-fetch-mock": "3.0.3",
"lint-staged": "11.1.2",
"lint-staged": "11.2.0",
"prettier": "2.4.1",
"proxyquire": "2.1.3",
"source-map-support": "0.5.20",

View File

@ -1,10 +1,9 @@
{
"extends": [
"config:base"
],
"extends": ["config:base"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"matchPackagePatterns": ["*"],
"automerge": true
}
]

View File

@ -4,14 +4,14 @@ import Addon from './addon';
import slackDefinition from './slack-definition';
import { IAddonConfig, IEvent } from '../types/model';
const {
import {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_STALE_ON,
FEATURE_STALE_OFF,
} = require('../types/events');
} from '../types/events';
export default class SlackAddon extends Addon {
unleashUrl: string;
@ -123,7 +123,7 @@ This was changed by ${createdBy}.`;
const stale = data.stale ? '("stale")' : '';
const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`;
const strategies = `*Activation strategies*: \`\`\`${YAML.safeDump(
const strategies = `*Activation strategies*: \`\`\`${YAML.dump(
data.strategies,
{ skipInvalid: true },
)}\`\`\``;

View File

@ -109,7 +109,7 @@ export default class TeamsAddon extends Addon {
const { data } = event;
const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`;
const strategies = `*Activation strategies*: \n${YAML.safeDump(
const strategies = `*Activation strategies*: \n${YAML.dump(
data.strategies,
{ skipInvalid: true },
)}`;

View File

@ -10,7 +10,6 @@ import { snakeCaseKeys } from '../util/snakeCase';
interface IEnvironmentsTable {
name: string;
display_name: string;
created_at?: Date;
type: string;
sort_order: number;
@ -20,7 +19,6 @@ interface IEnvironmentsTable {
const COLUMNS = [
'type',
'display_name',
'name',
'created_at',
'sort_order',
@ -31,7 +29,6 @@ const COLUMNS = [
function mapRow(row: IEnvironmentsTable): IEnvironment {
return {
name: row.name,
displayName: row.display_name,
type: row.type,
sortOrder: row.sort_order,
enabled: row.enabled,
@ -42,7 +39,6 @@ function mapRow(row: IEnvironmentsTable): IEnvironment {
function fieldToRow(env: IEnvironment): IEnvironmentsTable {
return {
name: env.name,
display_name: env.displayName,
type: env.type,
sort_order: env.sortOrder,
enabled: env.enabled,
@ -95,10 +91,14 @@ export default class EnvironmentStore implements IEnvironmentStore {
throw new NotFoundError(`Could not find environment with name: ${key}`);
}
async getAll(): Promise<IEnvironment[]> {
const rows = await this.db<IEnvironmentsTable>(TABLE)
async getAll(query?: Object): Promise<IEnvironment[]> {
let qB = this.db<IEnvironmentsTable>(TABLE)
.select('*')
.orderBy('sort_order', 'created_at');
if (query) {
qB = qB.where(query);
}
const rows = await qB;
return rows.map(mapRow);
}
@ -144,7 +144,7 @@ export default class EnvironmentStore implements IEnvironmentStore {
}
async update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>,
env: Pick<IEnvironment, 'type' | 'protected'>,
name: string,
): Promise<IEnvironment> {
const updatedEnv = await this.db<IEnvironmentsTable>(TABLE)

View File

@ -10,7 +10,10 @@ import { DB_TIME } from '../metric-events';
import { IFeatureEnvironment } from '../types/model';
import NotFoundError from '../error/notfound-error';
const T = { featureEnvs: 'feature_environments' };
const T = {
featureEnvs: 'feature_environments',
featureStrategies: 'feature_strategies',
};
interface IFeatureEnvironmentRow {
environment: string;
@ -92,6 +95,22 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
}));
}
async disableEnvironmentIfNoStrategies(
featureName: string,
environment: string,
): Promise<void> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureStrategies} WHERE feature_name = ? AND environment = ?) AS enabled`,
[featureName, environment],
);
const { enabled } = result.rows[0];
if (!enabled) {
await this.db(T.featureEnvs)
.update({ enabled: false })
.where({ feature_name: featureName, environment });
}
}
async addEnvironmentToFeature(
featureName: string,
environment: string,

View File

@ -213,7 +213,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'environments.name as environment_name',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
'environments.display_name as environment_display_name',
'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters',
@ -266,7 +265,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
env.enabled = r.enabled;
env.type = r.environment_type;
env.sortOrder = r.environment_sort_order;
env.displayName = r.environment_display_name;
if (!env.strategies) {
env.strategies = [];
}
@ -300,7 +298,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
private getEnvironment(r: any): IEnvironmentOverview {
return {
name: r.environment,
displayName: r.display_name,
enabled: r.enabled,
type: r.environment_type,
sortOrder: r.environment_sort_order,
@ -321,7 +318,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'features.stale as stale',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'environments.display_name as display_name',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
)

View File

@ -45,16 +45,20 @@ export default class FeatureToggleClientStore
r: any,
includeId: boolean = true,
): IStrategyConfig {
const strategy = {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
id: r.strategy_id,
};
if (!includeId) {
delete strategy.id;
if (includeId) {
return {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
id: r.strategy_id,
};
} else {
return {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
};
}
return strategy;
}
private async getAll(
@ -74,28 +78,29 @@ export default class FeatureToggleClientStore
'features.variants as variants',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters',
'feature_strategies.constraints as constraints',
'fe.enabled as enabled',
'fe.environment as environment',
'fs.id as strategy_id',
'fs.strategy_name as strategy_name',
'fs.parameters as parameters',
'fs.constraints as constraints',
)
.fullOuterJoin(
'feature_environments',
'feature_environments.feature_name',
this.db('feature_strategies')
.select('*')
.where({ environment })
.as('fs'),
'fs.feature_name',
'features.name',
)
.fullOuterJoin(
this.db('feature_environments')
.select('feature_name', 'enabled', 'environment')
.where({ environment })
.as('fe'),
'fe.feature_name',
'features.name',
)
.fullOuterJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'features.name',
).andOn(
'feature_strategies.environment',
'feature_environments.environment',
);
})
.where('feature_environments.environment', environment)
.where({ archived });
if (featureQuery) {
@ -117,6 +122,7 @@ export default class FeatureToggleClientStore
);
}
}
const rows = await query;
stopTimer();
const featureToggles = rows.reduce((acc, r) => {
@ -132,7 +138,7 @@ export default class FeatureToggleClientStore
if (r.strategy_name) {
feature.strategies.push(this.getAdminStrategy(r, isAdmin));
}
feature.enabled = r.enabled;
feature.enabled = !!r.enabled;
feature.name = r.name;
feature.description = r.description;
feature.project = r.project;

View File

@ -6,6 +6,7 @@ import { IProject } from '../types/model';
import {
IProjectHealthUpdate,
IProjectInsert,
IProjectQuery,
IProjectStore,
} from '../types/stores/project-store';
import { DEFAULT_ENV } from '../util/constants';
@ -43,10 +44,11 @@ class ProjectStore implements IProjectStore {
return present;
}
async getAll(): Promise<IProject[]> {
async getAll(query: IProjectQuery = {}): Promise<IProject[]> {
const rows = await this.db
.select(COLUMNS)
.from(TABLE)
.where(query)
.orderBy('name', 'asc');
return rows.map(this.mapRow);

View File

@ -300,12 +300,13 @@ export default class ProjectFeaturesController extends Controller {
res: Response,
): Promise<void> {
this.logger.info('Deleting strategy');
const { environment, projectId } = req.params;
const { environment, projectId, featureName } = req.params;
const userName = extractUsername(req);
const { strategyId } = req.params;
this.logger.info(strategyId);
const strategy = await this.featureService.deleteStrategy(
strategyId,
featureName,
userName,
projectId,
environment,

View File

@ -1,15 +1,28 @@
import { Request, Response } from 'express';
import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services';
import ProjectFeaturesController from './features';
import EnvironmentsController from './environments';
import ProjectHealthReport from './health-report';
import ProjectService from '../../../services/project-service';
export default class ProjectApi extends Controller {
private projectService: ProjectService;
constructor(config: IUnleashConfig, services: IUnleashServices) {
super(config);
this.projectService = services.projectService;
this.get('/', this.getProjects);
this.use('/', new ProjectFeaturesController(config, services).router);
this.use('/', new EnvironmentsController(config, services).router);
this.use('/', new ProjectHealthReport(config, services).router);
}
async getProjects(req: Request, res: Response): Promise<void> {
const projects = await this.projectService.getProjects({
id: 'default',
});
res.json({ version: 1, projects }).end();
}
}

View File

@ -50,7 +50,7 @@ class StateController extends Controller {
// @ts-ignore
if (mime.getType(req.file.originalname) === 'text/yaml') {
// @ts-ignore
data = YAML.safeLoad(req.file.buffer);
data = YAML.load(req.file.buffer);
} else {
// @ts-ignore
data = JSON.parse(req.file.buffer);
@ -93,7 +93,7 @@ class StateController extends Controller {
if (downloadFile) {
res.attachment(`export-${timestamp}.yml`);
}
res.type('yaml').send(YAML.safeDump(data, { skipInvalid: true }));
res.type('yaml').send(YAML.dump(data, { skipInvalid: true }));
} else {
if (downloadFile) {
res.attachment(`export-${timestamp}.json`);

View File

@ -140,8 +140,12 @@ export default class ClientMetricsService {
): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data);
const toggleNames = Object.keys(value.bucket.toggles);
await this.featureToggleStore.setLastSeen(toggleNames);
await this.clientMetricsStore.insert(value);
if (toggleNames.length > 0) {
await this.featureToggleStore.setLastSeen(toggleNames);
await this.clientMetricsStore.insert(value);
}
await this.clientInstanceStore.insert({
appName: value.appName,
instanceId: value.instanceId,

View File

@ -3,7 +3,6 @@ import { IUnleashStores } from '../types/stores';
import { Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store';
import { IEvent } from '../types/model';
import { FEATURE_METADATA_UPDATED } from '../types/events';
export default class EventService {
private logger: Logger;
@ -23,10 +22,7 @@ export default class EventService {
}
async getEventsForToggle(name: string): Promise<IEvent[]> {
const events = await this.eventStore.getEventsFilterByType(name);
return events.filter(
(e: IEvent) => e.type !== FEATURE_METADATA_UPDATED,
);
return this.eventStore.getEventsFilterByType(name);
}
async getEventsForProject(project: string): Promise<IEvent[]> {

View File

@ -226,6 +226,7 @@ class FeatureToggleServiceV2 {
*/
async deleteStrategy(
id: string,
featureName: string,
userName: string,
project: string = 'default',
environment: string = DEFAULT_ENV,
@ -240,6 +241,11 @@ class FeatureToggleServiceV2 {
id,
},
});
// If there are no strategies left for environment disable it
await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(
featureName,
environment,
);
}
async getStrategiesForEnvironment(
@ -576,7 +582,7 @@ class FeatureToggleServiceV2 {
? FEATURE_ENVIRONMENT_ENABLED
: FEATURE_ENVIRONMENT_DISABLED,
createdBy: userName,
data,
data: { name: featureName },
tags,
project: projectId,
environment,

View File

@ -63,6 +63,9 @@ export default class ProjectHealthService {
archived: boolean = false,
): Promise<IProjectOverview> {
const project = await this.projectStore.get(projectId);
const environments = await this.projectStore.getEnvironmentsForProject(
projectId,
);
const features = await this.featureToggleService.getFeatureOverview(
projectId,
archived,
@ -72,6 +75,7 @@ export default class ProjectHealthService {
name: project.name,
description: project.description,
health: project.health,
environments,
features,
members,
version: 1,

View File

@ -24,13 +24,12 @@ import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectStore } from '../types/stores/project-store';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import { IRole } from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store';
import FeatureToggleServiceV2 from './feature-toggle-service-v2';
import { CREATE_FEATURE, UPDATE_FEATURE } from '../types/permissions';
import NoAccessError from '../error/no-access-error';
import { DEFAULT_ENV } from '../util/constants';
const getCreatedBy = (user: User) => user.email || user.username;
@ -92,8 +91,8 @@ export default class ProjectService {
this.logger = config.getLogger('services/project-service.js');
}
async getProjects(): Promise<IProjectWithCount[]> {
const projects = await this.store.getAll();
async getProjects(query?: IProjectQuery): Promise<IProjectWithCount[]> {
const projects = await this.store.getAll(query);
const projectsWithCount = await Promise.all(
projects.map(async (p) => {
let featureCount = 0;
@ -123,8 +122,17 @@ export default class ProjectService {
await this.store.create(data);
// TODO: we should only connect to enabled environments
await this.featureEnvironmentStore.connectProject(DEFAULT_ENV, data.id);
const enabledEnvironments = await this.environmentStore.getAll({
enabled: true,
});
await Promise.all(
enabledEnvironments.map(async (e) => {
await this.featureEnvironmentStore.connectProject(
e.name,
data.id,
);
}),
);
await this.accessService.createDefaultProjectRoles(user, data.id);
@ -302,6 +310,9 @@ export default class ProjectService {
archived: boolean = false,
): Promise<IProjectOverview> {
const project = await this.store.get(projectId);
const environments = await this.store.getEnvironmentsForProject(
projectId,
);
const features = await this.featureToggleService.getFeatureOverview(
projectId,
archived,
@ -309,6 +320,7 @@ export default class ProjectService {
const members = await this.store.getMembers(projectId);
return {
name: project.name,
environments,
description: project.description,
health: project.health,
features,

View File

@ -533,12 +533,10 @@ test('exporting to new format works', async () => {
});
await stores.environmentStore.create({
name: 'dev',
displayName: 'Development',
type: 'development',
});
await stores.environmentStore.create({
name: 'prod',
displayName: 'Production',
type: 'production',
});
await stores.featureToggleStore.create('fancy', {
@ -575,12 +573,10 @@ test('featureStrategies can keep existing', async () => {
});
await stores.environmentStore.create({
name: 'dev',
displayName: 'Development',
type: 'development',
});
await stores.environmentStore.create({
name: 'prod',
displayName: 'Production',
type: 'production',
});
await stores.featureToggleStore.create('fancy', {
@ -623,12 +619,10 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
});
await stores.environmentStore.create({
name: 'dev',
displayName: 'Development',
type: 'development',
});
await stores.environmentStore.create({
name: 'prod',
displayName: 'Production',
type: 'production',
});
await stores.featureToggleStore.create('fancy', {

View File

@ -12,8 +12,7 @@ export const readFile: (file: string) => Promise<string> = (file) =>
export const parseFile: (file: string, data: string) => any = (
file: string,
data: string,
) =>
mime.lookup(file) === 'text/yaml' ? YAML.safeLoad(data) : JSON.parse(data);
) => (mime.lookup(file) === 'text/yaml' ? YAML.load(data) : JSON.parse(data));
export const filterExisting: (
keepExisting: boolean,

View File

@ -103,7 +103,6 @@ export interface IVariant {
export interface IEnvironment {
name: string;
displayName: string;
type: string;
sortOrder: number;
enabled: boolean;
@ -112,14 +111,13 @@ export interface IEnvironment {
export interface IEnvironmentCreate {
name: string;
displayName: string;
type: string;
sortOrder?: number;
enabled?: boolean;
}
export interface IEnvironmentOverview {
name: string;
displayName: string;
enabled: boolean;
type: string;
sortOrder: number;
@ -137,6 +135,7 @@ export interface IFeatureOverview {
export interface IProjectOverview {
name: string;
description: string;
environments: string[];
features: IFeatureOverview[];
members: number;
version: number;

View File

@ -5,7 +5,7 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
exists(name: string): Promise<boolean>;
create(env: IEnvironmentCreate): Promise<IEnvironment>;
update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>,
env: Pick<IEnvironment, 'type' | 'protected'>,
name: string,
): Promise<IEnvironment>;
updateProperty(

View File

@ -34,7 +34,10 @@ export interface IFeatureEnvironmentStore
environment: string,
enabled: boolean,
): Promise<void>;
disableEnvironmentIfNoStrategies(
featureName: string,
environment: string,
): Promise<void>;
disconnectFeatures(environment: string, project: string): Promise<void>;
connectFeatures(environment: string, projectId: string): Promise<void>;

View File

@ -17,6 +17,10 @@ export interface IProjectHealthUpdate {
health: number;
}
export interface IProjectQuery {
id?: string;
}
export interface IProjectStore extends Store<IProject, string> {
hasProject(id: string): Promise<boolean>;
updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>;
@ -28,4 +32,5 @@ export interface IProjectStore extends Store<IProject, string> {
getEnvironmentsForProject(id: string): Promise<string[]>;
getMembers(projectId: string): Promise<number>;
count(): Promise<number>;
getAll(query?: IProjectQuery): Promise<IProject[]>;
}

View File

@ -0,0 +1,17 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`ALTER TABLE environments
DROP COLUMN display_name`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`ALTER TABLE environments
ADD COLUMN display_name TEXT`,
cb,
);
};

View File

@ -0,0 +1,23 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO environments(name, type, enabled)
VALUES ('development', 'development', true),
('production', 'production', true);
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DELETE
FROM environments
WHERE name IN ('development', 'production');
`,
cb,
);
};

View File

@ -0,0 +1,22 @@
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO project_environments(project_id, environment_name)
SELECT id, 'default'
FROM projects
ON CONFLICT DO NOTHING;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DELETE
FROM project_environments
WHERE environment_name = 'default';
`,
cb,
);
};

View File

@ -24,7 +24,6 @@ test('Can list all existing environments', async () => {
.expect((res) => {
expect(res.body.version).toBe(1);
expect(res.body.environments[0]).toStrictEqual({
displayName: 'Default Environment',
name: DEFAULT_ENV,
enabled: true,
sortOrder: 1,
@ -38,7 +37,6 @@ test('Can update sort order', async () => {
const envName = 'update-sort-order';
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
await app.request
@ -81,7 +79,6 @@ test('Can update environment enabled status', async () => {
const envName = 'enable-environment';
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
await app.request
@ -95,7 +92,6 @@ test('Can update environment disabled status', async () => {
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
@ -128,7 +124,6 @@ test('Can get specific environment', async () => {
await db.stores.environmentStore.create({
name: envName,
type: 'production',
displayName: 'Fun!',
});
await app.request
.get(`/api/admin/environments/${envName}`)

View File

@ -36,7 +36,6 @@ test('Should add environment to project', async () => {
// Endpoint to create env does not exists anymore
await db.stores.environmentStore.create({
name: 'test',
displayName: 'Test Env',
type: 'test',
});
await app.request
@ -67,7 +66,6 @@ test('Should remove environment from project', async () => {
await db.stores.environmentStore.create({
name,
displayName: 'Test Env',
type: 'test',
});

View File

@ -174,7 +174,6 @@ test('Project overview includes environment connected to feature', async () => {
});
await db.stores.environmentStore.create({
name: 'project-overview',
displayName: 'Project Overview',
type: 'production',
});
await app.request
@ -208,7 +207,6 @@ test('Disconnecting environment from project, removes environment from features
});
await db.stores.environmentStore.create({
name: 'dis-project-overview',
displayName: 'Project Overview',
type: 'production',
});
await app.request
@ -236,7 +234,6 @@ test('Can enable/disable environment for feature with strategies', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -386,7 +383,6 @@ test('Can get environment info for feature toggle', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -550,7 +546,6 @@ test('Can add strategy to feature toggle to a "some-env-2"', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -591,13 +586,11 @@ test('Environments are returned in sortOrder', async () => {
// Create environments
await db.stores.environmentStore.create({
name: sortedLast,
displayName: 'Enable feature for environment',
type: 'production',
sortOrder: 8000,
});
await db.stores.environmentStore.create({
name: sortedSecond,
displayName: 'Enable feature for environment',
type: 'production',
sortOrder: 8,
});
@ -659,7 +652,6 @@ test('Can get strategies for feature and environment', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -714,7 +706,6 @@ test('Can update a strategy based on id', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -767,7 +758,6 @@ test('Trying to update a non existing feature strategy should yield 404', async
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -798,7 +788,6 @@ test('Can patch a strategy based on id', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -851,7 +840,6 @@ test('Trying to get a non existing feature strategy should yield 404', async ()
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'production',
});
// Connect environment to project
@ -880,7 +868,6 @@ test('Can not enable environment for feature without strategies', async () => {
// Create environment
await db.stores.environmentStore.create({
name: environment,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -922,7 +909,6 @@ test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async (
// Create environment
await db.stores.environmentStore.create({
name: environment,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -965,7 +951,6 @@ test('Disabling environment creates a FEATURE_ENVIRONMENT_DISABLED event', async
// Create environment
await db.stores.environmentStore.create({
name: environment,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -1009,7 +994,6 @@ test('Can delete strategy from feature toggle', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -1053,7 +1037,6 @@ test('List of strategies should respect sortOrder', async () => {
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Enable feature for environment',
type: 'test',
});
// Connect environment to project
@ -1084,12 +1067,10 @@ test('Feature strategies list should respect strategy sortorders for each enviro
// Create environment
await db.stores.environmentStore.create({
name: envName,
displayName: 'Sort orders within environment',
type: 'test',
});
await db.stores.environmentStore.create({
name: secondEnv,
displayName: 'Sort orders within environment',
type: 'test',
});
// Connect environment to project
@ -1127,3 +1108,140 @@ test('Feature strategies list should respect strategy sortorders for each enviro
expect(strategies[1].sortOrder).toBe(sortOrderSecond);
expect(strategies[2].sortOrder).toBe(sortOrderDefault);
});
test('Deleting last strategy for feature environment should disable that environment', async () => {
const envName = 'last_strategy_delete_env';
const featureName = 'last_strategy_delete_feature';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
let strategyId;
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200)
.expect((res) => {
strategyId = res.body.id;
});
// Enable feature_environment
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/on`,
)
.send({})
.expect(200);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
// Delete last strategy, this should also disable the environment
await app.request.delete(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategyId}`,
);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeFalsy();
});
});
test('Deleting strategy for feature environment should not disable that environment as long as there are other strategies', async () => {
const envName = 'any_strategy_delete_env';
const featureName = 'any_strategy_delete_feature';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
let strategyId;
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200)
.expect((res) => {
strategyId = res.body.id;
});
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
customerId: 'string',
},
})
.expect(200);
// Enable feature_environment
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/on`,
)
.send({})
.expect(200);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
// Delete a strategy, this should also disable the environment
await app.request.delete(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategyId}`,
);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
});

View File

@ -49,6 +49,8 @@ test('Project with no stale toggles should have 100% health rating', async () =>
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.health).toBe(100);
expect(res.body.environments).toHaveLength(1);
expect(res.body.environments).toStrictEqual(['default']);
});
});

View File

@ -0,0 +1,32 @@
import dbInit from '../../../helpers/database-init';
import { setupApp } from '../../../helpers/test-helper';
import getLogger from '../../../../fixtures/no-logger';
import ProjectStore from '../../../../../lib/db/project-store';
let app;
let db;
let projectStore: ProjectStore;
beforeAll(async () => {
db = await dbInit('projects_api_serial', getLogger);
app = await setupApp(db.stores);
projectStore = db.stores.projectStore;
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('Should ONLY return default project', async () => {
projectStore.create({ id: 'test2', name: 'test', description: '' });
const { body } = await app.request
.get('/api/admin/projects')
.expect(200)
.expect('Content-Type', /json/);
expect(body.projects).toHaveLength(1);
expect(body.projects[0].id).toBe('default');
});

View File

@ -142,7 +142,6 @@ test('Can roundtrip. I.e. export and then import', async () => {
await db.stores.environmentStore.create({
name: environmentId,
type: 'test',
displayName: 'Environment for export',
});
await db.stores.projectStore.create({
name: projectId,
@ -191,7 +190,6 @@ test('Roundtrip with tags works', async () => {
await db.stores.environmentStore.create({
name: environmentId,
type: 'test',
displayName: 'Environment for export',
});
await db.stores.projectStore.create({
name: projectId,
@ -253,7 +251,6 @@ test('Roundtrip with strategies in multiple environments works', async () => {
await db.stores.environmentStore.create({
name: environmentId,
type: 'test',
displayName: 'Environment for export',
});
await db.stores.projectStore.create({
name: projectId,

View File

@ -176,7 +176,6 @@ test('Can get strategies for specific environment', async () => {
await db.stores.environmentStore.create({
name: 'testing',
displayName: 'simple test',
type: 'test',
});

View File

@ -0,0 +1,70 @@
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
let app: IUnleashTest;
let db: ITestDb;
const featureName = 'feature.default.1';
beforeAll(async () => {
db = await dbInit('feature_api_client', getLogger);
app = await setupApp(db.stores);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: featureName,
description: 'the #1 feature',
},
'test',
);
await app.services.featureToggleServiceV2.createStrategy(
{ name: 'default', constraints: [], parameters: {} },
'default',
featureName,
'test',
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('returns feature toggle for default env', async () => {
await app.services.featureToggleServiceV2.updateEnabled(
'default',
'feature.default.1',
'default',
true,
'test',
);
await app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].enabled).toBe(true);
expect(res.body.features[0].strategies).toHaveLength(1);
});
});
test('returns feature toggle for default env even if it is removed from project', async () => {
await app.services.environmentService.removeEnvironmentFromProject(
'default',
'default',
);
await app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].enabled).toBe(false);
expect(res.body.features[0].strategies).toHaveLength(1);
});
});

View File

@ -28,7 +28,6 @@ beforeAll(async () => {
await environmentStore.create({
name: environment,
displayName: '',
type: 'test',
});

View File

@ -23,7 +23,6 @@ test('should enrich metrics with environment from api-token', async () => {
await environmentStore.create({
name: 'some',
displayName: '',
type: 'test',
});

View File

@ -30,8 +30,8 @@
"environments": [
{
"name": "default",
"displayName": "Default Environment",
"type": "production",
"sortOrder": 1,
"enabled": true,
"protected": true

View File

@ -22,7 +22,6 @@ afterAll(async () => {
test('Can get environment', async () => {
const created = await db.stores.environmentStore.create({
name: 'testenv',
displayName: 'Environment for testing',
type: 'production',
});
@ -33,7 +32,6 @@ test('Can get environment', async () => {
test('Can get all', async () => {
await db.stores.environmentStore.create({
name: 'testenv2',
displayName: 'Environment for testing',
type: 'production',
});
@ -44,7 +42,6 @@ test('Can get all', async () => {
test('Can connect environment to project', async () => {
await db.stores.environmentStore.create({
name: 'test-connection',
displayName: '',
type: 'production',
});
await stores.featureToggleStore.create('default', {
@ -63,7 +60,6 @@ test('Can connect environment to project', async () => {
expect(f.environments).toEqual([
{
name: 'test-connection',
displayName: '',
enabled: false,
sortOrder: 9999,
type: 'production',
@ -75,7 +71,6 @@ test('Can connect environment to project', async () => {
test('Can remove environment from project', async () => {
await db.stores.environmentStore.create({
name: 'removal-test',
displayName: '',
type: 'production',
});
await stores.featureToggleStore.create('default', {
@ -92,7 +87,6 @@ test('Can remove environment from project', async () => {
expect(f.environments).toEqual([
{
name: 'removal-test',
displayName: '',
enabled: false,
sortOrder: 9999,
type: 'production',
@ -113,7 +107,6 @@ test('Can remove environment from project', async () => {
test('Adding same environment twice should throw a NameExistsError', async () => {
await db.stores.environmentStore.create({
name: 'uniqueness-test',
displayName: '',
type: 'production',
});
await service.removeEnvironmentFromProject('test-connection', 'default');

View File

@ -132,7 +132,7 @@ test('should validate name, legal', async () => {
expect(result).toBe(true);
});
test('should not be able to create exiting project', async () => {
test('should not be able to create existing project', async () => {
const project = {
id: 'test-delete',
name: 'New project',
@ -510,3 +510,29 @@ test('should change project when checks pass', async () => {
expect(updatedFeature.project).toBe(projectDestination.id);
});
test('A newly created project only gets connected to enabled environments', async () => {
const project = {
id: 'environment-test',
name: 'New environment project',
description: 'Blah',
};
const enabledEnv = 'connection_test';
await db.stores.environmentStore.create({
name: enabledEnv,
type: 'test',
});
const disabledEnv = 'do_not_connect';
await db.stores.environmentStore.create({
name: disabledEnv,
type: 'test',
enabled: false,
});
await projectService.createProject(project, user);
const connectedEnvs =
await db.stores.projectStore.getEnvironmentsForProject(project.id);
expect(connectedEnvs).toHaveLength(2); // default, connection_test
expect(connectedEnvs.some((e) => e === enabledEnv)).toBeTruthy();
expect(connectedEnvs.some((e) => e === disabledEnv)).toBeFalsy();
});

View File

@ -123,7 +123,6 @@ test('should add environment to project', async () => {
await environmentStore.create({
name: 'test',
displayName: 'Test Env',
type: 'production',
});

View File

@ -37,7 +37,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
}
async update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>,
env: Pick<IEnvironment, 'type' | 'protected'>,
name: string,
): Promise<IEnvironment> {
const found = this.environments.find(

View File

@ -148,4 +148,13 @@ export default class FakeFeatureEnvironmentStore
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
disableEnvironmentIfNoStrategies(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
}

View File

@ -49,5 +49,6 @@ To make use of toggle variants, you need to use a compatible client. Client SDK
- [unleash-client-python](https://github.com/Unleash/unleash-client-python) (from v3.3.0)
- [unleash-client-dotnet](https://github.com/Unleash/unleash-client-dotnet) (from v1.3.6)
- [unleash-client-go](https://github.com/Unleash/unleash-client-go) (from v3 branch)
- [unleash-client-php](https://github.com/Unleash/unleash-client-php) (from v1.0.0)
If you would like to give feedback on this feature, experience issues or have questions, please feel free to open an issue on [GitHub](https://github.com/Unleash/unleash/).

View File

@ -76,4 +76,26 @@ $context = new UnleashContext(
$unleash->isEnabled("someToggle", $context);
```
**b) Via a UnleashContextProvider**
This is a bit more advanced approach, where you configure a unleash-context provider. By doing this you do not have to rebuild or to pass the unleash-context object to every place you are calling `$unleash->isEnabled()`.
```php
<?php
use Unleash\Client\UnleashBuilder;
$contextProvider = new MyAwesomeContextProvider();
$unleash = UnleashBuilder::create()
->withAppName('my.php-app')
->withInstanceId('your-instance-1')
->withAppUrl('http://unleash.herokuapp.com/api/')
->withContextProvider($contextProvider)
->build();
// Anywhere in the code unleash will get the unleash context from your registered provider.
$unleash->isEnabled("someToggle");
```
> You can read more complete documentation in the [Client SDK repository](https://github.com/Unleash/unleash-client-php).

942
yarn.lock

File diff suppressed because it is too large Load Diff