diff --git a/packages/@n8n/db/src/entities/project.ts b/packages/@n8n/db/src/entities/project.ts index 7856a219d1a..16b6b739c1a 100644 --- a/packages/@n8n/db/src/entities/project.ts +++ b/packages/@n8n/db/src/entities/project.ts @@ -4,6 +4,7 @@ import { WithTimestampsAndStringId } from './abstract-entity'; import type { ProjectRelation } from './project-relation'; import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; +import type { Variables } from './variables'; @Entity() export class Project extends WithTimestampsAndStringId { @@ -27,4 +28,7 @@ export class Project extends WithTimestampsAndStringId { @OneToMany('SharedWorkflow', 'project') sharedWorkflows: SharedWorkflow[]; + + @OneToMany('Variables', 'project') + variables: Variables[]; } diff --git a/packages/@n8n/db/src/entities/variables.ts b/packages/@n8n/db/src/entities/variables.ts index 15e2e9bbc94..f0c767c14f5 100644 --- a/packages/@n8n/db/src/entities/variables.ts +++ b/packages/@n8n/db/src/entities/variables.ts @@ -1,7 +1,7 @@ import { Column, Entity, ManyToOne } from '@n8n/typeorm'; import { WithStringId } from './abstract-entity'; -import { Project } from './project'; +import type { Project } from './project'; @Entity() export class Variables extends WithStringId { diff --git a/packages/@n8n/db/src/repositories/variables.repository.ts b/packages/@n8n/db/src/repositories/variables.repository.ts index 4145d5887cb..9222db1c85d 100644 --- a/packages/@n8n/db/src/repositories/variables.repository.ts +++ b/packages/@n8n/db/src/repositories/variables.repository.ts @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; +import { DataSource, In, Repository } from '@n8n/typeorm'; import { Variables } from '../entities'; @@ -8,4 +8,8 @@ export class VariablesRepository extends Repository { constructor(dataSource: DataSource) { super(Variables, dataSource.manager); } + + async deleteByIds(ids: string[]): Promise { + await this.delete({ id: In(ids) }); + } } diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts index 68984dfc394..5775f51552e 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts @@ -13,6 +13,7 @@ import type { WorkflowRepository, WorkflowTagMapping, WorkflowTagMappingRepository, + Variables, } from '@n8n/db'; import { GLOBAL_ADMIN_ROLE, In, PROJECT_OWNER_ROLE, User } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -340,7 +341,7 @@ describe('SourceControlExportService', () => { variablesService.getAllCached.mockResolvedValue([mock()]); // Act - const result = await service.exportVariablesToWorkFolder(); + const result = await service.exportGlobalVariablesToWorkFolder(); // Assert expect(result.count).toBe(1); @@ -352,7 +353,7 @@ describe('SourceControlExportService', () => { variablesService.getAllCached.mockResolvedValue([]); // Act - const result = await service.exportVariablesToWorkFolder(); + const result = await service.exportGlobalVariablesToWorkFolder(); // Assert expect(result.count).toBe(0); @@ -418,6 +419,7 @@ describe('SourceControlExportService', () => { icon: { type: 'icon', value: 'icon.png' }, description: 'Project 1', type: 'team', + variables: [], }); const project2 = mock({ id: 'project-id-2', @@ -425,6 +427,7 @@ describe('SourceControlExportService', () => { icon: null, description: 'Team Project', type: 'team', + variables: [mock({ key: 'VAR1', value: 'value1' })], }); const expectedProject1Json = JSON.stringify( @@ -439,6 +442,7 @@ describe('SourceControlExportService', () => { teamId: project1.id, teamName: project1.name, }, + variableStubs: [], }, null, 2, @@ -455,6 +459,12 @@ describe('SourceControlExportService', () => { teamId: project2.id, teamName: project2.name, }, + variableStubs: [ + { + key: 'VAR1', + value: '', + }, + ], }, null, 2, @@ -468,6 +478,7 @@ describe('SourceControlExportService', () => { // Assert expect(projectRepository.find).toHaveBeenCalledWith({ where: { id: In([project1.id, project2.id]), type: 'team' }, + relations: ['variables'], }); expect(fsWriteFile).toHaveBeenCalledWith( '/mock/n8n/git/projects/project-id-1.json', diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index f6d3c88e23f..164362c94d4 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -1,6 +1,8 @@ import type { SourceControlledFile } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; import { + type Variables, + type VariablesRepository, type FolderRepository, GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, @@ -17,6 +19,8 @@ import { mock } from 'jest-mock-extended'; import { type InstanceSettings } from 'n8n-core'; import fsp from 'node:fs/promises'; +import type { VariablesService } from '@/environments.ee/variables/variables.service.ee'; + import { SourceControlImportService } from '../source-control-import.service.ee'; import type { SourceControlScopedService } from '../source-control-scoped.service'; import type { ExportableFolder } from '../types/exportable-folders'; @@ -44,10 +48,12 @@ describe('SourceControlImportService', () => { const sharedWorkflowRepository = mock(); const mockLogger = mock(); const sourceControlScopedService = mock(); + const variableService = mock(); + const variablesRepository = mock(); const service = new SourceControlImportService( mockLogger, mock(), - mock(), + variableService, mock(), mock(), projectRepository, @@ -55,7 +61,7 @@ describe('SourceControlImportService', () => { sharedWorkflowRepository, mock(), mock(), - mock(), + variablesRepository, workflowRepository, mock(), mock(), @@ -570,6 +576,7 @@ describe('SourceControlImportService', () => { type: 'team', teamId: 'project2', }, + variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1' }], }; const candidates = [ mock({ file: mockProjectFile1, id: mockProjectData1.id }), @@ -580,6 +587,8 @@ describe('SourceControlImportService', () => { .mockResolvedValueOnce(JSON.stringify(mockProjectData1)) .mockResolvedValueOnce(JSON.stringify(mockProjectData2)); + variableService.getAllCached.mockResolvedValue([]); + // Act const result = await service.importTeamProjectsFromWorkFolder(candidates); @@ -606,6 +615,14 @@ describe('SourceControlImportService', () => { }), ['id'], ); + expect(variablesRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'var1', + key: 'VAR1', + value: 'value1', + }), + ['id'], + ); expect(result).toEqual([ { @@ -699,6 +716,44 @@ describe('SourceControlImportService', () => { }, ]); }); + + it('should delete project variables not in the imported stubs', async () => { + // Arrange + const mockProjectFile = '/mock/team-project.json'; + const mockProjectData = { + id: 'project1', + name: 'Team Project 1', + icon: 'icon1.png', + description: 'First team project', + type: 'team', + owner: { + type: 'team', + teamId: 'project1', + }, + variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1' }], + }; + const candidates = [ + mock({ file: mockProjectFile, id: mockProjectData.id }), + ]; + + fsReadFile.mockResolvedValueOnce(JSON.stringify(mockProjectData)); + + variableService.getAllCached.mockResolvedValue([ + { + id: 'var2', + key: 'VAR2', + value: 'value2', + type: 'string', + project: { id: 'project1' } as Project, + } as Variables, + ]); + + // Act + await service.importTeamProjectsFromWorkFolder(candidates); + + // Assert + expect(variableService.deleteByIds).toHaveBeenCalledWith(['var2']); + }); }); describe('getRemoteProjectsFromFiles', () => { @@ -725,6 +780,7 @@ describe('SourceControlImportService', () => { teamId: 'project2', teamName: 'Team Project 2', }, + variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1', type: 'string' }], }; it('should return all projects if the user has access to all projects', async () => { @@ -795,6 +851,7 @@ describe('SourceControlImportService', () => { type: 'team', createdAt: new Date(), updatedAt: new Date(), + variables: [], }); const mockProjectData2: Project = mock({ id: 'project2', @@ -804,6 +861,7 @@ describe('SourceControlImportService', () => { type: 'team', createdAt: new Date(), updatedAt: new Date(), + variables: [], }); const mockFilter = { id: 'test' }; @@ -820,6 +878,7 @@ describe('SourceControlImportService', () => { // making sure the correct filter is used expect(projectRepository.find).toHaveBeenCalledWith({ select: ['id', 'name', 'description', 'icon', 'type'], + relations: ['variables'], where: { type: 'team', ...mockFilter, @@ -865,6 +924,7 @@ describe('SourceControlImportService', () => { type: 'team', createdAt: new Date(), updatedAt: new Date(), + variables: [{ id: 'var1', key: 'VAR1', value: 'value1', type: 'string' }], }); projectRepository.find.mockResolvedValue([mockProjectData1]); @@ -877,6 +937,7 @@ describe('SourceControlImportService', () => { // making sure the correct filter is used expect(projectRepository.find).toHaveBeenCalledWith({ select: ['id', 'name', 'description', 'icon', 'type'], + relations: ['variables'], where: { type: 'team' }, }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts index 391a191fcd8..9fa17549140 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-status.service.test.ts @@ -62,7 +62,7 @@ describe('getStatus', () => { // variables sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]); - sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]); + sourceControlImportService.getLocalGlobalVariablesFromDb.mockResolvedValue([]); // folders // Define a folder that does only exist remotely. @@ -201,7 +201,7 @@ describe('getStatus', () => { // Pulling this would delete it so it should be marked as a conflict. // Pushing this is conflict free. sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]); - sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock()]); + sourceControlImportService.getLocalGlobalVariablesFromDb.mockResolvedValue([mock()]); // Define a tag that does only exist locally. // Pulling this would delete it so it should be marked as a conflict. @@ -539,6 +539,48 @@ describe('getStatus', () => { }); }); + it('should identify projects with modified variables', async () => { + // ARRANGE + const user = mockUsers.globalAdmin; + const localProject = mockProjects.basic; + const remoteProject: ExportableProjectWithFileName = { + ...mockProjects.basic, + variableStubs: [{ id: 'var1', key: 'VAR1', value: '', type: 'string' }], + }; + + // both projects exist but are different + setupProjectMocks({ + remote: [remoteProject], + local: [localProject], + }); + + // ACT + const result = await sourceControlStatusService.getStatus(user, { + direction: 'push', + verbose: true, + preferLocalVersion: false, + }); + + // ASSERT + if (Array.isArray(result)) { + fail('Expected result to be an object.'); + } + + expect(result).toMatchObject({ + projectsRemote: [remoteProject], + projectsLocal: [localProject], + projectsMissingInLocal: [], + projectsMissingInRemote: [], + projectsModifiedInEither: [remoteProject], + sourceControlledFiles: [ + expect.objectContaining({ + id: remoteProject.id, + conflict: true, + }), + ], + }); + }); + it('should prevent out of scope projects from being deleted for non-global users', async () => { // ARRANGE const user = mockUsers.limitedUser; diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 1b3e48faa96..fd1c07aa8bc 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -181,11 +181,11 @@ describe('SourceControlService', () => { ); sourceControlExportService.exportTagsToWorkFolder.mockResolvedValueOnce(mockExportResult); sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult); - sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce( + sourceControlExportService.exportGlobalVariablesToWorkFolder.mockResolvedValueOnce( mockExportResult, ); sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult); - sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce( + sourceControlExportService.exportGlobalVariablesToWorkFolder.mockResolvedValueOnce( mockExportResult, ); @@ -223,7 +223,7 @@ describe('SourceControlService', () => { ); expect(sourceControlExportService.exportTagsToWorkFolder).toHaveBeenCalled(); expect(sourceControlExportService.exportFoldersToWorkFolder).toHaveBeenCalled(); - expect(sourceControlExportService.exportVariablesToWorkFolder).toHaveBeenCalled(); + expect(sourceControlExportService.exportGlobalVariablesToWorkFolder).toHaveBeenCalled(); // Deleted resources should be passed to rmFilesFromExportFolder expect(sourceControlExportService.rmFilesFromExportFolder).toHaveBeenCalledWith( diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index 6c0000398ea..e64740ecc15 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -20,6 +20,8 @@ import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workfl import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises'; import path from 'path'; +import { formatWorkflow } from '@/workflows/workflow.formatter'; + import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, @@ -46,8 +48,7 @@ import { ExportableProject } from './types/exportable-project'; import type { ExportableWorkflow } from './types/exportable-workflow'; import type { RemoteResourceOwner } from './types/resource-owner'; import type { SourceControlContext } from './types/source-control-context'; - -import { formatWorkflow } from '@/workflows/workflow.formatter'; +import { ExportableVariable } from './types/exportable-variable'; @Service() export class SourceControlExportService { @@ -198,10 +199,10 @@ export class SourceControlExportService { } } - async exportVariablesToWorkFolder(): Promise { + async exportGlobalVariablesToWorkFolder(): Promise { try { sourceControlFoldersExistCheck([this.gitFolder]); - const variables = await this.variablesService.getAllCached(); + const variables = await this.variablesService.getAllCached({ globalOnly: true }); // do not export empty variables if (variables.length === 0) { return { @@ -211,7 +212,12 @@ export class SourceControlExportService { }; } const fileName = getVariablesPath(this.gitFolder); - const sanitizedVariables = variables.map((e) => ({ ...e, value: '' })); + const sanitizedVariables: ExportableVariable[] = variables.map((e) => ({ + id: e.id, + key: e.key, + type: e.type, + value: '', + })); await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2)); return { count: sanitizedVariables.length, @@ -487,6 +493,7 @@ export class SourceControlExportService { const projectIds = candidates.map((e) => e.id); const projects = await this.projectRepository.find({ where: { id: In(projectIds), type: 'team' }, + relations: ['variables'], }); await Promise.all( @@ -504,6 +511,12 @@ export class SourceControlExportService { teamId: project.id, teamName: project.name, }, + variableStubs: project.variables.map((variable) => ({ + id: variable.id, + key: variable.key, + type: variable.type, + value: '', + })), }; this.logger.debug(`Writing project ${project.id} to ${fileName}`); diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 167d4775857..240f2f92f16 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -39,7 +39,6 @@ import { TagService } from '@/services/tag.service'; import { assertNever } from '@/utils'; import { WorkflowService } from '@/workflows/workflow.service'; -import { VariablesService } from '../variables/variables.service.ee'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_FOLDERS_EXPORT_FILE, @@ -55,6 +54,7 @@ import { getWorkflowExportPath, } from './source-control-helper.ee'; import { SourceControlScopedService } from './source-control-scoped.service'; +import { VariablesService } from '../variables/variables.service.ee'; import type { ExportableCredential, StatusExportableCredential, @@ -62,6 +62,7 @@ import type { import type { ExportableFolder } from './types/exportable-folders'; import type { ExportableProject, ExportableProjectWithFileName } from './types/exportable-project'; import type { ExportableTags } from './types/exportable-tags'; +import { ExportableVariable } from './types/exportable-variable'; import type { RemoteResourceOwner, StatusResourceOwner, @@ -421,22 +422,25 @@ export class SourceControlImportService { }) as StatusExportableCredential[]; } - async getRemoteVariablesFromFile(): Promise { + async getRemoteVariablesFromFile(): Promise { const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, { cwd: this.gitFolder, absolute: true, }); if (variablesFile.length > 0) { this.logger.debug(`Importing variables from file ${variablesFile[0]}`); - return jsonParse(await fsReadFile(variablesFile[0], { encoding: 'utf8' }), { - fallbackValue: [], - }); + return jsonParse( + await fsReadFile(variablesFile[0], { encoding: 'utf8' }), + { + fallbackValue: [], + }, + ); } return []; } - async getLocalVariablesFromDb(): Promise { - return await this.variablesService.getAllCached(); + async getLocalGlobalVariablesFromDb(): Promise { + return await this.variablesService.getAllCached({ globalOnly: true }); } async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{ @@ -593,6 +597,7 @@ export class SourceControlImportService { const localProjects = await this.projectRepository.find({ select: ['id', 'name', 'description', 'icon', 'type'], + relations: ['variables'], where, }); @@ -616,6 +621,12 @@ export class SourceControlImportService { teamId: project.id, teamName: project.name, }, + variableStubs: project.variables.map((variable) => ({ + id: variable.id, + key: variable.key, + type: variable.type, + value: '', + })), }; } @@ -923,41 +934,33 @@ export class SourceControlImportService { return mappedFolders; } - async importVariablesFromWorkFolder( - candidate: SourceControlledFile, + async importVariables( + variables: ExportableVariable[], valueOverrides?: { [key: string]: string; }, ) { const result: { imported: string[] } = { imported: [] }; - let importedVariables; - try { - this.logger.debug(`Importing variables from file ${candidate.file}`); - importedVariables = jsonParse>>( - await fsReadFile(candidate.file, { encoding: 'utf8' }), - { fallbackValue: [] }, - ); - } catch (e) { - this.logger.error(`Failed to import tags from file ${candidate.file}`, { error: e }); - return; - } const overriddenKeys = Object.keys(valueOverrides ?? {}); - for (const variable of importedVariables) { + for (const variable of variables) { if (!variable.key) { continue; } - // by default no value is stored remotely, so an empty string is returned - // it must be changed to undefined so as to not overwrite existing values! - if (variable.value === '') { - variable.value = undefined; - } if (overriddenKeys.includes(variable.key) && valueOverrides) { variable.value = valueOverrides[variable.key]; overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1); } try { - await this.variablesRepository.upsert({ ...variable }, ['id']); + // by default no value is stored remotely, so an empty string is returned + // it must be changed to undefined so as to not overwrite existing values! + const variableToUpsert = { + ...variable, + value: variable.value === '' ? undefined : variable.value, + project: variable.projectId ? { id: variable.projectId } : null, + }; + + await this.variablesRepository.upsert(variableToUpsert, ['id']); } catch (errorUpsert) { if (isUniqueConstraintError(errorUpsert as Error)) { this.logger.debug(`Variable ${variable.key} already exists, updating instead`); @@ -990,6 +993,27 @@ export class SourceControlImportService { return result; } + async importVariablesFromWorkFolder( + candidate: SourceControlledFile, + valueOverrides?: { + [key: string]: string; + }, + ) { + let importedVariables; + try { + this.logger.debug(`Importing variables from file ${candidate.file}`); + importedVariables = jsonParse( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + { fallbackValue: [] }, + ); + } catch (e) { + this.logger.error(`Failed to import tags from file ${candidate.file}`, { error: e }); + return; + } + + return await this.importVariables(importedVariables, valueOverrides); + } + /** * Reads project files candidates from the work folder and imports them into the database. * @@ -999,6 +1023,9 @@ export class SourceControlImportService { */ async importTeamProjectsFromWorkFolder(candidates: SourceControlledFile[]) { const importResults = []; + const existingProjectVariables = (await this.variablesService.getAllCached()).filter( + (v) => v.project, + ); for (const candidate of candidates) { try { @@ -1030,6 +1057,17 @@ export class SourceControlImportService { ['id'], ); + await this.importVariables( + project.variableStubs?.map((v) => ({ ...v, projectId: project.id })) ?? [], + ); + + // Delete variables that existed before but are no longer present in the imported project + const deletedVariables = existingProjectVariables.filter( + (v) => + v.project!.id === project.id && !project.variableStubs?.some((vs) => vs.id === v.id), + ); + await this.variablesService.deleteByIds(deletedVariables.map((v) => v.id)); + this.logger.info(`Imported team project: ${project.name}`); importResults.push({ id: project.id, diff --git a/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts index fcf537b7331..b0b4bfab179 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-status.service.ee.ts @@ -1,6 +1,6 @@ import type { SourceControlledFile } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { FolderRepository, type TagEntity, TagRepository, type User, Variables } from '@n8n/db'; +import { FolderRepository, type TagEntity, TagRepository, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import { UserError } from 'n8n-workflow'; @@ -23,6 +23,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se import type { StatusExportableCredential } from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; import type { ExportableProjectWithFileName } from './types/exportable-project'; +import { ExportableVariable } from './types/exportable-variable'; import { SourceControlContext } from './types/source-control-context'; import type { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; @@ -378,7 +379,7 @@ export class SourceControlStatusService { sourceControlledFiles: SourceControlledFile[], ) { const varRemoteIds = await this.sourceControlImportService.getRemoteVariablesFromFile(); - const varLocalIds = await this.sourceControlImportService.getLocalVariablesFromDb(); + const varLocalIds = await this.sourceControlImportService.getLocalGlobalVariablesFromDb(); const varMissingInLocal = varRemoteIds.filter( (remote) => varLocalIds.findIndex((local) => local.id === remote.id) === -1, @@ -388,7 +389,7 @@ export class SourceControlStatusService { (local) => varRemoteIds.findIndex((remote) => remote.id === local.id) === -1, ); - const varModifiedInEither: Variables[] = []; + const varModifiedInEither: ExportableVariable[] = []; varLocalIds.forEach((local) => { const mismatchingIds = varRemoteIds.find( (remote) => @@ -737,6 +738,9 @@ export class SourceControlStatusService { ? localProject.description : remoteProjectWithSameId.description, icon: options.preferLocalVersion ? localProject.icon : remoteProjectWithSameId.icon, + variableStubs: options.preferLocalVersion + ? localProject.variableStubs + : remoteProjectWithSameId.variableStubs, }); } }); @@ -806,6 +810,29 @@ export class SourceControlStatusService { }; } + private areVariablesEqual( + localVariables: ExportableProjectWithFileName['variableStubs'], + remoteVariables: ExportableProjectWithFileName['variableStubs'], + ): boolean { + if (Array.isArray(localVariables) !== Array.isArray(remoteVariables)) { + return false; + } + + if (localVariables?.length !== remoteVariables?.length) { + return false; + } + + const sortedLocalVars = [...(localVariables ?? [])].sort((a, b) => a.key.localeCompare(b.key)); + const sortedRemoteVars = [...(remoteVariables ?? [])].sort((a, b) => + a.key.localeCompare(b.key), + ); + + return sortedLocalVars.every((localVar, index) => { + const remoteVar = sortedRemoteVars[index]; + return localVar.key === remoteVar.key && localVar.type === remoteVar.type; + }); + } + private isProjectModified( local: ExportableProjectWithFileName, remote: ExportableProjectWithFileName, @@ -819,7 +846,8 @@ export class SourceControlStatusService { isIconModified || remote.type !== local.type || remote.name !== local.name || - remote.description !== local.description + remote.description !== local.description || + !this.areVariablesEqual(local.variableStubs, remote.variableStubs) ); } diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 8986b3e1fa1..130aca3e680 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -337,7 +337,7 @@ export class SourceControlService { const variablesChanges = filterByType(filesToPush, 'variables')[0]; if (variablesChanges) { filesToBePushed.add(variablesChanges.file); - await this.sourceControlExportService.exportVariablesToWorkFolder(); + await this.sourceControlExportService.exportGlobalVariablesToWorkFolder(); } await this.gitService.stage(filesToBePushed, filesToBeDeleted); diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-project.ts b/packages/cli/src/environments.ee/source-control/types/exportable-project.ts index 43571970a1d..2eeee460ebb 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-project.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-project.ts @@ -1,3 +1,4 @@ +import type { ExportableVariable } from './exportable-variable'; import type { TeamResourceOwner } from './resource-owner'; export interface ExportableProject { @@ -10,6 +11,7 @@ export interface ExportableProject { */ type: 'team'; owner: TeamResourceOwner; + variableStubs?: ExportableVariable[]; } export type ExportableProjectWithFileName = ExportableProject & { diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-variable.ts b/packages/cli/src/environments.ee/source-control/types/exportable-variable.ts new file mode 100644 index 00000000000..b68b1e7e997 --- /dev/null +++ b/packages/cli/src/environments.ee/source-control/types/exportable-variable.ts @@ -0,0 +1,7 @@ +export interface ExportableVariable { + id: string; + key: string; + type: string; + value: string; + projectId?: string; +} diff --git a/packages/cli/src/environments.ee/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts index 2d261b7dd9d..ded74f37f18 100644 --- a/packages/cli/src/environments.ee/variables/variables.service.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.service.ee.ts @@ -61,11 +61,19 @@ export class VariablesService { return !!project; } - async getAllCached(): Promise { - const variables = await this.cacheService.get('variables', { - refreshFn: async () => await this.findAll(), - }); - return variables ?? []; + async getAllCached( + filters: { globalOnly: boolean } = { globalOnly: false }, + ): Promise { + const variables = + (await this.cacheService.get('variables', { + refreshFn: async () => await this.findAll(), + })) ?? []; + + if (filters.globalOnly) { + return variables.filter((v) => !v.project); + } + + return variables; } async getCached(id: string): Promise { @@ -150,6 +158,11 @@ export class VariablesService { await this.updateCache(); } + async deleteByIds(ids: string[]): Promise { + await this.variablesRepository.deleteByIds(ids); + await this.updateCache(); + } + private async canCreateNewVariable() { if (!this.licenseState.isVariablesLicensed()) { throw new FeatureNotLicensedError('feat:variables');