diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index d118ae57b8..f8250ec703 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -163,20 +163,26 @@ export default class EnvironmentStore implements IEnvironmentStore { return mapRow(row[0]); } - async disableAllExcept(environments: string[]): Promise { + async disable(environments: IEnvironment[]): Promise { await this.db(TABLE) .update({ enabled: false, }) - .whereNotIn('name', environments); + .whereIn( + 'name', + environments.map((env) => env.name), + ); } - async enable(environments: string[]): Promise { + async enable(environments: IEnvironment[]): Promise { await this.db(TABLE) .update({ enabled: true, }) - .whereIn('name', environments); + .whereIn( + 'name', + environments.map((env) => env.name), + ); } async delete(name: string): Promise { 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/services/environment-service.ts b/src/lib/services/environment-service.ts index 02405a01a8..891eca0a07 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -95,25 +95,87 @@ export default class EnvironmentService { } async overrideEnabledProjects( - environmentsToEnable: string[], + environmentNamesToEnable: string[], ): Promise { - if (environmentsToEnable.length === 0) { + if (environmentNamesToEnable.length === 0) { return Promise.resolve(); } - const environmentsExist = await Promise.all( - environmentsToEnable.map((env) => - this.environmentStore.exists(env), - ), + const allEnvironments = await this.environmentStore.getAll(); + const existingEnvironmentsToEnable = allEnvironments.filter((env) => + environmentNamesToEnable.includes(env.name), ); - if (!environmentsExist.every((exists) => exists)) { - this.logger.error( + + 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; + return Promise.resolve(); } - await this.environmentStore.disableAllExcept(environmentsToEnable); - await this.environmentStore.enable(environmentsToEnable); + + 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( @@ -125,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/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index 45e21d72ed..4c1f4c48b7 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -16,6 +16,6 @@ export interface IEnvironmentStore extends Store { updateSortOrder(id: string, value: number): Promise; importEnvironments(environments: IEnvironment[]): Promise; delete(name: string): Promise; - disableAllExcept(environments: string[]): Promise; - enable(environments: 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 73823cce4b..e2510559be 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -150,7 +150,7 @@ test('Setting an override disables all other envs', async () => { type: 'production', }); - //Set these to the wrong state so we can assert that overriding them flips + //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); @@ -165,7 +165,6 @@ test('Setting an override disables all other envs', async () => { .filter((x) => x.name != enabledEnvName) .map((env) => env.enabled); - console.log(allOtherEnvironments); expect(targetedEnvironment.enabled).toBe(true); expect(allOtherEnvironments.every((x) => x === false)).toBe(true); }); @@ -189,3 +188,47 @@ test('Passing an empty override does nothing', async () => { 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'); +}); diff --git a/src/test/fixtures/fake-environment-store.ts b/src/test/fixtures/fake-environment-store.ts index 35847aba57..0df598952a 100644 --- a/src/test/fixtures/fake-environment-store.ts +++ b/src/test/fixtures/fake-environment-store.ts @@ -10,16 +10,18 @@ export default class FakeEnvironmentStore implements IEnvironmentStore { environments: IEnvironment[] = []; - disableAllExcept(environments: string[]): Promise { + disable(environments: IEnvironment[]): Promise { for (let env of this.environments) { - if (!environments.includes(env.name)) env.enabled = false; + if (environments.map((e) => e.name).includes(env.name)) + env.enabled = false; } return Promise.resolve(); } - enable(environments: string[]): Promise { + enable(environments: IEnvironment[]): Promise { for (let env of this.environments) { - if (environments.includes(env.name)) env.enabled = true; + if (environments.map((e) => e.name).includes(env.name)) + env.enabled = true; } return Promise.resolve(); } 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