diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index c953618298..609b84065b 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -41,6 +41,7 @@ Object { }, "enableOAS": false, "enterpriseVersion": undefined, + "environmentEnableOverrides": Array [], "eventBus": EventEmitter { "_events": Object {}, "_eventsCount": 0, diff --git a/src/lib/create-config.test.ts b/src/lib/create-config.test.ts index 92eba02bab..be9c0044a2 100644 --- a/src/lib/create-config.test.ts +++ b/src/lib/create-config.test.ts @@ -224,3 +224,47 @@ test('should handle cases where no env var specified for tokens', async () => { expect(config.authentication.initApiTokens).toHaveLength(1); }); + +test('should load environment overrides from env var', async () => { + process.env.ENABLED_ENVIRONMENTS = 'default,production'; + + const config = createConfig({ + db: { + host: 'localhost', + port: 4242, + user: 'unleash', + password: 'password', + database: 'unleash_db', + }, + server: { + port: 4242, + }, + authentication: { + initApiTokens: [], + }, + }); + + expect(config.environmentEnableOverrides).toHaveLength(2); + expect(config.environmentEnableOverrides).toContain('production'); + delete process.env.ENABLED_ENVIRONMENTS; +}); + +test('should yield an empty list when no environment overrides are specified', async () => { + const config = createConfig({ + db: { + host: 'localhost', + port: 4242, + user: 'unleash', + password: 'password', + database: 'unleash_db', + }, + server: { + port: 4242, + }, + authentication: { + initApiTokens: [], + }, + }); + + expect(config.environmentEnableOverrides).toStrictEqual([]); +}); diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 91c6d1223e..9ab90b57c6 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -217,6 +217,14 @@ const loadInitApiTokens = () => { ]; }; +const loadEnvironmentEnableOverrides = () => { + const environmentsString = process.env.ENABLED_ENVIRONMENTS; + if (environmentsString) { + return environmentsString.split(','); + } + return []; +}; + export function createConfig(options: IUnleashOptions): IUnleashConfig { let extraDbOptions = {}; @@ -275,6 +283,8 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { { initApiTokens: initApiTokens }, ]); + const environmentEnableOverrides = loadEnvironmentEnableOverrides(); + const importSetting: IImportOption = mergeAll([ defaultImport, options.import, @@ -323,6 +333,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { eventHook: options.eventHook, enterpriseVersion: options.enterpriseVersion, eventBus: new EventEmitter(), + environmentEnableOverrides, }; } diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index ca000b1e86..f8250ec703 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -163,6 +163,28 @@ export default class EnvironmentStore implements IEnvironmentStore { return mapRow(row[0]); } + async disable(environments: IEnvironment[]): Promise { + await this.db(TABLE) + .update({ + enabled: false, + }) + .whereIn( + 'name', + environments.map((env) => env.name), + ); + } + + async enable(environments: IEnvironment[]): Promise { + await this.db(TABLE) + .update({ + enabled: true, + }) + .whereIn( + 'name', + environments.map((env) => env.name), + ); + } + async delete(name: string): Promise { await this.db(TABLE).where({ name, protected: false }).del(); } diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 2c08a845d5..8b10a12ad1 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -24,6 +24,11 @@ const COLUMNS = [ ]; const TABLE = 'projects'; +export interface IEnvironmentProjectLink { + environmentName: string; + projectId: string; +} + class ProjectStore implements IProjectStore { private db: Knex; @@ -197,6 +202,15 @@ class ProjectStore implements IProjectStore { } } + async getProjectLinksForEnvironments( + environments: string[], + ): Promise { + let rows = await this.db('project_environments') + .select(['project_id', 'environment_name']) + .whereIn('environment_name', environments); + return rows.map(this.mapLinkRow); + } + async deleteEnvironmentForProject( id: string, environment: string, @@ -251,6 +265,14 @@ class ProjectStore implements IProjectStore { .then((res) => Number(res[0].count)); } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + mapLinkRow(row): IEnvironmentProjectLink { + return { + environmentName: row.environment_name, + projectId: row.project_id, + }; + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types mapRow(row): IProject { if (!row) { diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts index af038507a4..1c2904588f 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -87,6 +87,12 @@ async function createApp( }); } + if (config.environmentEnableOverrides?.length > 0) { + await services.environmentService.overrideEnabledProjects( + config.environmentEnableOverrides, + ); + } + return new Promise((resolve, reject) => { if (startApp) { const server = stoppable( diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts index 48c9ce91da..891eca0a07 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -94,6 +94,90 @@ export default class EnvironmentService { } } + async overrideEnabledProjects( + environmentNamesToEnable: string[], + ): Promise { + if (environmentNamesToEnable.length === 0) { + return Promise.resolve(); + } + + const allEnvironments = await this.environmentStore.getAll(); + const existingEnvironmentsToEnable = allEnvironments.filter((env) => + environmentNamesToEnable.includes(env.name), + ); + + if ( + existingEnvironmentsToEnable.length !== + environmentNamesToEnable.length + ) { + this.logger.warn( + "Found environment enabled overrides but some of the specified environments don't exist, no overrides will be executed", + ); + return Promise.resolve(); + } + + const environmentsNotAlreadyEnabled = + existingEnvironmentsToEnable.filter((env) => env.enabled == false); + const environmentsToDisable = allEnvironments.filter((env) => { + return ( + !environmentNamesToEnable.includes(env.name) && + env.enabled == true + ); + }); + + await this.environmentStore.disable(environmentsToDisable); + await this.environmentStore.enable(environmentsNotAlreadyEnabled); + + await this.remapProjectsLinks( + environmentsToDisable, + environmentsNotAlreadyEnabled, + ); + } + + private async remapProjectsLinks( + toDisable: IEnvironment[], + toEnable: IEnvironment[], + ) { + const projectLinks = + await this.projectStore.getProjectLinksForEnvironments( + toDisable.map((env) => env.name), + ); + + const unlinkTasks = projectLinks.map((link) => { + return this.forceRemoveEnvironmentFromProject( + link.environmentName, + link.projectId, + ); + }); + await Promise.all(unlinkTasks.flat()); + + const uniqueProjects = [ + ...new Set(projectLinks.map((link) => link.projectId)), + ]; + + let linkTasks = uniqueProjects.map((project) => { + return toEnable.map((enabledEnv) => { + return this.addEnvironmentToProject(enabledEnv.name, project); + }); + }); + + await Promise.all(linkTasks.flat()); + } + + async forceRemoveEnvironmentFromProject( + environment: string, + projectId: string, + ): Promise { + await this.featureEnvironmentStore.disconnectFeatures( + environment, + projectId, + ); + await this.featureEnvironmentStore.disconnectProject( + environment, + projectId, + ); + } + async removeEnvironmentFromProject( environment: string, projectId: string, @@ -103,11 +187,7 @@ export default class EnvironmentService { ); if (projectEnvs.length > 1) { - await this.featureEnvironmentStore.disconnectFeatures( - environment, - projectId, - ); - await this.featureEnvironmentStore.disconnectProject( + await this.forceRemoveEnvironmentFromProject( environment, projectId, ); diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index f60413cd4a..61212860e0 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -158,4 +158,5 @@ export interface IUnleashConfig { enterpriseVersion?: string; eventBus: EventEmitter; disableLegacyFeaturesApi?: boolean; + environmentEnableOverrides?: string[]; } diff --git a/src/lib/types/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index c6fe2600f2..4c1f4c48b7 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -16,4 +16,6 @@ export interface IEnvironmentStore extends Store { updateSortOrder(id: string, value: number): Promise; importEnvironments(environments: IEnvironment[]): Promise; delete(name: string): Promise; + disable(environments: IEnvironment[]): Promise; + enable(environments: IEnvironment[]): Promise; } diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index 2e4c41d8b5..aca4caa950 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -1,3 +1,4 @@ +import { IEnvironmentProjectLink } from 'lib/db/project-store'; import { IProject, IProjectWithCount } from '../model'; import { Store } from './store'; @@ -35,4 +36,7 @@ export interface IProjectStore extends Store { getProjectsWithCounts(query?: IProjectQuery): Promise; count(): Promise; getAll(query?: IProjectQuery): Promise; + getProjectLinksForEnvironments( + environments: string[], + ): Promise; } diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts index 8cb953b733..ac21c74df5 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -136,3 +136,134 @@ test('Trying to get an environment that does not exist throws NotFoundError', as new NotFoundError(`Could not find environment with name: ${envName}`), ); }); + +test('Setting an override disables all other envs', async () => { + const enabledEnvName = 'should-get-enabled'; + const disabledEnvName = 'should-get-disabled'; + await db.stores.environmentStore.create({ + name: disabledEnvName, + type: 'production', + }); + + await db.stores.environmentStore.create({ + name: enabledEnvName, + type: 'production', + }); + + //Set these to the wrong state so we can assert that overriding them flips their state + await service.toggleEnvironment(disabledEnvName, true); + await service.toggleEnvironment(enabledEnvName, false); + + await service.overrideEnabledProjects([enabledEnvName]); + + const environments = await service.getAll(); + const targetedEnvironment = environments.find( + (env) => env.name == enabledEnvName, + ); + + const allOtherEnvironments = environments + .filter((x) => x.name != enabledEnvName) + .map((env) => env.enabled); + + expect(targetedEnvironment.enabled).toBe(true); + expect(allOtherEnvironments.every((x) => x === false)).toBe(true); +}); + +test('Passing an empty override does nothing', async () => { + const enabledEnvName = 'should-be-enabled'; + + await db.stores.environmentStore.create({ + name: enabledEnvName, + type: 'production', + }); + + await service.toggleEnvironment(enabledEnvName, true); + + await service.overrideEnabledProjects([]); + + const environments = await service.getAll(); + const targetedEnvironment = environments.find( + (env) => env.name == enabledEnvName, + ); + + expect(targetedEnvironment.enabled).toBe(true); +}); + +test('When given overrides should remap projects to override environments', async () => { + const enabledEnvName = 'enabled'; + const ignoredEnvName = 'ignored'; + const disabledEnvName = 'disabled'; + const toggleName = 'test-toggle'; + + await db.stores.environmentStore.create({ + name: enabledEnvName, + type: 'production', + }); + + await db.stores.environmentStore.create({ + name: ignoredEnvName, + type: 'production', + }); + + await db.stores.environmentStore.create({ + name: disabledEnvName, + type: 'production', + }); + + await service.toggleEnvironment(disabledEnvName, true); + await service.toggleEnvironment(ignoredEnvName, true); + await service.toggleEnvironment(enabledEnvName, false); + + await stores.featureToggleStore.create('default', { + name: toggleName, + type: 'release', + description: '', + stale: false, + }); + + await service.addEnvironmentToProject(disabledEnvName, 'default'); + + await service.overrideEnabledProjects([enabledEnvName]); + + const projects = await stores.projectStore.getEnvironmentsForProject( + 'default', + ); + + expect(projects).toContain('enabled'); + expect(projects).not.toContain('default'); +}); + +test('Override works correctly when enabling default and disabling prod and dev', async () => { + const defaultEnvironment = 'default'; + const prodEnvironment = 'production'; + const devEnvironment = 'development'; + + await db.stores.environmentStore.create({ + name: prodEnvironment, + type: 'production', + }); + + await db.stores.environmentStore.create({ + name: devEnvironment, + type: 'development', + }); + await service.toggleEnvironment(prodEnvironment, true); + await service.toggleEnvironment(devEnvironment, true); + + await service.overrideEnabledProjects([defaultEnvironment]); + + const environments = await service.getAll(); + const targetedEnvironment = environments.find( + (env) => env.name == defaultEnvironment, + ); + + const allOtherEnvironments = environments + .filter((x) => x.name != defaultEnvironment) + .map((env) => env.enabled); + const envNames = environments.map((x) => x.name); + + expect(envNames).toContain('production'); + expect(envNames).toContain('development'); + expect(targetedEnvironment.enabled).toBe(true); + expect(allOtherEnvironments.every((x) => x === false)).toBe(true); +}); diff --git a/src/test/fixtures/fake-environment-store.ts b/src/test/fixtures/fake-environment-store.ts index c7aaad3127..0df598952a 100644 --- a/src/test/fixtures/fake-environment-store.ts +++ b/src/test/fixtures/fake-environment-store.ts @@ -10,6 +10,22 @@ export default class FakeEnvironmentStore implements IEnvironmentStore { environments: IEnvironment[] = []; + disable(environments: IEnvironment[]): Promise { + for (let env of this.environments) { + if (environments.map((e) => e.name).includes(env.name)) + env.enabled = false; + } + return Promise.resolve(); + } + + enable(environments: IEnvironment[]): Promise { + for (let env of this.environments) { + if (environments.map((e) => e.name).includes(env.name)) + env.enabled = true; + } + return Promise.resolve(); + } + async getAll(): Promise { return this.environments; } diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts index 2494b508e2..9ae835fbfd 100644 --- a/src/test/fixtures/fake-project-store.ts +++ b/src/test/fixtures/fake-project-store.ts @@ -5,15 +5,23 @@ import { } from '../../lib/types/stores/project-store'; import { IProject, IProjectWithCount } from '../../lib/types/model'; import NotFoundError from '../../lib/error/notfound-error'; +import { IEnvironmentProjectLink } from 'lib/db/project-store'; export default class FakeProjectStore implements IProjectStore { + projects: IProject[] = []; + + projectEnvironment: Map> = new Map(); + getEnvironmentsForProject(): Promise { throw new Error('Method not implemented.'); } - projects: IProject[] = []; - - projectEnvironment: Map> = new Map(); + getProjectLinksForEnvironments( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + environments: string[], + ): Promise { + throw new Error('Method not implemented.'); + } async addEnvironmentToProject( // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/website/docs/deploy/configuring-unleash.md b/website/docs/deploy/configuring-unleash.md index 367857eb28..65fb23dca8 100644 --- a/website/docs/deploy/configuring-unleash.md +++ b/website/docs/deploy/configuring-unleash.md @@ -131,6 +131,10 @@ unleash.start(unleashOptions); - **versionCheck** - the object deciding where to check for latest version - `url` - The url to check version (Defaults to `https://version.unleash.run`) - Overridable with (`UNLEASH_VERSION_URL`) - `enable` - Whether version checking is enabled (defaults to true) - Overridable with (`CHECK_VERSION`) (if anything other than `true`, does not check) +- **environmentEnableOverrides** - A list of environment names to force enable at startup. This is feature should be + used with caution. When passed a list, this will enable each environment in that list and disable all other environments. You can't use this to disable all environments, passing an empty list will do nothing. If one of the given environments is not already enabled on startup then it will also enable projects and toggles for that environment. Note that if one of the passed environments doesn't already exist this will do nothing aside from log a warning. + + You can also set the environment variable `ENABLED_ENVIRONMENTS` to a comma delimited string of environment names to override environments. ### Disabling Auto-Start {#disabling-auto-start}