diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index c9536182987..609b84065bd 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 92eba02babc..be9c0044a2f 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 91c6d1223e7..9ab90b57c6b 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 ca000b1e860..f8250ec7035 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 2c08a845d5b..8b10a12ad1f 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 af038507a46..1c2904588fc 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 48c9ce91da3..891eca0a074 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 f60413cd4af..61212860e0b 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 c6fe2600f2c..4c1f4c48b75 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 2e4c41d8b5f..aca4caa9507 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 8cb953b7336..ac21c74df59 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 c7aaad31270..0df598952ae 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 2494b508e2b..9ae835fbfd4 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 367857eb28a..65fb23dca8b 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}