refactor: Migrate source control feature to modules (#22453)

This commit is contained in:
Irénée 2026-01-06 15:59:11 +00:00 committed by GitHub
parent 639c09f69a
commit 9bfb014cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 93 additions and 104 deletions

View file

@ -27,6 +27,7 @@ describe('eligibleModules', () => {
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
]);
@ -42,6 +43,7 @@ describe('eligibleModules', () => {
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
]);

View file

@ -37,6 +37,7 @@ export class ModuleRegistry {
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
];

View file

@ -10,6 +10,7 @@ export const MODULE_NAMES = [
'mcp',
'provisioning',
'breaking-changes',
'source-control',
'dynamic-credentials',
'chat-hub',
] as const;

View file

@ -28,6 +28,7 @@ export const LOG_SCOPES = [
'chat-hub',
'breaking-changes',
'circuit-breaker',
'source-control',
'dynamic-credentials',
'workflow-history-compaction',
] as const;

View file

@ -1,29 +0,0 @@
import { Container } from '@n8n/di';
import type { RequestHandler } from 'express';
import { isSourceControlLicensed } from '../source-control-helper.ee';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
export const sourceControlLicensedAndEnabledMiddleware: RequestHandler = (_req, res, next) => {
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
if (sourceControlPreferencesService.isSourceControlLicensedAndEnabled()) {
next();
} else {
if (!sourceControlPreferencesService.isSourceControlConnected()) {
res.status(412).json({
status: 'error',
message: 'source_control_not_connected',
});
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
}
};
export const sourceControlLicensedMiddleware: RequestHandler = (_req, res, next) => {
if (isSourceControlLicensed()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};

View file

@ -21,7 +21,7 @@ import { captor, mock } from 'jest-mock-extended';
import { Cipher, type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
import type { VariablesService } from '../../variables/variables.service.ee';
import type { VariablesService } from '../../../environments.ee/variables/variables.service.ee';
import { SourceControlExportService } from '../source-control-export.service.ee';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import { SourceControlContext } from '../types/source-control-context';

View file

@ -8,7 +8,7 @@ import path from 'path';
import {
SOURCE_CONTROL_SSH_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
} from '@/environments.ee/source-control/constants';
} from '@/modules/source-control.ee/constants';
import {
hasOwnerChanged,
generateSshKeyPair,
@ -18,8 +18,8 @@ import {
getTrackingInformationFromPullResult,
isWorkflowModified,
sourceControlFoldersExistCheck,
} from '@/environments.ee/source-control/source-control-helper.ee';
import type { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
} from '../source-control-helper.ee';
import type { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import type { License } from '@/license';
import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id';
@ -462,7 +462,7 @@ describe('readTagAndMappingsFromSourceControlFile', () => {
const filePath = 'invalid/path/tags-and-mappings.json';
// Import the function after resetting modules
const { readTagAndMappingsFromSourceControlFile } = await import(
'@/environments.ee/source-control/source-control-helper.ee'
'@/modules/source-control.ee/source-control-helper.ee'
);
const result = await readTagAndMappingsFromSourceControlFile(filePath);
expect(result).toEqual({
@ -483,7 +483,7 @@ describe('readFoldersFromSourceControlFile', () => {
const filePath = 'invalid/path/folders.json';
// Import the function after resetting modules
const { readFoldersFromSourceControlFile } = await import(
'@/environments.ee/source-control/source-control-helper.ee'
'@/modules/source-control.ee/source-control-helper.ee'
);
const result = await readFoldersFromSourceControlFile(filePath);
expect(result).toEqual({

View file

@ -6,8 +6,8 @@ import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import type { PushResult } from 'simple-git';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { EventService } from '@/events/event.service';
import type { SourceControlExportService } from '../source-control-export.service.ee';

View file

@ -0,0 +1,17 @@
import { Container } from '@n8n/di';
import type { RequestHandler } from 'express';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
export const sourceControlEnabledMiddleware: RequestHandler = (_req, res, next) => {
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
if (sourceControlPreferencesService.isSourceControlConnected()) {
next();
} else {
res.status(412).json({
status: 'error',
message: 'source_control_not_connected',
});
}
};

View file

@ -41,7 +41,7 @@ import {
stringContainsExpression,
} from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import { VariablesService } from '../variables/variables.service.ee';
import { VariablesService } from '../../environments.ee/variables/variables.service.ee';
import type { ExportResult } from './types/export-result';
import type { ExportableCredential } from './types/exportable-credential';
import { ExportableProject } from './types/exportable-project';

View file

@ -61,7 +61,7 @@ import {
getWorkflowExportPath,
} from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import { VariablesService } from '../variables/variables.service.ee';
import { VariablesService } from '../../environments.ee/variables/variables.service.ee';
import type {
ExportableCredential,
StatusExportableCredential,

View file

@ -7,10 +7,7 @@ import express from 'express';
import type { PullResult } from 'simple-git';
import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
import {
sourceControlLicensedMiddleware,
sourceControlLicensedAndEnabledMiddleware,
} from './middleware/source-control-enabled-middleware.ee';
import { sourceControlEnabledMiddleware } from './middleware/source-control-enabled-middleware.ee';
import { getRepoType } from './source-control-helper.ee';
import { SourceControlPreferencesService } from './source-control-preferences.service.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
@ -33,14 +30,14 @@ export class SourceControlController {
private readonly eventService: EventService,
) {}
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware], skipAuth: true })
@Get('/preferences', { skipAuth: true })
async getPreferences(): Promise<SourceControlPreferences> {
// returns the settings with the privateKey property redacted
const publicKey = await this.sourceControlPreferencesService.getPublicKey();
return { ...this.sourceControlPreferencesService.getPreferences(), publicKey };
}
@Post('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
@Post('/preferences')
@GlobalScope('sourceControl:manage')
async setPreferences(req: SourceControlRequest.UpdatePreferences) {
if (
@ -87,7 +84,7 @@ export class SourceControlController {
throw error;
}
}
await this.sourceControlService.init();
await this.sourceControlService.start();
const resultingPreferences = this.sourceControlPreferencesService.getPreferences();
// #region Tracking Information
// located in controller so as to not call this multiple times when updating preferences
@ -105,7 +102,7 @@ export class SourceControlController {
}
}
@Patch('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
@Patch('/preferences')
@GlobalScope('sourceControl:manage')
async updatePreferences(req: SourceControlRequest.UpdatePreferences) {
try {
@ -135,7 +132,7 @@ export class SourceControlController {
true,
);
}
await this.sourceControlService.init();
await this.sourceControlService.start();
const resultingPreferences = this.sourceControlPreferencesService.getPreferences();
this.eventService.emit('source-control-settings-updated', {
branchName: resultingPreferences.branchName,
@ -150,7 +147,7 @@ export class SourceControlController {
}
}
@Post('/disconnect', { middlewares: [sourceControlLicensedMiddleware] })
@Post('/disconnect')
@GlobalScope('sourceControl:manage')
async disconnect(req: SourceControlRequest.Disconnect) {
try {
@ -160,7 +157,7 @@ export class SourceControlController {
}
}
@Get('/get-branches', { middlewares: [sourceControlLicensedMiddleware] })
@Get('/get-branches')
async getBranches() {
try {
return await this.sourceControlService.getBranches();
@ -169,7 +166,7 @@ export class SourceControlController {
}
}
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@Post('/push-workfolder', { middlewares: [sourceControlEnabledMiddleware] })
async pushWorkfolder(
req: AuthenticatedRequest,
res: express.Response,
@ -191,7 +188,7 @@ export class SourceControlController {
}
}
@Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@Post('/pull-workfolder', { middlewares: [sourceControlEnabledMiddleware] })
@GlobalScope('sourceControl:pull')
async pullWorkfolder(
req: AuthenticatedRequest,
@ -207,7 +204,7 @@ export class SourceControlController {
}
}
@Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@Get('/reset-workfolder', { middlewares: [sourceControlEnabledMiddleware] })
@GlobalScope('sourceControl:manage')
async resetWorkfolder(): Promise<ImportResult | undefined> {
try {
@ -217,7 +214,7 @@ export class SourceControlController {
}
}
@Get('/get-status', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@Get('/get-status', { middlewares: [sourceControlEnabledMiddleware] })
async getStatus(req: SourceControlRequest.GetStatus) {
try {
const result = await this.sourceControlService.getStatus(
@ -230,7 +227,7 @@ export class SourceControlController {
}
}
@Get('/status', { middlewares: [sourceControlLicensedMiddleware] })
@Get('/status')
async status(req: SourceControlRequest.GetStatus) {
try {
return await this.sourceControlService.getStatus(
@ -242,7 +239,7 @@ export class SourceControlController {
}
}
@Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] })
@Post('/generate-key-pair')
@GlobalScope('sourceControl:manage')
async generateKeyPair(
req: SourceControlRequest.GenerateKeyPair,
@ -257,7 +254,7 @@ export class SourceControlController {
}
}
@Get('/remote-content/:type/:id', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@Get('/remote-content/:type/:id', { middlewares: [sourceControlEnabledMiddleware] })
async getFileContent(
req: AuthenticatedRequest & { params: { type: SourceControlledFile['type']; id: string } },
): Promise<{ content: IWorkflowToImport; type: SourceControlledFile['type'] }> {

View file

@ -0,0 +1,13 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
@BackendModule({ name: 'source-control', licenseFlag: 'feat:sourceControl' })
export class SourceControlModule implements ModuleInterface {
async init() {
await import('./source-control.controller.ee');
const { SourceControlService } = await import('./source-control.service.ee');
await Container.get(SourceControlService).start();
}
}

View file

@ -70,7 +70,7 @@ export class SourceControlService {
this.sshKeyName = sshKeyName;
}
async init(): Promise<void> {
async start(): Promise<void> {
this.gitService.resetService();
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
await this.sourceControlPreferencesService.loadFromDbAndApplySourceControlPreferences();

View file

@ -7,10 +7,10 @@ import type { StatusResult } from 'simple-git';
import {
getTrackingInformationFromPullResult,
isSourceControlLicensed,
} from '@/environments.ee/source-control/source-control-helper.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { ImportResult } from '@/environments.ee/source-control/types/import-result';
} from '@/modules/source-control.ee/source-control-helper.ee';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee';
import type { ImportResult } from '@/modules/source-control.ee/types/import-result';
import { EventService } from '@/events/event.service';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';

View file

@ -172,19 +172,9 @@ export class Server extends AbstractServer {
}
// ----------------------------------------
// Source Control
// Variables
// ----------------------------------------
try {
const { SourceControlService } = await import(
'@/environments.ee/source-control/source-control.service.ee'
);
await Container.get(SourceControlService).init();
await import('@/environments.ee/source-control/source-control.controller.ee');
} catch (error) {
this.logger.warn(`Source control initialization failed: ${(error as Error).message}`);
}
try {
await import('@/environments.ee/variables/variables.controller.ee');
} catch (error) {

View file

@ -18,7 +18,7 @@ import type { IExecutionTrackProperties } from '@/interfaces';
import { License } from '@/license';
import { PostHogClient } from '@/posthog';
import { SourceControlPreferencesService } from '../environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlPreferencesService } from '../modules/source-control.ee/source-control-preferences.service.ee';
type ExecutionTrackDataKey =
| 'manual_error'

View file

@ -15,18 +15,14 @@ import { writeFile as fsWriteFile } from 'node:fs/promises';
import path from 'node:path';
import { v4 as uuid } from 'uuid';
import { SourceControlExportService } from '@/environments.ee/source-control/source-control-export.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import { SourceControlExportService } from '@/modules/source-control.ee/source-control-export.service.ee';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import { createCredentials } from '../shared/db/credentials';
import { createUser } from '../shared/db/users';
// Mock file system operations
jest.mock('node:fs/promises');
jest.mock('@/environments.ee/source-control/source-control-helper.ee', () => ({
...jest.requireActual('@/environments.ee/source-control/source-control-helper.ee'),
sourceControlFoldersExistCheck: jest.fn().mockResolvedValue(true),
}));
describe('SourceControlExportService Integration', () => {
let exportService: SourceControlExportService;

View file

@ -37,10 +37,10 @@ import * as utils from 'n8n-workflow';
import { nanoid } from 'nanoid';
import fsp from 'node:fs/promises';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import { SourceControlScopedService } from '@/environments.ee/source-control/source-control-scoped.service';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import { SourceControlContext } from '@/environments.ee/source-control/types/source-control-context';
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import { SourceControlContext } from '@/modules/source-control.ee/types/source-control-context';
import type { IWorkflowToImport } from '@/interfaces';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { createFolder } from '@test-integration/db/folders';

View file

@ -3,8 +3,8 @@ import { mockInstance } from '@n8n/backend-test-utils';
import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee';
import { Telemetry } from '@/telemetry';
import { createUser } from '../shared/db/users';

View file

@ -28,18 +28,18 @@ import {
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from '@/environments.ee/source-control/constants';
import { SourceControlExportService } from '@/environments.ee/source-control/source-control-export.service.ee';
import type { SourceControlGitService } from '@/environments.ee/source-control/source-control-git.service.ee';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlScopedService } from '@/environments.ee/source-control/source-control-scoped.service';
import { SourceControlStatusService } from '@/environments.ee/source-control/source-control-status.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import type { ExportableFolder } from '@/environments.ee/source-control/types/exportable-folders';
import type { ExportableWorkflow } from '@/environments.ee/source-control/types/exportable-workflow';
import type { RemoteResourceOwner } from '@/environments.ee/source-control/types/resource-owner';
} from '@/modules/source-control.ee/constants';
import { SourceControlExportService } from '@/modules/source-control.ee/source-control-export.service.ee';
import type { SourceControlGitService } from '@/modules/source-control.ee/source-control-git.service.ee';
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
import { SourceControlStatusService } from '@/modules/source-control.ee/source-control-status.service.ee';
import { SourceControlService } from '@/modules/source-control.ee/source-control.service.ee';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import type { ExportableFolder } from '@/modules/source-control.ee/types/exportable-folders';
import type { ExportableWorkflow } from '@/modules/source-control.ee/types/exportable-workflow';
import type { RemoteResourceOwner } from '@/modules/source-control.ee/types/resource-owner';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';

View file

@ -226,7 +226,7 @@ export const setupTestServer = ({
}
case 'sourceControl':
await import('@/environments.ee/source-control/source-control.controller.ee');
await import('@/modules/source-control.ee/source-control.controller.ee');
break;
case 'community-packages':

View file

@ -165,7 +165,7 @@ Module-level decorators to be aware of:
## Controller
To register a controller with the server, simply import the controller file in the module entrypoint:
To register a controller with the server, simply import the controller file in the module entrypoint:
```ts
@BackendModule({ name: 'my-feature' })
@ -261,7 +261,7 @@ export class MyFeatureRepository extends Repository<MyFeatureEntity> {
}
async getSummary() {
return await /* typeorm query on entities */;
return await /* typeorm query on entities */;
}
}
```
@ -293,7 +293,7 @@ Entities must be registered with `typeorm` in the module entrypoint:
class MyFeatureModule implements ModuleInterface {
async entities() {
const { MyFeatureEntity } = await import('./my-feature.entity');
return [MyFeatureEntity];
}
}
@ -343,7 +343,7 @@ Currently, testing utilities live partly at `cli` and partly at `@n8n/backend-te
1. A few aspects of modules continue to be defined outside a module's dir:
- Add a license flag to `LICENSE_FEATURES` at `packages/@n8n/constants/src/index.ts`
- Add a logging scope to `LOG_SCOPES` at `packages/cli/src/logging.config.ts`
- Add a logging scope to `LOG_SCOPES` at `packages/@n8n/config/src/configs/logging.config.ts`
- Add a license check to `LicenseState` at `packages/@n8n/backend-common/src/license-state.ts`
- Add a migration (as discussed above) at `packages/@n8n/db/src/migrations`
- Add request payload validation using `zod` at `@n8n/api-types`
@ -351,7 +351,7 @@ Currently, testing utilities live partly at `cli` and partly at `@n8n/backend-te
2. License events (e.g. expiration) currently do not trigger module shutdown or initialization at runtime.
3. Some core functionality is yet to be moved from `cli` into common packages. This is not a blocker for module adoption, but this is desirable so that (a) modules become decoupled from `cli` in the long term, and (b) future external extensions can access some of that functionality.
3. Some core functionality is yet to be moved from `cli` into common packages. This is not a blocker for module adoption, but this is desirable so that (a) modules become decoupled from `cli` in the long term, and (b) future external extensions can access some of that functionality.
4. Existing features that are not modules (e.g. LDAP) should be turned into modules over time.