mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
feat(core): Handle project variables sync on source control (#21001)
This commit is contained in:
parent
5d431aabeb
commit
832774db80
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Variables> {
|
|||
constructor(dataSource: DataSource) {
|
||||
super(Variables, dataSource.manager);
|
||||
}
|
||||
|
||||
async deleteByIds(ids: string[]): Promise<void> {
|
||||
await this.delete({ id: In(ids) });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Project>({
|
||||
id: 'project-id-2',
|
||||
|
|
@ -425,6 +427,7 @@ describe('SourceControlExportService', () => {
|
|||
icon: null,
|
||||
description: 'Team Project',
|
||||
type: 'team',
|
||||
variables: [mock<Variables>({ 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',
|
||||
|
|
|
|||
|
|
@ -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<SharedWorkflowRepository>();
|
||||
const mockLogger = mock<Logger>();
|
||||
const sourceControlScopedService = mock<SourceControlScopedService>();
|
||||
const variableService = mock<VariablesService>();
|
||||
const variablesRepository = mock<VariablesRepository>();
|
||||
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<SourceControlledFile>({ 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<SourceControlledFile>({ 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<Project>({
|
||||
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' },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Variables>()]);
|
||||
sourceControlImportService.getLocalGlobalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<ExportResult> {
|
||||
async exportGlobalVariablesToWorkFolder(): Promise<ExportResult> {
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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<Variables[]> {
|
||||
async getRemoteVariablesFromFile(): Promise<ExportableVariable[]> {
|
||||
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<Variables[]>(await fsReadFile(variablesFile[0], { encoding: 'utf8' }), {
|
||||
fallbackValue: [],
|
||||
});
|
||||
return jsonParse<ExportableVariable[]>(
|
||||
await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
|
||||
{
|
||||
fallbackValue: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLocalVariablesFromDb(): Promise<Variables[]> {
|
||||
return await this.variablesService.getAllCached();
|
||||
async getLocalGlobalVariablesFromDb(): Promise<Variables[]> {
|
||||
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<Array<Partial<Variables>>>(
|
||||
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<ExportableVariable[]>(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
export interface ExportableVariable {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
value: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
|
@ -61,11 +61,19 @@ export class VariablesService {
|
|||
return !!project;
|
||||
}
|
||||
|
||||
async getAllCached(): Promise<Variables[]> {
|
||||
const variables = await this.cacheService.get('variables', {
|
||||
refreshFn: async () => await this.findAll(),
|
||||
});
|
||||
return variables ?? [];
|
||||
async getAllCached(
|
||||
filters: { globalOnly: boolean } = { globalOnly: false },
|
||||
): Promise<Variables[]> {
|
||||
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<Variables | null> {
|
||||
|
|
@ -150,6 +158,11 @@ export class VariablesService {
|
|||
await this.updateCache();
|
||||
}
|
||||
|
||||
async deleteByIds(ids: string[]): Promise<void> {
|
||||
await this.variablesRepository.deleteByIds(ids);
|
||||
await this.updateCache();
|
||||
}
|
||||
|
||||
private async canCreateNewVariable() {
|
||||
if (!this.licenseState.isVariablesLicensed()) {
|
||||
throw new FeatureNotLicensedError('feat:variables');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user