feat(core): Add flag to import workflow cli to activate workflow on import (#29341)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.13.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Konstantin Tieber 2026-05-05 10:25:41 +02:00 committed by GitHub
parent 77eb53363d
commit db3b57b040
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 409 additions and 8 deletions

View File

@ -0,0 +1,77 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import '@/zod-alias-support';
import { ImportService } from '@/services/import.service';
import { ImportWorkflowsCommand } from '../workflow';
jest.mock('@/services/import.service');
describe('ImportWorkflowsCommand', () => {
mockInstance(ImportService);
const globalConfig = Container.get(GlobalConfig);
const originalMode = globalConfig.executions.mode;
afterEach(() => {
globalConfig.executions.mode = originalMode;
});
const buildCommand = () => {
const command = new ImportWorkflowsCommand();
// @ts-expect-error Protected property
command.logger = {
info: jest.fn(),
error: jest.fn(),
};
return command;
};
describe('--activeState flag', () => {
it('throws when n8n is not running in queue mode and activeState is set to "fromJson"', async () => {
globalConfig.executions.mode = 'regular';
const command = buildCommand();
// @ts-expect-error Protected property
command.flags = {
input: './workflows.json',
separate: false,
activeState: 'fromJson',
};
await expect(command.run()).rejects.toThrow(
'The "--activeState=fromJson" flag can only be used when n8n is running in queue or multi-main mode. In regular deployment mode, workflow activation is not supported.',
);
});
it('does not throw on the queue-mode guard when running in queue mode', async () => {
globalConfig.executions.mode = 'queue';
const command = buildCommand();
// @ts-expect-error Protected property
command.flags = {
// `input` intentionally missing so `run` returns early after the guard
// without us needing to mock filesystem/repositories.
separate: false,
activeState: 'fromJson',
};
await expect(command.run()).resolves.toBeUndefined();
});
it('does not throw when activeState is "false", regardless of mode', async () => {
globalConfig.executions.mode = 'regular';
const command = buildCommand();
// @ts-expect-error Protected property
command.flags = {
separate: false,
activeState: 'false',
};
await expect(command.run()).resolves.toBeUndefined();
});
});
});

View File

@ -51,6 +51,16 @@ const flagsSchema = z.object({
.string()
.describe('The ID of the project to assign the imported workflows to')
.optional(),
activeState: z
.enum(['false', 'fromJson'], {
errorMap: () => ({
message: 'Valid values for flag "--activeState" are only "false" or "fromJson".',
}),
})
.describe(
'Whether to respect the JSON active field. "false" (default) deactivates all imported workflows. "fromJson" activates/deactivates each workflow based on its JSON active field.',
)
.default('false'),
});
@Command({
@ -62,6 +72,7 @@ const flagsSchema = z.object({
'--input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
'--input=file.json --projectId=Ox8O54VQrmBrb4qL',
'--separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
'--input=file.json --activeState=fromJson',
],
flagsSchema,
})
@ -69,6 +80,12 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
async run(): Promise<void> {
const { flags } = this;
if (flags.activeState === 'fromJson' && this.globalConfig.executions.mode !== 'queue') {
throw new UserError(
'The "--activeState=fromJson" flag can only be used when n8n is running in queue or multi-main mode. In regular deployment mode, workflow activation is not supported.',
);
}
if (!flags.input) {
this.logger.info('An input file or directory with --input must be provided');
return;
@ -101,7 +118,9 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
this.logger.info(`Importing ${workflows.length} workflows...`);
await Container.get(ImportService).importWorkflows(workflows, project.id);
await Container.get(ImportService).importWorkflows(workflows, project.id, {
activeState: flags.activeState,
});
this.reportSuccess(workflows.length);
}

View File

@ -1,6 +1,11 @@
import { safeJoinPath, type Logger } from '@n8n/backend-common';
import type { DatabaseConfig } from '@n8n/config';
import type { CredentialsRepository, TagRepository } from '@n8n/db';
import type {
CredentialsRepository,
TagRepository,
WorkflowPublishHistoryRepository,
WorkflowRepository,
} from '@n8n/db';
import { type DataSource, type EntityManager } from '@n8n/typeorm';
import { readdir, readFile } from 'fs/promises';
import { mock } from 'jest-mock-extended';
@ -42,6 +47,8 @@ describe('ImportService', () => {
let mockActiveWorkflowManager: ActiveWorkflowManager;
let mockWorkflowIndexService: WorkflowIndexService;
let mockDatabaseConfig: DatabaseConfig;
let mockWorkflowRepository: WorkflowRepository;
let mockWorkflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
beforeEach(() => {
jest.clearAllMocks();
@ -55,6 +62,8 @@ describe('ImportService', () => {
mockActiveWorkflowManager = mock<ActiveWorkflowManager>();
mockWorkflowIndexService = mock<WorkflowIndexService>();
mockDatabaseConfig = mock<DatabaseConfig>();
mockWorkflowRepository = mock<WorkflowRepository>();
mockWorkflowPublishHistoryRepository = mock<WorkflowPublishHistoryRepository>();
// Set up cipher mock
mockCipher.decrypt = jest.fn((data: string) => data.replace('encrypted:', ''));
@ -103,6 +112,8 @@ describe('ImportService', () => {
mockActiveWorkflowManager,
mockWorkflowIndexService,
mockDatabaseConfig,
mockWorkflowRepository,
mockWorkflowPublishHistoryRepository,
);
});

View File

@ -9,11 +9,14 @@ import {
TagRepository,
WorkflowHistory,
WorkflowPublishHistory,
WorkflowPublishHistoryRepository,
WorkflowRepository,
} from '@n8n/db';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { DataSource, EntityManager, In } from '@n8n/typeorm';
import { Service } from '@n8n/di';
import { type INode, type INodeCredentialsDetails, type IWorkflowBase } from 'n8n-workflow';
import { ensureError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { readdir, readFile } from 'fs/promises';
@ -58,6 +61,8 @@ export class ImportService {
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly workflowIndexService: WorkflowIndexService,
private readonly databaseConfig: DatabaseConfig,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository,
) {}
async initRecords() {
@ -65,7 +70,11 @@ export class ImportService {
this.dbTags = await this.tagRepository.find();
}
async importWorkflows(workflows: IWorkflowDb[], projectId: string) {
async importWorkflows(
workflows: IWorkflowDb[],
projectId: string,
{ activeState = 'false' }: { activeState?: 'false' | 'fromJson' } = {},
) {
await this.initRecords();
const { manager: dbManager } = this.credentialsRepository;
@ -108,6 +117,7 @@ export class ImportService {
}
const insertedWorkflows: IWorkflowBase[] = [];
const workflowsToActivate: Array<{ workflowId: string; versionId: string }> = [];
await dbManager.transaction(async (tx) => {
const workflowsNeedingPublishHistory: Array<{ workflowId: string; versionId: string }> = [];
@ -116,11 +126,19 @@ export class ImportService {
// Always generate a new versionId on import to ensure proper history ordering
workflow.versionId = uuid();
// Always deactivate workflows on import - they need to be manually activated later
// Store the old activeVersionId to record the deactivation of the old version
const oldActiveVersionId = workflow.id ? activeVersionIdByWorkflow.get(workflow.id) : null;
if (oldActiveVersionId || workflow.activeVersionId || workflow.active) {
this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`);
const shouldActivate = activeState === 'fromJson' && workflow.active;
const versionIdToActivate = workflow.versionId;
// Always upsert with active=false and activeVersionId=null.
// Activation happens post-transaction once the new workflow_history row exists
// (the activeVersionId FK references workflow_history.versionId).
if (
!shouldActivate &&
(oldActiveVersionId || workflow.activeVersionId || workflow.active)
) {
this.logger.info(`Deactivating workflow "${workflow.name}".`);
}
workflow.active = false;
workflow.activeVersionId = null;
@ -134,6 +152,10 @@ export class ImportService {
workflowsNeedingPublishHistory.push({ workflowId, versionId: oldActiveVersionId });
}
if (shouldActivate) {
workflowsToActivate.push({ workflowId, versionId: versionIdToActivate });
}
const personalProject = await tx.findOneByOrFail(Project, { id: projectId });
// Create relationship if the workflow was inserted instead of updated.
@ -180,6 +202,10 @@ export class ImportService {
}
});
for (const { workflowId, versionId } of workflowsToActivate) {
await this.activateWorkflow(workflowId, versionId);
}
// Directly update the index for the important workflows, since they don't generate
// workflow-update events during import.
// Workflow indexing isn't supported on legacy SQLite.
@ -190,6 +216,31 @@ export class ImportService {
}
}
private async activateWorkflow(workflowId: string, versionIdToActivate: string): Promise<void> {
let didActivate = false;
try {
await this.workflowRepository.update(
{ id: workflowId },
{ activeVersionId: versionIdToActivate },
);
await this.workflowRepository.updateActiveState(workflowId, true);
await this.activeWorkflowManager.add(workflowId, 'activate');
didActivate = true;
} catch (e) {
const error = ensureError(e);
this.logger.error(`Failed to activate workflow ${workflowId}`, { error });
} finally {
if (didActivate) {
await this.workflowPublishHistoryRepository.addRecord({
workflowId,
versionId: versionIdToActivate,
event: 'activated',
userId: null,
});
}
}
}
async replaceInvalidCreds(workflow: IWorkflowBase, projectId: string) {
try {
await replaceInvalidCredentials(workflow, projectId);

View File

@ -5,7 +5,9 @@ import {
getAllSharedWorkflows,
getAllWorkflows,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowPublishHistoryRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { nanoid } from 'nanoid';
import '@/zod-alias-support';
@ -340,3 +342,115 @@ test('`import:workflow --projectId ... --userId ...` fails explaining that only
'You cannot use `--userId` and `--projectId` together. Use one or the other.',
);
});
describe('--activeState flag', () => {
const globalConfig = Container.get(GlobalConfig);
const originalMode = globalConfig.executions.mode;
beforeAll(() => {
globalConfig.executions.mode = 'queue';
});
afterAll(() => {
globalConfig.executions.mode = originalMode;
});
describe('fromJson', () => {
it('should activate a workflow that is marked as active in the imported json', async () => {
await createOwner();
await command.run([
'--separate',
'--input=./test/integration/commands/import-workflows/separate',
'--activeState=fromJson',
]);
const workflowsInDB = await getAllWorkflows();
const activeWorkflow = workflowsInDB.find((w) => w.name === 'active-workflow');
const inactiveWorkflow = workflowsInDB.find((w) => w.name === 'inactive-workflow');
expect(workflowsInDB).toHaveLength(2);
expect(activeWorkflow).toMatchObject({ active: true });
expect(activeWorkflow?.activeVersionId).toBe(activeWorkflow?.versionId);
expect(inactiveWorkflow).toMatchObject({ active: false, activeVersionId: null });
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate');
expect(activeWorkflowManager.add).not.toHaveBeenCalledWith('999', expect.anything());
});
it('should deactivate the previously active version and activate the new version when importing a workflow json with an ID that already exists for an active workflow', async () => {
await createOwner();
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
'--activeState=fromJson',
]);
const [first] = await getAllWorkflows();
const v1VersionId = first.versionId;
expect(first).toMatchObject({ id: '998', active: true, name: 'active-workflow' });
expect(first.activeVersionId).toBe(v1VersionId);
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/updated.json',
'--activeState=fromJson',
]);
const [second] = await getAllWorkflows();
expect(second).toMatchObject({
id: '998',
active: true,
name: 'active-workflow updated',
});
expect(second.versionId).not.toBe(v1VersionId);
expect(second.activeVersionId).toBe(second.versionId);
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
expect(activeWorkflowManager.remove).toHaveBeenCalledWith('998'); // first removing previously active version
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate'); // added the new version
const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository);
expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(2);
expect(publishHistoryRepo.addRecord).toHaveBeenLastCalledWith({
workflowId: '998',
versionId: second.versionId,
event: 'activated',
userId: null,
});
});
});
describe('false', () => {
it('should deactivate a workflow that is active in the workflow json to import', async () => {
await createOwner();
const fixture =
'./test/integration/commands/import-workflows/separate/001-activeWorkflow.json';
// Setup: activate the workflow first by importing it with --activeState=fromJson
await command.run([`--input=${fixture}`, '--activeState=fromJson']);
const [active] = await getAllWorkflows();
expect(active).toMatchObject({ id: '998', active: true });
expect(active.activeVersionId).toBe(active.versionId);
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
jest.mocked(activeWorkflowManager.add).mockClear();
jest.mocked(activeWorkflowManager.remove).mockClear();
// Action: re-import the same active workflow JSON with --activeState=false
await command.run([`--input=${fixture}`, '--activeState=false']);
const [deactivated] = await getAllWorkflows();
expect(deactivated).toMatchObject({
id: '998',
name: 'active-workflow',
active: false,
activeVersionId: null,
});
expect(activeWorkflowManager.remove).toHaveBeenCalledWith('998');
expect(activeWorkflowManager.add).not.toHaveBeenCalled();
});
});
});

View File

@ -71,6 +71,8 @@ describe('ImportService', () => {
mockActiveWorkflowManager,
mockWorkflowIndexService,
Container.get(DatabaseConfig),
workflowRepository,
workflowPublishHistoryRepository,
);
});
@ -331,4 +333,126 @@ describe('ImportService', () => {
const updatedWorkflow = await getWorkflowById(initialWorkflow.id);
expect(updatedWorkflow?.versionId).toBe(historyRecords[1].versionId);
});
describe('activeState: fromJson', () => {
test('should activate imported workflow when JSON has active=true', async () => {
const workflowToImport = await createWorkflow();
workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
const dbWorkflow = await getWorkflowById(workflowToImport.id);
if (!dbWorkflow) fail('Expected to find workflow');
expect(dbWorkflow.active).toBe(true);
expect(dbWorkflow.activeVersionId).toBe(dbWorkflow.versionId);
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate');
});
test('should deactivate imported workflow that is updating existing one when JSON has active=false', async () => {
jest.mocked(mockActiveWorkflowManager.add).mockClear();
const existingWorkflow = await createActiveWorkflow();
const workflowToImport = await getWorkflowById(existingWorkflow.id);
if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = false;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
const dbWorkflow = await getWorkflowById(workflowToImport.id);
if (!dbWorkflow) fail('Expected to find workflow');
expect(dbWorkflow.active).toBe(false);
expect(dbWorkflow.activeVersionId).toBeNull();
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled();
});
test('should leave imported workflow deactivated when JSON has active=false', async () => {
jest.mocked(mockActiveWorkflowManager.add).mockClear();
const workflowToImport = await createWorkflow();
workflowToImport.active = false;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
const dbWorkflow = await getWorkflowById(workflowToImport.id);
if (!dbWorkflow) fail('Expected to find workflow');
expect(dbWorkflow.active).toBe(false);
expect(dbWorkflow.activeVersionId).toBeNull();
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled();
});
test('should record both deactivated (old) and activated (new) publish history when re-importing an active workflow', async () => {
const existingWorkflow = await createActiveWorkflow();
const originalActiveVersionId = existingWorkflow.activeVersionId!;
const workflowToImport = await getWorkflowById(existingWorkflow.id);
if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
const dbWorkflow = await getWorkflowById(existingWorkflow.id);
if (!dbWorkflow) fail('Expected to find workflow');
const deactivatedRecords = await workflowPublishHistoryRepository.find({
where: { workflowId: existingWorkflow.id, event: 'deactivated' },
});
const activatedRecords = await workflowPublishHistoryRepository.find({
where: { workflowId: existingWorkflow.id, event: 'activated' },
});
expect(deactivatedRecords).toHaveLength(1);
expect(deactivatedRecords[0].versionId).toBe(originalActiveVersionId);
expect(activatedRecords).toHaveLength(1);
expect(activatedRecords[0].versionId).toBe(dbWorkflow.versionId);
expect(activatedRecords[0].userId).toBeNull();
});
test('should not call ActiveWorkflowManager.remove for a brand-new active workflow', async () => {
jest.mocked(mockActiveWorkflowManager.remove).mockClear();
jest.mocked(mockActiveWorkflowManager.add).mockClear();
const workflowToImport = await createWorkflow();
workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
expect(mockActiveWorkflowManager.remove).not.toHaveBeenCalled();
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1);
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate');
});
test('should call ActiveWorkflowManager.remove exactly once when re-importing an active workflow', async () => {
jest.mocked(mockActiveWorkflowManager.remove).mockClear();
jest.mocked(mockActiveWorkflowManager.add).mockClear();
const existingWorkflow = await createActiveWorkflow();
const workflowToImport = await getWorkflowById(existingWorkflow.id);
if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, {
activeState: 'fromJson',
});
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledTimes(1);
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(existingWorkflow.id);
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1);
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(existingWorkflow.id, 'activate');
});
});
});

View File

@ -1,5 +1,6 @@
import { testDb, mockInstance } from '@n8n/backend-test-utils';
import type { CommandClass } from '@n8n/decorators';
import { CommandMetadata, type CommandClass } from '@n8n/decorators';
import { Container } from '@n8n/di';
import argvParser from 'yargs-parser';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
@ -29,7 +30,11 @@ export const setupTestCommand = <T extends CommandClass>(Command: T) => {
const run = async (argv: string[] = []) => {
const command = new Command();
command.flags = argvParser(argv);
const rawFlags = argvParser(argv);
const entry = Container.get(CommandMetadata)
.getEntries()
.find(([, e]) => e.class === Command)?.[1];
command.flags = entry?.flagsSchema ? entry.flagsSchema.parse(rawFlags) : rawFlags;
await command.init?.();
await command.run();
return command;