From 29f1d11ab96a3dfb5498cfaff901b434bd6aa3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 31 Aug 2023 19:49:37 +0200 Subject: [PATCH 001/259] refactor(core): Simplify executions and binary data pruning --- .../handlers/executions/executions.handler.ts | 4 - .../handlers/executions/executions.service.ts | 4 +- packages/cli/src/Server.ts | 1 - .../cli/src/WorkflowExecuteAdditionalData.ts | 88 +----------- packages/cli/src/WorkflowRunner.ts | 2 +- packages/cli/src/config/schema.ts | 12 -- .../repositories/execution.repository.ts | 127 ++++++++++++++---- .../cli/src/executions/executions.service.ts | 12 +- .../core/src/BinaryDataManager/FileSystem.ts | 56 +------- packages/core/src/BinaryDataManager/index.ts | 16 --- packages/core/src/Interfaces.ts | 3 - .../core/test/NodeExecuteFunctions.test.ts | 2 - packages/nodes-base/test/nodes/Helpers.ts | 1 - 13 files changed, 117 insertions(+), 211 deletions(-) diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 03b70556e0970..9a5c419b7deef 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -1,7 +1,5 @@ import type express from 'express'; -import { BinaryDataManager } from 'n8n-core'; - import { getExecutions, getExecutionInWorkflows, @@ -37,8 +35,6 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - await BinaryDataManager.getInstance().deleteBinaryDataByExecutionIds([execution.id!]); - await deleteExecution(execution); execution.id = id; diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts index 6061df800ca97..6ab0fdfc8519e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts @@ -110,6 +110,6 @@ export async function getExecutionInWorkflows( }); } -export async function deleteExecution(execution: IExecutionBase): Promise { - return Container.get(ExecutionRepository).deleteExecution(execution.id as string); +export async function deleteExecution(execution: IExecutionBase): Promise { + return Container.get(ExecutionRepository).softDeleteExecution(execution.id as string); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 7500ef17c0bd7..09a464589999a 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -393,7 +393,6 @@ export class Server extends AbstractServer { ), executions_data_prune: config.getEnv('executions.pruneData'), executions_data_max_age: config.getEnv('executions.pruneDataMaxAge'), - executions_data_prune_timeout: config.getEnv('executions.pruneDataTimeout'), }, deploymentType: config.getEnv('deployment.type'), binaryDataMode: binaryDataConfig.mode, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 5760cce3d1a6d..903daba4775c6 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -9,7 +9,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { BinaryDataManager, UserSettings, WorkflowExecute } from 'n8n-core'; +import { UserSettings, WorkflowExecute } from 'n8n-core'; import type { IDataObject, @@ -38,9 +38,6 @@ import { import pick from 'lodash/pick'; import { Container } from 'typedi'; -import type { FindOptionsWhere } from 'typeorm'; -import { LessThanOrEqual, In } from 'typeorm'; -import { DateUtils } from 'typeorm/util/DateUtils'; import config from '@/config'; import * as Db from '@/Db'; import { ActiveExecutions } from '@/ActiveExecutions'; @@ -48,7 +45,6 @@ import { CredentialsHelper } from '@/CredentialsHelper'; import { ExternalHooks } from '@/ExternalHooks'; import type { IExecutionDb, - IExecutionFlattedDb, IPushDataExecutionFinished, IWorkflowExecuteProcess, IWorkflowExecutionDataProcess, @@ -181,77 +177,6 @@ export function executeErrorWorkflow( } } -/** - * Prunes Saved Execution which are older than configured. - * Throttled to be executed just once in configured timeframe. - * TODO: Consider moving this whole function to the repository or at least the queries - */ -let throttling = false; -async function pruneExecutionData(this: WorkflowHooks): Promise { - if (!throttling) { - Logger.verbose('Pruning execution data from database'); - - throttling = true; - const timeout = config.getEnv('executions.pruneDataTimeout'); // in seconds - const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h - const maxCount = config.getEnv('executions.pruneDataMaxCount'); - const date = new Date(); // today - date.setHours(date.getHours() - maxAge); - - // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 - - const utcDate = DateUtils.mixedDateToUtcDatetimeString(date); - - const toPrune: Array> = [ - { stoppedAt: LessThanOrEqual(utcDate) }, - ]; - - if (maxCount > 0) { - const executions = await Db.collections.Execution.find({ - select: ['id'], - skip: maxCount, - take: 1, - order: { id: 'DESC' }, - }); - - if (executions[0]) { - toPrune.push({ id: LessThanOrEqual(executions[0].id) }); - } - } - - try { - setTimeout(() => { - throttling = false; - }, timeout * 1000); - let executionIds: Array; - do { - executionIds = ( - await Db.collections.Execution.find({ - select: ['id'], - where: toPrune, - take: 100, - }) - ).map(({ id }) => id); - await Db.collections.Execution.delete({ id: In(executionIds) }); - // Mark binary data for deletion for all executions - await BinaryDataManager.getInstance().markDataForDeletionByExecutionIds(executionIds); - } while (executionIds.length > 0); - } catch (error) { - ErrorReporter.error(error); - throttling = false; - Logger.error( - `Failed pruning execution data from database for execution ID ${this.executionId} (hookFunctionsSave)`, - { - ...error, - executionId: this.executionId, - sessionId: this.sessionId, - workflowId: this.workflowData.id, - }, - ); - } - } -} - export async function saveExecutionMetadata( executionId: string, executionMetadata: Record, @@ -535,11 +460,6 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - // Prune old execution data - if (config.getEnv('executions.pruneData')) { - await pruneExecutionData.call(this); - } - const isManualMode = [this.mode, parentProcessMode].includes('manual'); try { @@ -567,8 +487,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) { - // Data is always saved, so we remove from database - await Container.get(ExecutionRepository).deleteExecution(this.executionId, true); + await Container.get(ExecutionRepository).softDeleteExecution(this.executionId); return; } @@ -606,8 +525,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { this.executionId, this.retryOf, ); - // Data is always saved, so we remove from database - await Container.get(ExecutionRepository).deleteExecution(this.executionId); + await Container.get(ExecutionRepository).softDeleteExecution(this.executionId); return; } diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index b7e4b4eaacb48..5103e4dafe27e 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -601,7 +601,7 @@ export class WorkflowRunner { (workflowDidSucceed && saveDataSuccessExecution === 'none') || (!workflowDidSucceed && saveDataErrorExecution === 'none') ) { - await Container.get(ExecutionRepository).deleteExecution(executionId); + await Container.get(ExecutionRepository).softDeleteExecution(executionId); } // eslint-disable-next-line id-denylist } catch (err) { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 157e9c2a95723..586fba82b973e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -316,12 +316,6 @@ export const schema = { default: 336, env: 'EXECUTIONS_DATA_MAX_AGE', }, - pruneDataTimeout: { - doc: 'Timeout (seconds) after execution data has been pruned', - format: Number, - default: 3600, - env: 'EXECUTIONS_DATA_PRUNE_TIMEOUT', - }, // Additional pruning option to delete executions if total count exceeds the configured max. // Deletes the oldest entries first @@ -907,12 +901,6 @@ export const schema = { env: 'N8N_BINARY_DATA_STORAGE_PATH', doc: 'Path for binary data storage in "filesystem" mode', }, - binaryDataTTL: { - format: Number, - default: 60, - env: 'N8N_BINARY_DATA_TTL', - doc: 'TTL for binary data of unsaved executions in minutes', - }, }, deployment: { diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 07215d4701908..ce4fb0d1bae5b 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,29 +1,33 @@ import { Service } from 'typedi'; import { DataSource, In, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, FindOneOptions, FindOptionsWhere, SelectQueryBuilder, } from 'typeorm'; -import { ExecutionEntity } from '../entities/ExecutionEntity'; import { parse, stringify } from 'flatted'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import type { IExecutionsSummary, IRunExecutionData } from 'n8n-workflow'; +import { BinaryDataManager } from 'n8n-core'; import type { IExecutionBase, IExecutionDb, IExecutionFlattedDb, IExecutionResponse, } from '@/Interfaces'; -import { LoggerProxy } from 'n8n-workflow'; -import type { IExecutionsSummary, IRunExecutionData } from 'n8n-workflow'; -import { ExecutionDataRepository } from './executionData.repository'; -import type { ExecutionData } from '../entities/ExecutionData'; + +import config from '@/config'; import type { IGetExecutionsQueryFilter } from '@/executions/executions.service'; import { isAdvancedExecutionFiltersEnabled } from '@/executions/executionHelpers'; +import type { ExecutionData } from '../entities/ExecutionData'; +import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; -import { DateUtils } from 'typeorm/util/DateUtils'; -import { BinaryDataManager } from 'n8n-core'; -import config from '@/config'; +import { ExecutionDataRepository } from './executionData.repository'; +import { inTest } from '@/constants'; + +const PRUNING_BATCH_SIZE = 100; function parseFiltersToQueryBuilder( qb: SelectQueryBuilder, @@ -71,6 +75,14 @@ export class ExecutionRepository extends Repository { private readonly executionDataRepository: ExecutionDataRepository, ) { super(ExecutionEntity, dataSource.manager); + + if (!inTest) { + if (config.getEnv('executions.pruneData')) { + setInterval(async () => this.pruneOlderExecutions(), 60 * 60 * 1000); // Every hour + } + + setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * 60 * 1000); // Every 15 minutes + } } async findMultipleExecutions( @@ -238,16 +250,6 @@ export class ExecutionRepository extends Repository { } } - async deleteExecution(executionId: string, deferBinaryDataDeletion = false) { - const binaryDataManager = BinaryDataManager.getInstance(); - if (deferBinaryDataDeletion) { - await binaryDataManager.markDataForDeletionByExecutionId(executionId); - } else { - await binaryDataManager.deleteBinaryDataByExecutionIds([executionId]); - } - return this.delete({ id: executionId }); - } - async countExecutions( filters: IGetExecutionsQueryFilter | undefined, accessibleWorkflowIds: string[], @@ -287,7 +289,7 @@ export class ExecutionRepository extends Repository { } } catch (error) { if (error instanceof Error) { - LoggerProxy.warn(`Failed to get executions count from Postgres: ${error.message}`, { + Logger.warn(`Failed to get executions count from Postgres: ${error.message}`, { error, }); } @@ -357,7 +359,15 @@ export class ExecutionRepository extends Repository { }); } - async deleteExecutions( + async softDeleteExecution(executionId: string) { + await this.update({ id: executionId }, { deletedAt: Date.now() }); + } + + async softDeleteExecutions(executionIds: string[]) { + await this.update({ id: In(executionIds) }, { deletedAt: Date.now() }); + } + + async deleteExecutionsByFilter( filters: IGetExecutionsQueryFilter | undefined, accessibleWorkflowIds: string[], deleteConditions: { @@ -389,7 +399,7 @@ export class ExecutionRepository extends Repository { if (!executions.length) { if (deleteConditions.ids) { - LoggerProxy.error('Failed to delete an execution due to insufficient permissions', { + Logger.error('Failed to delete an execution due to insufficient permissions', { executionIds: deleteConditions.ids, }); } @@ -397,13 +407,78 @@ export class ExecutionRepository extends Repository { } const executionIds = executions.map(({ id }) => id); - const binaryDataManager = BinaryDataManager.getInstance(); - await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); - do { // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error - const batch = executionIds.splice(0, 500); - await this.delete(batch); + const batch = executionIds.splice(0, PRUNING_BATCH_SIZE); + await this.softDeleteExecutions(batch); } while (executionIds.length > 0); } + + private async pruneOlderExecutions() { + Logger.verbose('Pruning execution data from database'); + + const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h + const maxCount = config.getEnv('executions.pruneDataMaxCount'); + + // Find ids of all executions that were stopped longer that pruneDataMaxAge ago + const date = new Date(); + date.setHours(date.getHours() - maxAge); + + const toPrune: Array> = [ + // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 + { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, + ]; + + if (maxCount > 0) { + const executions = await this.find({ + select: ['id'], + skip: maxCount, + take: 1, + order: { id: 'DESC' }, + }); + + if (executions[0]) { + toPrune.push({ id: LessThanOrEqual(executions[0].id) }); + } + } + + const executionIds = ( + await this.find({ + select: ['id'], + where: toPrune, + take: PRUNING_BATCH_SIZE, + }) + ).map(({ id }) => id); + await this.softDeleteExecutions(executionIds); + + if (executionIds.length === PRUNING_BATCH_SIZE) { + setTimeout(async () => this.pruneOlderExecutions(), 1000); + } + } + + private async deleteSoftDeletedExecutions() { + // Find ids of all executions that were deleted over an hour ago + const date = new Date(); + date.setHours(date.getHours() - 1); + + const executionIds = ( + await this.find({ + select: ['id'], + where: { + deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), + }, + take: PRUNING_BATCH_SIZE, + }) + ).map(({ id }) => id); + + const binaryDataManager = BinaryDataManager.getInstance(); + await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); + + // Actually delete these executions + await this.delete({ id: In(executionIds) }); + + if (executionIds.length === PRUNING_BATCH_SIZE) { + setTimeout(async () => this.deleteSoftDeletedExecutions(), 1000); + } + } } diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index aab68b449d4f5..d13776c5d20d8 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -351,9 +351,13 @@ export class ExecutionsService { } } - return Container.get(ExecutionRepository).deleteExecutions(requestFilters, sharedWorkflowIds, { - deleteBefore, - ids, - }); + return Container.get(ExecutionRepository).deleteExecutionsByFilter( + requestFilters, + sharedWorkflowIds, + { + deleteBefore, + ids, + }, + ); } } diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index cf9f1d94de220..6ae70271211b9 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -1,4 +1,3 @@ -import glob from 'fast-glob'; import { createReadStream } from 'fs'; import fs from 'fs/promises'; import path from 'path'; @@ -10,32 +9,19 @@ import { jsonParse } from 'n8n-workflow'; import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import { FileNotFoundError } from '../errors'; -const PREFIX_METAFILE = 'binarymeta'; - const executionExtractionRegexp = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; export class BinaryDataFileSystem implements IBinaryDataManager { private storagePath: string; - private binaryDataTTL: number; - constructor(config: IBinaryDataConfig) { this.storagePath = config.localStoragePath; - this.binaryDataTTL = config.binaryDataTTL; } - async init(startPurger = false): Promise { - if (startPurger) { - setInterval(async () => { - await this.deleteMarkedFiles(); - }, this.binaryDataTTL * 30000); - } - + async init(): Promise { await this.assertFolder(this.storagePath); await this.assertFolder(this.getBinaryDataMetaPath()); - - await this.deleteMarkedFiles(); } async getFileSize(identifier: string): Promise { @@ -81,47 +67,8 @@ export class BinaryDataFileSystem implements IBinaryDataManager { return this.resolveStoragePath(`${identifier}.metadata`); } - async markDataForDeletionByExecutionId(executionId: string): Promise { - const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000); - return fs.writeFile( - this.resolveStoragePath('meta', `${PREFIX_METAFILE}_${executionId}_${tt.valueOf()}`), - '', - ); - } - - async deleteMarkedFiles(): Promise { - return this.deleteMarkedFilesByMeta(this.getBinaryDataMetaPath(), PREFIX_METAFILE); - } - - private async deleteMarkedFilesByMeta(metaPath: string, filePrefix: string): Promise { - const currentTimeValue = new Date().valueOf(); - const metaFileNames = await glob(`${filePrefix}_*`, { cwd: metaPath }); - - const executionIds = metaFileNames - .map((f) => f.split('_') as [string, string, string]) - .filter(([prefix, , ts]) => { - if (prefix !== filePrefix) return false; - const execTimestamp = parseInt(ts, 10); - return execTimestamp < currentTimeValue; - }) - .map((e) => e[1]); - - const filesToDelete = []; - const deletedIds = await this.deleteBinaryDataByExecutionIds(executionIds); - for (const executionId of deletedIds) { - filesToDelete.push( - ...(await glob(`${filePrefix}_${executionId}_`, { - absolute: true, - cwd: metaPath, - })), - ); - } - await Promise.all(filesToDelete.map(async (file) => fs.rm(file))); - } - async duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise { const newBinaryDataId = this.generateFileName(prefix); - await fs.copyFile( this.resolveStoragePath(binaryDataId), this.resolveStoragePath(newBinaryDataId), @@ -130,6 +77,7 @@ export class BinaryDataFileSystem implements IBinaryDataManager { } async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { + // TODO: switch over to new folder structure, and delete folders instead const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); const deletedIds = []; diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 7bfbf614c997b..eca32f184da26 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -156,22 +156,6 @@ export class BinaryDataManager { throw new Error('Storage mode used to store binary data not available'); } - async markDataForDeletionByExecutionId(executionId: string): Promise { - if (this.managers[this.binaryDataMode]) { - await this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId); - } - } - - async markDataForDeletionByExecutionIds(executionIds: string[]): Promise { - if (this.managers[this.binaryDataMode]) { - await Promise.all( - executionIds.map(async (id) => - this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(id), - ), - ); - } - } - async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { if (this.managers[this.binaryDataMode]) { await this.managers[this.binaryDataMode].deleteBinaryDataByExecutionIds(executionIds); diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index d01b655bf1e8a..f1f3f4ff7a5bd 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -38,7 +38,6 @@ export interface IBinaryDataConfig { mode: 'default' | 'filesystem'; availableModes: string; localStoragePath: string; - binaryDataTTL: number; } export interface IBinaryDataManager { @@ -51,8 +50,6 @@ export interface IBinaryDataManager { retrieveBinaryDataByIdentifier(identifier: string): Promise; getBinaryPath(identifier: string): string; getBinaryStream(identifier: string, chunkSize?: number): Readable; - markDataForDeletionByExecutionId(executionId: string): Promise; - deleteMarkedFiles(): Promise; deleteBinaryDataByIdentifier(identifier: string): Promise; duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise; deleteBinaryDataByExecutionIds(executionIds: string[]): Promise; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index d981c1d246d98..ec2a32e7f0e54 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -34,7 +34,6 @@ describe('NodeExecuteFunctions', () => { mode: 'default', availableModes: 'default', localStoragePath: temporaryDir, - binaryDataTTL: 1, }); // Set our binary data buffer @@ -84,7 +83,6 @@ describe('NodeExecuteFunctions', () => { mode: 'filesystem', availableModes: 'filesystem', localStoragePath: temporaryDir, - binaryDataTTL: 1, }); // Set our binary data buffer diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index ecc87dbb556d9..d14ef99577011 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -222,7 +222,6 @@ export async function initBinaryDataManager(mode: 'default' | 'filesystem' = 'de mode, availableModes: mode, localStoragePath: temporaryDir, - binaryDataTTL: 1, }); return temporaryDir; } From 05e3fefd0cc19552f01dbc8a4f51a069c5203ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 11:19:53 +0200 Subject: [PATCH 002/259] Remove test code from service --- .../databases/repositories/execution.repository.ts | 11 ++++------- .../cli/test/integration/shared/utils/testServer.ts | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 620027075b458..ddb483fd89f76 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -25,7 +25,6 @@ import type { ExecutionData } from '../entities/ExecutionData'; import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; -import { inTest } from '@/constants'; const PRUNING_BATCH_SIZE = 100; @@ -76,13 +75,11 @@ export class ExecutionRepository extends Repository { ) { super(ExecutionEntity, dataSource.manager); - if (!inTest) { - if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneOlderExecutions(), 60 * 60 * 1000); // Every hour - } - - setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * 60 * 1000); // Every 15 minutes + if (config.getEnv('executions.pruneData')) { + setInterval(async () => this.pruneOlderExecutions(), 60 * 60 * 1000); // Every hour } + + setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * 60 * 1000); // Every 15 minutes } async findMultipleExecutions( diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index b00273eb66958..54e1af53babb4 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -152,6 +152,7 @@ export const setupTestServer = ({ config.set('userManagement.jwtSecret', 'My JWT secret'); config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('executions.pruneData', false); if (enabledFeatures) { Container.get(License).isFeatureEnabled = (feature) => enabledFeatures.includes(feature); From 9dabbe86a8247edc9864bced3c972062c9c3d7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 11:24:40 +0200 Subject: [PATCH 003/259] Use time constants --- packages/cli/src/constants.ts | 6 ++++++ .../cli/src/databases/repositories/execution.repository.ts | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 8d9bbb9e14f2b..ec66cbdf32062 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -94,3 +94,9 @@ export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a export const UM_FIX_INSTRUCTION = 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; + +export const TIME = { + SECOND: 1000, + MINUTE: 60 * 1000, + HOUR: 60 * 60 * 1000, +}; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index ddb483fd89f76..981eacad3f1b7 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -25,6 +25,7 @@ import type { ExecutionData } from '../entities/ExecutionData'; import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; +import { TIME } from '@/constants'; const PRUNING_BATCH_SIZE = 100; @@ -76,10 +77,10 @@ export class ExecutionRepository extends Repository { super(ExecutionEntity, dataSource.manager); if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneOlderExecutions(), 60 * 60 * 1000); // Every hour + setInterval(async () => this.pruneOlderExecutions(), TIME.HOUR); } - setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * 60 * 1000); // Every 15 minutes + setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * TIME.MINUTE); } async findMultipleExecutions( From 3f7de8e044c8ac9c682c45acb6fd8b25a3dcb069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 11:26:38 +0200 Subject: [PATCH 004/259] Improve naming --- .../cli/src/databases/repositories/execution.repository.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 981eacad3f1b7..399999cf6bd4b 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -77,7 +77,7 @@ export class ExecutionRepository extends Repository { super(ExecutionEntity, dataSource.manager); if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneOlderExecutions(), TIME.HOUR); + setInterval(async () => this.pruneBySoftDeleting(), TIME.HOUR); } setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * TIME.MINUTE); @@ -422,7 +422,7 @@ export class ExecutionRepository extends Repository { } while (executionIds.length > 0); } - private async pruneOlderExecutions() { + private async pruneBySoftDeleting() { Logger.verbose('Pruning execution data from database'); const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h @@ -460,7 +460,7 @@ export class ExecutionRepository extends Repository { await this.softDeleteExecutions(executionIds); if (executionIds.length === PRUNING_BATCH_SIZE) { - setTimeout(async () => this.pruneOlderExecutions(), 1000); + setTimeout(async () => this.pruneBySoftDeleting(), 1000); } } From 5583814938af16a84cd3395a3a3b29224b8dde03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 11:51:11 +0200 Subject: [PATCH 005/259] Use native typeorm soft deletion --- .../v1/handlers/executions/executions.handler.ts | 12 ++++-------- .../v1/handlers/executions/executions.service.ts | 6 +----- .../cli/src/WorkflowExecuteAdditionalData.ts | 5 ++--- packages/cli/src/WorkflowRunner.ts | 2 +- .../src/databases/entities/ExecutionEntity.ts | 3 ++- .../repositories/execution.repository.ts | 16 ++++------------ 6 files changed, 14 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 9a5c419b7deef..90ed886bfa62a 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -1,11 +1,6 @@ import type express from 'express'; -import { - getExecutions, - getExecutionInWorkflows, - deleteExecution, - getExecutionsCount, -} from './executions.service'; +import { getExecutions, getExecutionInWorkflows, getExecutionsCount } from './executions.service'; import { ActiveExecutions } from '@/ActiveExecutions'; import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; import type { ExecutionRequest } from '../../../types'; @@ -13,6 +8,7 @@ import { getSharedWorkflowIds } from '../workflows/workflows.service'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; +import { ExecutionRepository } from '@/databases/repositories'; export = { deleteExecution: [ @@ -31,11 +27,11 @@ export = { // look for the execution on the workflow the user owns const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false); - if (!execution) { + if (!execution?.id) { return res.status(404).json({ message: 'Not Found' }); } - await deleteExecution(execution); + await Container.get(ExecutionRepository).softDelete(execution.id); execution.id = id; diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts index 6ab0fdfc8519e..de08c9095a601 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts @@ -1,4 +1,4 @@ -import type { DeleteResult, FindOptionsWhere } from 'typeorm'; +import type { FindOptionsWhere } from 'typeorm'; import { In, Not, Raw, LessThan } from 'typeorm'; import { Container } from 'typedi'; import type { ExecutionStatus } from 'n8n-workflow'; @@ -109,7 +109,3 @@ export async function getExecutionInWorkflows( unflattenData: true, }); } - -export async function deleteExecution(execution: IExecutionBase): Promise { - return Container.get(ExecutionRepository).softDeleteExecution(execution.id as string); -} diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 59aac1192d002..d19bae6e25009 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -39,7 +39,6 @@ import { import pick from 'lodash/pick'; import { Container } from 'typedi'; import config from '@/config'; -import * as Db from '@/Db'; import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsHelper } from '@/CredentialsHelper'; import { ExternalHooks } from '@/ExternalHooks'; @@ -471,7 +470,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) { - await Container.get(ExecutionRepository).softDeleteExecution(this.executionId); + await Container.get(ExecutionRepository).softDelete(this.executionId); return; } @@ -509,7 +508,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { this.executionId, this.retryOf, ); - await Container.get(ExecutionRepository).softDeleteExecution(this.executionId); + await Container.get(ExecutionRepository).softDelete(this.executionId); return; } diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 5103e4dafe27e..2f730af402480 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -601,7 +601,7 @@ export class WorkflowRunner { (workflowDidSucceed && saveDataSuccessExecution === 'none') || (!workflowDidSucceed && saveDataErrorExecution === 'none') ) { - await Container.get(ExecutionRepository).softDeleteExecution(executionId); + await Container.get(ExecutionRepository).softDelete(executionId); } // eslint-disable-next-line id-denylist } catch (err) { diff --git a/packages/cli/src/databases/entities/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts index f71bf0ba57988..d73267ee52003 100644 --- a/packages/cli/src/databases/entities/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -9,6 +9,7 @@ import { OneToOne, PrimaryColumn, Relation, + DeleteDateColumn, } from 'typeorm'; import { datetimeColumnType } from './AbstractEntity'; import { idStringifier } from '../utils/transformers'; @@ -49,7 +50,7 @@ export class ExecutionEntity { @Column({ type: datetimeColumnType, nullable: true }) stoppedAt: Date; - @Column(datetimeColumnType) + @DeleteDateColumn({ type: datetimeColumnType, nullable: true }) deletedAt: Date; @Column({ nullable: true }) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 399999cf6bd4b..bc29057b828b6 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -367,14 +367,6 @@ export class ExecutionRepository extends Repository { }); } - async softDeleteExecution(executionId: string) { - await this.update({ id: executionId }, { deletedAt: Date.now() }); - } - - async softDeleteExecutions(executionIds: string[]) { - await this.update({ id: In(executionIds) }, { deletedAt: Date.now() }); - } - async deleteExecutionsByFilter( filters: IGetExecutionsQueryFilter | undefined, accessibleWorkflowIds: string[], @@ -418,12 +410,12 @@ export class ExecutionRepository extends Repository { do { // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error const batch = executionIds.splice(0, PRUNING_BATCH_SIZE); - await this.softDeleteExecutions(batch); + await this.softDelete(batch); } while (executionIds.length > 0); } private async pruneBySoftDeleting() { - Logger.verbose('Pruning execution data from database'); + Logger.verbose('Pruning (soft-deleting) execution data from database'); const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxCount = config.getEnv('executions.pruneDataMaxCount'); @@ -457,10 +449,10 @@ export class ExecutionRepository extends Repository { take: PRUNING_BATCH_SIZE, }) ).map(({ id }) => id); - await this.softDeleteExecutions(executionIds); + await this.softDelete(executionIds); if (executionIds.length === PRUNING_BATCH_SIZE) { - setTimeout(async () => this.pruneBySoftDeleting(), 1000); + setTimeout(async () => this.pruneBySoftDeleting(), TIME.SECOND); } } From f50108a0cbc7a6d3b8c3479acf57434568ac282e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 12:00:26 +0200 Subject: [PATCH 006/259] Improve naming --- .../src/databases/repositories/execution.repository.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index bc29057b828b6..518adc8dbe6a4 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -80,7 +80,7 @@ export class ExecutionRepository extends Repository { setInterval(async () => this.pruneBySoftDeleting(), TIME.HOUR); } - setInterval(async () => this.deleteSoftDeletedExecutions(), 15 * TIME.MINUTE); + setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); } async findMultipleExecutions( @@ -456,7 +456,10 @@ export class ExecutionRepository extends Repository { } } - private async deleteSoftDeletedExecutions() { + /** + * Permanently delete all soft-deleted executions and their binary data, in batches. + */ + private async hardDelete() { // Find ids of all executions that were deleted over an hour ago const date = new Date(); date.setHours(date.getHours() - 1); @@ -478,7 +481,7 @@ export class ExecutionRepository extends Repository { await this.delete({ id: In(executionIds) }); if (executionIds.length === PRUNING_BATCH_SIZE) { - setTimeout(async () => this.deleteSoftDeletedExecutions(), 1000); + setTimeout(async () => this.hardDelete(), 1000); } } } From 85ac53a4316011a98f0c3f036ae77b8d2ca90e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 12:01:49 +0200 Subject: [PATCH 007/259] Make batch size class field --- .../databases/repositories/execution.repository.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 518adc8dbe6a4..d1c0feef41345 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -27,8 +27,6 @@ import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; import { TIME } from '@/constants'; -const PRUNING_BATCH_SIZE = 100; - function parseFiltersToQueryBuilder( qb: SelectQueryBuilder, filters?: IGetExecutionsQueryFilter, @@ -70,6 +68,8 @@ function parseFiltersToQueryBuilder( @Service() export class ExecutionRepository extends Repository { + private deletionBatchSize = 100; + constructor( dataSource: DataSource, private readonly executionDataRepository: ExecutionDataRepository, @@ -409,7 +409,7 @@ export class ExecutionRepository extends Repository { const executionIds = executions.map(({ id }) => id); do { // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error - const batch = executionIds.splice(0, PRUNING_BATCH_SIZE); + const batch = executionIds.splice(0, this.deletionBatchSize); await this.softDelete(batch); } while (executionIds.length > 0); } @@ -446,12 +446,12 @@ export class ExecutionRepository extends Repository { await this.find({ select: ['id'], where: toPrune, - take: PRUNING_BATCH_SIZE, + take: this.deletionBatchSize, }) ).map(({ id }) => id); await this.softDelete(executionIds); - if (executionIds.length === PRUNING_BATCH_SIZE) { + if (executionIds.length === this.deletionBatchSize) { setTimeout(async () => this.pruneBySoftDeleting(), TIME.SECOND); } } @@ -470,7 +470,7 @@ export class ExecutionRepository extends Repository { where: { deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), }, - take: PRUNING_BATCH_SIZE, + take: this.deletionBatchSize, }) ).map(({ id }) => id); @@ -480,7 +480,7 @@ export class ExecutionRepository extends Repository { // Actually delete these executions await this.delete({ id: In(executionIds) }); - if (executionIds.length === PRUNING_BATCH_SIZE) { + if (executionIds.length === this.deletionBatchSize) { setTimeout(async () => this.hardDelete(), 1000); } } From 1d87e9ef9e06e128a747aaee67b313a3d0d2513c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 12:04:49 +0200 Subject: [PATCH 008/259] Cleanup --- packages/core/src/BinaryDataManager/FileSystem.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index 6ae70271211b9..07eb66c815461 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -19,7 +19,7 @@ export class BinaryDataFileSystem implements IBinaryDataManager { this.storagePath = config.localStoragePath; } - async init(): Promise { + async init() { await this.assertFolder(this.storagePath); await this.assertFolder(this.getBinaryDataMetaPath()); } @@ -77,7 +77,6 @@ export class BinaryDataFileSystem implements IBinaryDataManager { } async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { - // TODO: switch over to new folder structure, and delete folders instead const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); const deletedIds = []; From dd16e458fa0a32946a177635d421fda6aa4fb41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 13:04:06 +0200 Subject: [PATCH 009/259] Remove unused method --- packages/core/src/BinaryDataManager/FileSystem.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index 07eb66c815461..c5501add5a180 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -21,7 +21,6 @@ export class BinaryDataFileSystem implements IBinaryDataManager { async init() { await this.assertFolder(this.storagePath); - await this.assertFolder(this.getBinaryDataMetaPath()); } async getFileSize(identifier: string): Promise { @@ -107,10 +106,6 @@ export class BinaryDataFileSystem implements IBinaryDataManager { return [prefix, uuid()].join(''); } - private getBinaryDataMetaPath() { - return path.join(this.storagePath, 'meta'); - } - private async deleteFromLocalStorage(identifier: string) { return fs.rm(this.getBinaryPath(identifier)); } From 619bff6d95f32b256872cffcae119ec33bfefe3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 13:05:45 +0200 Subject: [PATCH 010/259] Cleanup --- packages/core/src/BinaryDataManager/FileSystem.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index c5501add5a180..5c4bdd32a9a2c 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -68,6 +68,7 @@ export class BinaryDataFileSystem implements IBinaryDataManager { async duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise { const newBinaryDataId = this.generateFileName(prefix); + await fs.copyFile( this.resolveStoragePath(binaryDataId), this.resolveStoragePath(newBinaryDataId), From 8cace1144e84aaf83eb044918807481f3eedb8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 13:21:38 +0200 Subject: [PATCH 011/259] Filter out soft-deleted executions --- .../src/databases/repositories/execution.repository.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index d1c0feef41345..4d76bae08aeb4 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { DataSource, In, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { DataSource, In, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, @@ -118,6 +118,10 @@ export class ExecutionRepository extends Repository { (queryParams.relations as string[]).push('executionData'); } + if (queryParams.where && !Array.isArray(queryParams.where)) { + queryParams.where.deletedAt = IsNull(); + } + const executions = await this.find(queryParams); if (options?.includeData && options?.unflattenData) { @@ -182,6 +186,7 @@ export class ExecutionRepository extends Repository { where: { id, ...options?.where, + deletedAt: IsNull(), }, }; if (options?.includeData) { @@ -340,7 +345,8 @@ export class ExecutionRepository extends Repository { .limit(limit) // eslint-disable-next-line @typescript-eslint/naming-convention .orderBy({ 'execution.id': 'DESC' }) - .andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }); + .andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }) + .andWhere('execution.deletedAt IS NULL'); if (excludedExecutionIds.length > 0) { query.andWhere('execution.id NOT IN (:...excludedExecutionIds)', { excludedExecutionIds }); From c4ffe6d0060fcd20f668715c8dbc354ac6dc3961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 5 Sep 2023 17:12:56 +0200 Subject: [PATCH 012/259] Add tests --- .../repositories/execution.repository.ts | 29 +++++-- .../integration/executions.controller.test.ts | 39 ++++++++++ .../integration/publicApi/executions.test.ts | 2 + .../cli/test/integration/shared/testDb.ts | 4 + packages/cli/test/integration/shared/types.ts | 3 +- .../integration/shared/utils/testServer.ts | 11 ++- .../repositories/execution.repository.test.ts | 78 +++++++++++++++++++ 7 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 packages/cli/test/integration/executions.controller.test.ts create mode 100644 packages/cli/test/unit/repositories/execution.repository.test.ts diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 4d76bae08aeb4..1ea6b707fa9e7 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -83,6 +83,14 @@ export class ExecutionRepository extends Repository { setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); } + setDeletionBatchSize(size: number) { + this.deletionBatchSize = size; + } + + getDeletionBatchSize() { + return this.deletionBatchSize; + } + async findMultipleExecutions( queryParams: FindManyOptions, options?: { @@ -420,19 +428,26 @@ export class ExecutionRepository extends Repository { } while (executionIds.length > 0); } - private async pruneBySoftDeleting() { - Logger.verbose('Pruning (soft-deleting) execution data from database'); - - const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h - const maxCount = config.getEnv('executions.pruneDataMaxCount'); + /** + * Return the timestamp up to which executions should be pruned. + */ + pruningLimit() { + const maxAge = config.getEnv('executions.pruneDataMaxAge'); // hours - // Find ids of all executions that were stopped longer that pruneDataMaxAge ago const date = new Date(); date.setHours(date.getHours() - maxAge); + return date; + } + + async pruneBySoftDeleting() { + Logger.verbose('Pruning (soft-deleting) execution data from database'); + + const maxCount = config.getEnv('executions.pruneDataMaxCount'); + const toPrune: Array> = [ // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 - { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, + { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(this.pruningLimit())) }, ]; if (maxCount > 0) { diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts new file mode 100644 index 0000000000000..fa6a70499cc3f --- /dev/null +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -0,0 +1,39 @@ +import * as testDb from './shared/testDb'; +import { setupTestServer } from './shared/utils'; +import type { User } from '@/databases/entities/User'; + +const testServer = setupTestServer({ endpointGroups: ['executions'] }); + +let owner: User; + +const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { + const workflow = await testDb.createWorkflow({}, belongingTo); + return testDb.createSuccessfulExecution(workflow); +}; + +beforeEach(async () => { + await testDb.truncate(['Execution', 'Workflow', 'SharedWorkflow']); + owner = await testDb.createOwner(); +}); + +describe('POST /executions/delete', () => { + test('should hard-delete an execution', async () => { + await saveExecution({ belongingTo: owner }); + + const response = await testServer.authAgentFor(owner).get('/executions').expect(200); + + expect(response.body.data.count).toBe(1); + + const [execution] = response.body.data.results; + + await testServer + .authAgentFor(owner) + .post('/executions/delete') + .send({ ids: [execution.id] }) + .expect(200); + + const executions = await testDb.getAllExecutions(); + + expect(executions).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 2f5216a8b9198..d42f8ca62ad35 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -168,6 +168,8 @@ describe('DELETE /executions/:id', () => { expect(stoppedAt).not.toBeNull(); expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); + + await authOwnerAgent.get(`/executions/${execution.id}`).expect(404); }); }); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 3186c3f64bbe2..27b91c9c8122b 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -528,6 +528,10 @@ export async function getAllWorkflows() { return Db.collections.Workflow.find(); } +export async function getAllExecutions() { + return Db.collections.Execution.find(); +} + // ---------------------------------- // workflow sharing // ---------------------------------- diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 3fd972a6bd1cd..f552c7e1f7bc9 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -28,7 +28,8 @@ export type EndpointGroup = | 'tags' | 'externalSecrets' | 'mfa' - | 'metrics'; + | 'metrics' + | 'executions'; export interface SetupProps { applyAuth?: boolean; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index b89e6ab7bddd0..b33c3afb04387 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -64,6 +64,7 @@ import { import { JwtService } from '@/services/jwt.service'; import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; +import { executionsController } from '@/executions/executions.controller'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -93,7 +94,14 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { const routerEndpoints: EndpointGroup[] = []; const functionEndpoints: EndpointGroup[] = []; - const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license', 'variables']; + const ROUTER_GROUP = [ + 'credentials', + 'workflows', + 'publicApi', + 'license', + 'variables', + 'executions', + ]; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), @@ -176,6 +184,7 @@ export const setupTestServer = ({ workflows: { controller: workflowsController, path: 'workflows' }, license: { controller: licenseController, path: 'license' }, variables: { controller: variablesController, path: 'variables' }, + executions: { controller: executionsController, path: 'executions' }, }; if (enablePublicAPI) { diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts new file mode 100644 index 0000000000000..d00c0610ee68e --- /dev/null +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -0,0 +1,78 @@ +import { Container } from 'typedi'; +import { DataSource, EntityManager } from 'typeorm'; +import { mock } from 'jest-mock-extended'; +import { mockInstance } from '../../integration/shared/utils/'; +import { ExecutionRepository } from '@/databases/repositories'; +import config from '@/config'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; +import { TIME } from '@/constants'; + +const { objectContaining } = expect; + +describe('ExecutionRepository', () => { + const entityManager = mockInstance(EntityManager); + const dataSource = mockInstance(DataSource, { manager: entityManager }); + dataSource.getMetadata.mockReturnValue(mock()); + Object.assign(entityManager, { connection: dataSource }); + + const executionRepository = Container.get(ExecutionRepository); + + beforeAll(() => { + Container.set(ExecutionRepository, executionRepository); + LoggerProxy.init(getLogger()); + }); + + beforeEach(() => { + config.load(config.default); + + jest.clearAllMocks(); + }); + + describe('pruneBySoftDeleting()', () => { + test('should soft-delete executions based on batch size', async () => { + config.set('executions.pruneDataMaxCount', 0); // disable path + + executionRepository.setDeletionBatchSize(5); + + const find = jest.spyOn(ExecutionRepository.prototype, 'find'); + entityManager.find.mockResolvedValueOnce([]); + + await executionRepository.pruneBySoftDeleting(); + + expect(find.mock.calls[0][0]).toEqual( + objectContaining({ take: executionRepository.getDeletionBatchSize() }), + ); + }); + + test('should limit pruning based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => { + const maxCount = 1; + + config.set('executions.pruneDataMaxCount', maxCount); + + const find = jest.spyOn(ExecutionRepository.prototype, 'find'); + entityManager.find.mockResolvedValue([]); + + await executionRepository.pruneBySoftDeleting(); + + expect(find.mock.calls[0][0]).toEqual(objectContaining({ skip: maxCount })); + }); + }); + + describe('pruningLimit()', () => { + test('should limit pruning based on EXECUTIONS_DATA_MAX_AGE', async () => { + config.set('executions.pruneDataMaxCount', 0); // disable path + + const maxAge = 5; // hours + + config.set('executions.pruneDataMaxAge', maxAge); + + const now = Date.now(); + const limit = executionRepository.pruningLimit(); + + const difference = now - limit.valueOf(); + + expect(difference / TIME.HOUR).toBe(maxAge); + }); + }); +}); From 738eb1b6eb940fa2ad83d65d5608bb9de88b8cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 6 Sep 2023 11:53:05 +0200 Subject: [PATCH 013/259] Improve test --- .../repositories/execution.repository.ts | 29 +++++-------------- .../repositories/execution.repository.test.ts | 24 +++++++++------ 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 1ea6b707fa9e7..75773a2e97fd6 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -68,7 +68,7 @@ function parseFiltersToQueryBuilder( @Service() export class ExecutionRepository extends Repository { - private deletionBatchSize = 100; + deletionBatchSize = 100; constructor( dataSource: DataSource, @@ -83,14 +83,6 @@ export class ExecutionRepository extends Repository { setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); } - setDeletionBatchSize(size: number) { - this.deletionBatchSize = size; - } - - getDeletionBatchSize() { - return this.deletionBatchSize; - } - async findMultipleExecutions( queryParams: FindManyOptions, options?: { @@ -428,26 +420,19 @@ export class ExecutionRepository extends Repository { } while (executionIds.length > 0); } - /** - * Return the timestamp up to which executions should be pruned. - */ - pruningLimit() { - const maxAge = config.getEnv('executions.pruneDataMaxAge'); // hours - - const date = new Date(); - date.setHours(date.getHours() - maxAge); - - return date; - } - async pruneBySoftDeleting() { Logger.verbose('Pruning (soft-deleting) execution data from database'); + const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxCount = config.getEnv('executions.pruneDataMaxCount'); + // Find ids of all executions that were stopped longer that pruneDataMaxAge ago + const date = new Date(); + date.setHours(date.getHours() - maxAge); + const toPrune: Array> = [ // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 - { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(this.pruningLimit())) }, + { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, ]; if (maxCount > 0) { diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts index d00c0610ee68e..a1307de31e80b 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -7,6 +7,9 @@ import config from '@/config'; import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import { TIME } from '@/constants'; +import { DateUtils } from 'typeorm/util/DateUtils'; + +jest.mock('typeorm/util/DateUtils'); const { objectContaining } = expect; @@ -33,7 +36,7 @@ describe('ExecutionRepository', () => { test('should soft-delete executions based on batch size', async () => { config.set('executions.pruneDataMaxCount', 0); // disable path - executionRepository.setDeletionBatchSize(5); + executionRepository.deletionBatchSize = 5; const find = jest.spyOn(ExecutionRepository.prototype, 'find'); entityManager.find.mockResolvedValueOnce([]); @@ -41,7 +44,7 @@ describe('ExecutionRepository', () => { await executionRepository.pruneBySoftDeleting(); expect(find.mock.calls[0][0]).toEqual( - objectContaining({ take: executionRepository.getDeletionBatchSize() }), + objectContaining({ take: executionRepository.deletionBatchSize }), ); }); @@ -57,22 +60,25 @@ describe('ExecutionRepository', () => { expect(find.mock.calls[0][0]).toEqual(objectContaining({ skip: maxCount })); }); - }); - describe('pruningLimit()', () => { test('should limit pruning based on EXECUTIONS_DATA_MAX_AGE', async () => { + const maxAge = 5; // hours + config.set('executions.pruneDataMaxCount', 0); // disable path + config.set('executions.pruneDataMaxAge', 5); - const maxAge = 5; // hours + entityManager.find.mockResolvedValue([]); - config.set('executions.pruneDataMaxAge', maxAge); + const dateFormat = jest.spyOn(DateUtils, 'mixedDateToUtcDatetimeString'); const now = Date.now(); - const limit = executionRepository.pruningLimit(); - const difference = now - limit.valueOf(); + await executionRepository.pruneBySoftDeleting(); + + const argDate = dateFormat.mock.calls[0][0]; + const difference = now - argDate.valueOf(); - expect(difference / TIME.HOUR).toBe(maxAge); + expect(Math.round(difference / TIME.HOUR)).toBe(maxAge); }); }); }); From 259fe75b8608754e3c41337dca022dbddbb1dd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 15:20:02 +0200 Subject: [PATCH 014/259] Soft-delete in single pass --- .../repositories/execution.repository.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 75773a2e97fd6..0f54e54c8bf34 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,5 +1,13 @@ import { Service } from 'typedi'; -import { DataSource, In, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { + Brackets, + DataSource, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, @@ -77,7 +85,7 @@ export class ExecutionRepository extends Repository { super(ExecutionEntity, dataSource.manager); if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneBySoftDeleting(), TIME.HOUR); + setInterval(async () => this.pruneBySoftDeleting(), 10 * TIME.SECOND); } setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); @@ -430,7 +438,7 @@ export class ExecutionRepository extends Repository { const date = new Date(); date.setHours(date.getHours() - maxAge); - const toPrune: Array> = [ + const toPrune: Array> = [ // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, ]; @@ -448,18 +456,19 @@ export class ExecutionRepository extends Repository { } } - const executionIds = ( - await this.find({ - select: ['id'], - where: toPrune, - take: this.deletionBatchSize, - }) - ).map(({ id }) => id); - await this.softDelete(executionIds); - - if (executionIds.length === this.deletionBatchSize) { - setTimeout(async () => this.pruneBySoftDeleting(), TIME.SECOND); - } + const [timeBasedWhere, countBasedWhere] = toPrune; + + await this.createQueryBuilder() + .update(ExecutionEntity) + .set({ deletedAt: new Date() }) + .where( + new Brackets((qb) => + countBasedWhere + ? qb.where(timeBasedWhere).orWhere(countBasedWhere) + : qb.where(timeBasedWhere), + ), + ) + .execute(); } /** From f3f5b271b8d655f611db2352186d8fa20858a341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 15:23:58 +0200 Subject: [PATCH 015/259] Restore value --- packages/cli/src/databases/repositories/execution.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 0f54e54c8bf34..1670dab32360d 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -85,7 +85,7 @@ export class ExecutionRepository extends Repository { super(ExecutionEntity, dataSource.manager); if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneBySoftDeleting(), 10 * TIME.SECOND); + setInterval(async () => this.pruneBySoftDeleting(), 10 * TIME.HOUR); } setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); From 65d17cba684ac36d4a1882424b2f6615fe4923af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 15:24:44 +0200 Subject: [PATCH 016/259] Restore from debug value --- packages/cli/src/databases/repositories/execution.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 1670dab32360d..6cd7561026b3a 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -85,7 +85,7 @@ export class ExecutionRepository extends Repository { super(ExecutionEntity, dataSource.manager); if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneBySoftDeleting(), 10 * TIME.HOUR); + setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); } setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); From 96ae7b464d6f50ecf0255dca2e982a49685b6117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 15:30:21 +0200 Subject: [PATCH 017/259] Add clarifying comments --- .../cli/test/unit/repositories/execution.repository.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts index a1307de31e80b..d6a77e678e898 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -34,7 +34,7 @@ describe('ExecutionRepository', () => { describe('pruneBySoftDeleting()', () => { test('should soft-delete executions based on batch size', async () => { - config.set('executions.pruneDataMaxCount', 0); // disable path + config.set('executions.pruneDataMaxCount', 0); // disable prune-by-count path executionRepository.deletionBatchSize = 5; @@ -64,7 +64,7 @@ describe('ExecutionRepository', () => { test('should limit pruning based on EXECUTIONS_DATA_MAX_AGE', async () => { const maxAge = 5; // hours - config.set('executions.pruneDataMaxCount', 0); // disable path + config.set('executions.pruneDataMaxCount', 0); // disable prune-by-count path config.set('executions.pruneDataMaxAge', 5); entityManager.find.mockResolvedValue([]); From 21040b5ac86cd49e0bf14e0c68b1e444a7f64749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 16:40:44 +0200 Subject: [PATCH 018/259] Update tests --- .../repositories/execution.repository.test.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts index d6a77e678e898..ac15979504a6f 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -13,6 +13,14 @@ jest.mock('typeorm/util/DateUtils'); const { objectContaining } = expect; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const qb: any = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockReturnThis(), +}; + describe('ExecutionRepository', () => { const entityManager = mockInstance(EntityManager); const dataSource = mockInstance(DataSource, { manager: entityManager }); @@ -33,21 +41,6 @@ describe('ExecutionRepository', () => { }); describe('pruneBySoftDeleting()', () => { - test('should soft-delete executions based on batch size', async () => { - config.set('executions.pruneDataMaxCount', 0); // disable prune-by-count path - - executionRepository.deletionBatchSize = 5; - - const find = jest.spyOn(ExecutionRepository.prototype, 'find'); - entityManager.find.mockResolvedValueOnce([]); - - await executionRepository.pruneBySoftDeleting(); - - expect(find.mock.calls[0][0]).toEqual( - objectContaining({ take: executionRepository.deletionBatchSize }), - ); - }); - test('should limit pruning based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => { const maxCount = 1; @@ -56,6 +49,8 @@ describe('ExecutionRepository', () => { const find = jest.spyOn(ExecutionRepository.prototype, 'find'); entityManager.find.mockResolvedValue([]); + jest.spyOn(ExecutionRepository.prototype, 'createQueryBuilder').mockReturnValueOnce(qb); + await executionRepository.pruneBySoftDeleting(); expect(find.mock.calls[0][0]).toEqual(objectContaining({ skip: maxCount })); @@ -69,6 +64,8 @@ describe('ExecutionRepository', () => { entityManager.find.mockResolvedValue([]); + jest.spyOn(ExecutionRepository.prototype, 'createQueryBuilder').mockReturnValueOnce(qb); + const dateFormat = jest.spyOn(DateUtils, 'mixedDateToUtcDatetimeString'); const now = Date.now(); From aa4d63344c700acc20879bd772fced759969da02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:51:58 +0200 Subject: [PATCH 019/259] Add `typedi` to core --- pnpm-lock.yaml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22df3e77bb104..8bda6a0b0a436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,9 @@ importers: qs: specifier: ^6.10.1 version: 6.11.0 + typedi: + specifier: ^0.10.0 + version: 0.10.0(patch_hash=62r6bc2crgimafeyruodhqlgo4) uuid: specifier: ^8.3.2 version: 8.3.2 @@ -6759,7 +6762,7 @@ packages: ts-dedent: 2.2.0 type-fest: 3.13.1 vue: 3.3.4 - vue-component-type-helpers: 1.8.8 + vue-component-type-helpers: 1.8.11 transitivePeerDependencies: - encoding - supports-color @@ -9069,7 +9072,7 @@ packages: /axios@0.21.4: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2(debug@4.3.4) + follow-redirects: 1.15.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false @@ -9098,11 +9101,12 @@ packages: form-data: 4.0.0 transitivePeerDependencies: - debug + dev: true /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: - follow-redirects: 1.15.2(debug@4.3.4) + follow-redirects: 1.15.2(debug@3.2.7) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -12674,6 +12678,7 @@ packages: optional: true dependencies: debug: 4.3.4(supports-color@8.1.1) + dev: true /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -18003,7 +18008,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2(debug@4.3.4) + axios: 0.27.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false @@ -21734,12 +21739,12 @@ packages: vue: 3.3.4 dev: false - /vue-component-type-helpers@1.8.4: - resolution: {integrity: sha512-6bnLkn8O0JJyiFSIF0EfCogzeqNXpnjJ0vW/SZzNHfe6sPx30lTtTXlE5TFs2qhJlAtDFybStVNpL73cPe3OMQ==} + /vue-component-type-helpers@1.8.11: + resolution: {integrity: sha512-CWItFzuEWjkSXDeMGwQEc5cFH4FaueyPQHfi1mBDe+wA2JABqNjFxFUtmZJ9WHkb0HpEwqgBg1umiXrWYXkXHw==} dev: true - /vue-component-type-helpers@1.8.8: - resolution: {integrity: sha512-Ohv9HQY92nSbpReC6WhY0X4YkOszHzwUHaaN/lev5tHQLM1AEw+LrLeB2bIGIyKGDU7ZVrncXcv/oBny4rjbYg==} + /vue-component-type-helpers@1.8.4: + resolution: {integrity: sha512-6bnLkn8O0JJyiFSIF0EfCogzeqNXpnjJ0vW/SZzNHfe6sPx30lTtTXlE5TFs2qhJlAtDFybStVNpL73cPe3OMQ==} dev: true /vue-demi@0.14.5(vue@3.3.4): From e54becb0f21f7bfd6440da05102a049a03209db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:52:10 +0200 Subject: [PATCH 020/259] Update package.json --- packages/core/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index a0886df9010b3..d5e4ca1997dfa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,8 +44,8 @@ "@types/uuid": "^8.3.2" }, "dependencies": { - "axios": "^0.21.1", "@n8n/client-oauth2": "workspace:*", + "axios": "^0.21.1", "concat-stream": "^2.0.0", "cron": "~1.7.2", "crypto-js": "~4.1.1", @@ -60,6 +60,7 @@ "p-cancelable": "^2.0.0", "pretty-bytes": "^5.6.0", "qs": "^6.10.1", + "typedi": "^0.10.0", "uuid": "^8.3.2" } } From 80e7258570ef5e0c11840393052738bbeb648212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:52:52 +0200 Subject: [PATCH 021/259] Turn `BinaryDataManager` into service --- packages/core/src/BinaryDataManager/index.ts | 34 ++++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index eca32f184da26..3c64aed2977fb 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -6,47 +6,31 @@ import { BINARY_ENCODING } from 'n8n-workflow'; import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import { BinaryDataFileSystem } from './FileSystem'; import { binaryToBuffer } from './utils'; +import { Service } from 'typedi'; +@Service() export class BinaryDataManager { - static instance: BinaryDataManager | undefined; - private managers: { [key: string]: IBinaryDataManager; - }; + } = {}; - private binaryDataMode: string; + private binaryDataMode = ''; - private availableModes: string[]; + private availableModes: string[] = []; - constructor(config: IBinaryDataConfig) { + async init(config: IBinaryDataConfig, mainManager = false) { this.binaryDataMode = config.mode; this.availableModes = config.availableModes.split(','); this.managers = {}; - } - - static async init(config: IBinaryDataConfig, mainManager = false): Promise { - if (BinaryDataManager.instance) { - throw new Error('Binary Data Manager already initialized'); - } - - BinaryDataManager.instance = new BinaryDataManager(config); - if (BinaryDataManager.instance.availableModes.includes('filesystem')) { - BinaryDataManager.instance.managers.filesystem = new BinaryDataFileSystem(config); - await BinaryDataManager.instance.managers.filesystem.init(mainManager); + if (this.availableModes.includes('filesystem')) { + this.managers.filesystem = new BinaryDataFileSystem(config); + await this.managers.filesystem.init(mainManager); } return undefined; } - static getInstance(): BinaryDataManager { - if (!BinaryDataManager.instance) { - throw new Error('Binary Data Manager not initialized'); - } - - return BinaryDataManager.instance; - } - async copyBinaryFile( binaryData: IBinaryData, filePath: string, From 3a8960fcdf1489153fabbfe333f4869e3da62ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:53:23 +0200 Subject: [PATCH 022/259] Add `object` to binary data schema options --- packages/cli/src/config/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 2782fa0f02245..cf9ea86c4b740 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -885,12 +885,12 @@ export const schema = { binaryDataManager: { availableModes: { format: String, - default: 'filesystem', + default: 'filesystem,object', env: 'N8N_AVAILABLE_BINARY_DATA_MODES', doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: ['default', 'filesystem'] as const, + format: ['default', 'filesystem', 'object'] as const, default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', From 64b1a960e6bd9eb7611348428d178a5e6182d8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:54:55 +0200 Subject: [PATCH 023/259] Use service throughout core and cli --- packages/cli/src/Server.ts | 8 +++++--- packages/cli/src/WebhookHelpers.ts | 4 ++-- packages/cli/src/WorkflowRunnerProcess.ts | 2 +- packages/cli/src/commands/BaseCommand.ts | 2 +- .../repositories/execution.repository.ts | 4 ++-- packages/core/src/NodeExecuteFunctions.ts | 15 ++++++++------- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 08d5df488e00a..dcfafbe3dec58 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -201,6 +201,8 @@ export class Server extends AbstractServer { push: Push; + binaryDataManager: BinaryDataManager; + constructor() { super('main'); @@ -356,6 +358,7 @@ export class Server extends AbstractServer { this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); this.push = Container.get(Push); + this.binaryDataManager = Container.get(BinaryDataManager); await super.start(); LoggerProxy.debug(`Server ID: ${this.uniqueInstanceId}`); @@ -1421,13 +1424,12 @@ export class Server extends AbstractServer { async (req: BinaryDataRequest, res: express.Response): Promise => { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; - const binaryDataManager = BinaryDataManager.getInstance(); try { - const binaryPath = binaryDataManager.getBinaryPath(identifier); + const binaryPath = this.binaryDataManager.getBinaryPath(identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await binaryDataManager.getBinaryMetadata(identifier); + const metadata = await this.binaryDataManager.getBinaryMetadata(identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index c5c05f4215726..aa84e798ce2bb 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = BinaryDataManager.getInstance().getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataManager).getBinaryStream(binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -732,7 +732,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = BinaryDataManager.getInstance().getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataManager).getBinaryStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index d1b009a77ecb8..642a7cb98f727 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -123,7 +123,7 @@ class WorkflowRunnerProcess { await Container.get(InternalHooks).init(instanceId); const binaryDataConfig = config.getEnv('binaryDataManager'); - await BinaryDataManager.init(binaryDataConfig); + await Container.get(BinaryDataManager).init(binaryDataConfig); const license = Container.get(License); await license.init(instanceId); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 78be7cc23f04c..012d581be2f25 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -105,7 +105,7 @@ export abstract class BaseCommand extends Command { protected async initBinaryManager() { const binaryDataConfig = config.getEnv('binaryDataManager'); - await BinaryDataManager.init(binaryDataConfig, true); + await Container.get(BinaryDataManager).init(binaryDataConfig, true); } protected async initExternalHooks() { diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 6cd7561026b3a..3d08483a4507d 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -81,6 +81,7 @@ export class ExecutionRepository extends Repository { constructor( dataSource: DataSource, private readonly executionDataRepository: ExecutionDataRepository, + private readonly binaryDataManager: BinaryDataManager, ) { super(ExecutionEntity, dataSource.manager); @@ -489,8 +490,7 @@ export class ExecutionRepository extends Repository { }) ).map(({ id }) => id); - const binaryDataManager = BinaryDataManager.getInstance(); - await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); + await this.binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); // Actually delete these executions await this.delete({ id: In(executionIds) }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b6757384bb51c..daee20685ed7a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -138,6 +138,7 @@ import { } from './WorkflowExecutionMetadata'; import { getSecretsProxy } from './Secrets'; import { getUserN8nFolderPath } from './UserSettings'; +import Container from 'typedi'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -864,21 +865,21 @@ async function httpRequest( } export function getBinaryPath(binaryDataId: string): string { - return BinaryDataManager.getInstance().getBinaryPath(binaryDataId); + return Container.get(BinaryDataManager).getBinaryPath(binaryDataId); } /** * Returns binary file metadata */ export async function getBinaryMetadata(binaryDataId: string): Promise { - return BinaryDataManager.getInstance().getBinaryMetadata(binaryDataId); + return Container.get(BinaryDataManager).getBinaryMetadata(binaryDataId); } /** * Returns binary file stream for piping */ export function getBinaryStream(binaryDataId: string, chunkSize?: number): Readable { - return BinaryDataManager.getInstance().getBinaryStream(binaryDataId, chunkSize); + return Container.get(BinaryDataManager).getBinaryStream(binaryDataId, chunkSize); } export function assertBinaryData( @@ -915,7 +916,7 @@ export async function getBinaryDataBuffer( inputIndex: number, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return BinaryDataManager.getInstance().getBinaryDataBuffer(binaryData); + return Container.get(BinaryDataManager).getBinaryDataBuffer(binaryData); } /** @@ -931,7 +932,7 @@ export async function setBinaryDataBuffer( binaryData: Buffer | Readable, executionId: string, ): Promise { - return BinaryDataManager.getInstance().storeBinaryData(data, binaryData, executionId); + return Container.get(BinaryDataManager).storeBinaryData(data, binaryData, executionId); } export async function copyBinaryFile( @@ -984,7 +985,7 @@ export async function copyBinaryFile( returnData.fileName = path.parse(filePath).base; } - return BinaryDataManager.getInstance().copyBinaryFile(returnData, filePath, executionId); + return Container.get(BinaryDataManager).copyBinaryFile(returnData, filePath, executionId); } /** @@ -2563,7 +2564,7 @@ export function getExecuteFunctions( parentWorkflowSettings: workflow.settings, }) .then(async (result) => - BinaryDataManager.getInstance().duplicateBinaryData( + Container.get(BinaryDataManager).duplicateBinaryData( result, additionalData.executionId!, ), From f974b55461d886782841baaf1564944e34fd6235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 17:55:00 +0200 Subject: [PATCH 024/259] Update tests --- .../core/test/NodeExecuteFunctions.test.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index ec2a32e7f0e54..27525a43311c1 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -18,23 +18,23 @@ import { proxyRequestToAxios, } from '@/NodeExecuteFunctions'; import { initLogger } from './helpers/utils'; +import Container from 'typedi'; +import type { IBinaryDataConfig } from '@/Interfaces'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); describe('NodeExecuteFunctions', () => { describe('test binary data helper methods', () => { - // Reset BinaryDataManager for each run. This is a dirty operation, as individual managers are not cleaned. - beforeEach(() => { - BinaryDataManager.instance = undefined; - }); - test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'default' mode", async () => { // Setup a 'default' binary data manager instance - await BinaryDataManager.init({ + const config: IBinaryDataConfig = { mode: 'default', availableModes: 'default', localStoragePath: temporaryDir, - }); + }; + Container.set(BinaryDataManager, new BinaryDataManager()); + + await Container.get(BinaryDataManager).init(config); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); @@ -78,12 +78,15 @@ describe('NodeExecuteFunctions', () => { }); test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'filesystem' mode", async () => { - // Setup a 'filesystem' binary data manager instance - await BinaryDataManager.init({ + const config: IBinaryDataConfig = { mode: 'filesystem', availableModes: 'filesystem', localStoragePath: temporaryDir, - }); + }; + Container.set(BinaryDataManager, new BinaryDataManager()); + + // Setup a 'filesystem' binary data manager instance + await Container.get(BinaryDataManager).init(config); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); From 36d02439df1745c67ce0f76b85eb3c851a8ba198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:00:32 +0200 Subject: [PATCH 025/259] Reduce diff --- .../core/test/NodeExecuteFunctions.test.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 27525a43311c1..9fb7890f7081f 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -19,7 +19,6 @@ import { } from '@/NodeExecuteFunctions'; import { initLogger } from './helpers/utils'; import Container from 'typedi'; -import type { IBinaryDataConfig } from '@/Interfaces'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); @@ -27,14 +26,13 @@ describe('NodeExecuteFunctions', () => { describe('test binary data helper methods', () => { test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'default' mode", async () => { // Setup a 'default' binary data manager instance - const config: IBinaryDataConfig = { + Container.set(BinaryDataManager, new BinaryDataManager()); + + await Container.get(BinaryDataManager).init({ mode: 'default', availableModes: 'default', localStoragePath: temporaryDir, - }; - Container.set(BinaryDataManager, new BinaryDataManager()); - - await Container.get(BinaryDataManager).init(config); + }); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); @@ -78,15 +76,14 @@ describe('NodeExecuteFunctions', () => { }); test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'filesystem' mode", async () => { - const config: IBinaryDataConfig = { - mode: 'filesystem', - availableModes: 'filesystem', - localStoragePath: temporaryDir, - }; Container.set(BinaryDataManager, new BinaryDataManager()); // Setup a 'filesystem' binary data manager instance - await Container.get(BinaryDataManager).init(config); + await Container.get(BinaryDataManager).init({ + mode: 'filesystem', + availableModes: 'filesystem', + localStoragePath: temporaryDir, + }); // Set our binary data buffer const inputData: Buffer = Buffer.from('This is some binary data', 'utf8'); From 5e209e9d147bf9b34d233848270df648e3778ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:02:15 +0200 Subject: [PATCH 026/259] Update default --- packages/core/src/BinaryDataManager/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 3c64aed2977fb..9e0d9dab2b629 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -14,7 +14,7 @@ export class BinaryDataManager { [key: string]: IBinaryDataManager; } = {}; - private binaryDataMode = ''; + private binaryDataMode = 'default'; private availableModes: string[] = []; From 0c3a6aab28deeabb68c34584f532ab69dc52043c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:04:06 +0200 Subject: [PATCH 027/259] Add docline --- packages/core/src/BinaryDataManager/index.ts | 28 ++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 9e0d9dab2b629..44e549b3d115a 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -14,12 +14,18 @@ export class BinaryDataManager { [key: string]: IBinaryDataManager; } = {}; - private binaryDataMode = 'default'; + /** + * Mode for storing binary data: + * - `default` (in memory) + * - `filesystem` (on disk) + * - `object` (S3) + */ + private mode = 'default'; private availableModes: string[] = []; async init(config: IBinaryDataConfig, mainManager = false) { - this.binaryDataMode = config.mode; + this.mode = config.mode; this.availableModes = config.availableModes.split(','); this.managers = {}; @@ -37,14 +43,14 @@ export class BinaryDataManager { executionId: string, ): Promise { // If a manager handles this binary, copy over the binary file and return its reference id. - const manager = this.managers[this.binaryDataMode]; + const manager = this.managers[this.mode]; if (manager) { const identifier = await manager.copyBinaryFile(filePath, executionId); // Add data manager reference id. binaryData.id = this.generateBinaryId(identifier); // Prevent preserving data in memory if handled by a data manager. - binaryData.data = this.binaryDataMode; + binaryData.data = this.mode; const fileSize = await manager.getFileSize(identifier); binaryData.fileSize = prettyBytes(fileSize); @@ -69,7 +75,7 @@ export class BinaryDataManager { executionId: string, ): Promise { // If a manager handles this binary, return the binary data with its reference id. - const manager = this.managers[this.binaryDataMode]; + const manager = this.managers[this.mode]; if (manager) { const identifier = await manager.storeBinaryData(input, executionId); @@ -77,7 +83,7 @@ export class BinaryDataManager { binaryData.id = this.generateBinaryId(identifier); // Prevent preserving data in memory if handled by a data manager. - binaryData.data = this.binaryDataMode; + binaryData.data = this.mode; const fileSize = await manager.getFileSize(identifier); binaryData.fileSize = prettyBytes(fileSize); @@ -141,8 +147,8 @@ export class BinaryDataManager { } async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { - if (this.managers[this.binaryDataMode]) { - await this.managers[this.binaryDataMode].deleteBinaryDataByExecutionIds(executionIds); + if (this.managers[this.mode]) { + await this.managers[this.mode].deleteBinaryDataByExecutionIds(executionIds); } } @@ -150,7 +156,7 @@ export class BinaryDataManager { inputData: Array, executionId: string, ): Promise { - if (inputData && this.managers[this.binaryDataMode]) { + if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { if (executionDataArray) { @@ -176,7 +182,7 @@ export class BinaryDataManager { } private generateBinaryId(filename: string) { - return `${this.binaryDataMode}:${filename}`; + return `${this.mode}:${filename}`; } private splitBinaryModeFileId(fileId: string): { mode: string; id: string } { @@ -188,7 +194,7 @@ export class BinaryDataManager { executionData: INodeExecutionData, executionId: string, ): Promise { - const binaryManager = this.managers[this.binaryDataMode]; + const binaryManager = this.managers[this.mode]; if (executionData.binary) { const binaryDataKeys = Object.keys(executionData.binary); From 3d4beba381ca0295025c6e9e595fb1e6592218c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:05:16 +0200 Subject: [PATCH 028/259] Remove redundant output types --- packages/core/src/BinaryDataManager/index.ts | 27 ++++++-------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 44e549b3d115a..43bd9f1537d85 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -37,11 +37,7 @@ export class BinaryDataManager { return undefined; } - async copyBinaryFile( - binaryData: IBinaryData, - filePath: string, - executionId: string, - ): Promise { + async copyBinaryFile(binaryData: IBinaryData, filePath: string, executionId: string) { // If a manager handles this binary, copy over the binary file and return its reference id. const manager = this.managers[this.mode]; if (manager) { @@ -69,11 +65,7 @@ export class BinaryDataManager { return binaryData; } - async storeBinaryData( - binaryData: IBinaryData, - input: Buffer | Readable, - executionId: string, - ): Promise { + async storeBinaryData(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { // If a manager handles this binary, return the binary data with its reference id. const manager = this.managers[this.mode]; if (manager) { @@ -102,7 +94,7 @@ export class BinaryDataManager { return binaryData; } - getBinaryStream(identifier: string, chunkSize?: number): Readable { + getBinaryStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.managers[mode]) { return this.managers[mode].getBinaryStream(id, chunkSize); @@ -128,7 +120,7 @@ export class BinaryDataManager { throw new Error('Storage mode used to store binary data not available'); } - getBinaryPath(identifier: string): string { + getBinaryPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.managers[mode]) { return this.managers[mode].getBinaryPath(id); @@ -137,7 +129,7 @@ export class BinaryDataManager { throw new Error('Storage mode used to store binary data not available'); } - async getBinaryMetadata(identifier: string): Promise { + async getBinaryMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.managers[mode]) { return this.managers[mode].getBinaryMetadata(id); @@ -146,16 +138,13 @@ export class BinaryDataManager { throw new Error('Storage mode used to store binary data not available'); } - async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { + async deleteBinaryDataByExecutionIds(executionIds: string[]) { if (this.managers[this.mode]) { await this.managers[this.mode].deleteBinaryDataByExecutionIds(executionIds); } } - async duplicateBinaryData( - inputData: Array, - executionId: string, - ): Promise { + async duplicateBinaryData(inputData: Array, executionId: string) { if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { @@ -193,7 +182,7 @@ export class BinaryDataManager { private async duplicateBinaryDataInExecData( executionData: INodeExecutionData, executionId: string, - ): Promise { + ) { const binaryManager = this.managers[this.mode]; if (executionData.binary) { From 1bf7a3de9aa8bc76e226ca08adf352b085ce801e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:05:32 +0200 Subject: [PATCH 029/259] Remove unused type --- packages/core/src/BinaryDataManager/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 43bd9f1537d85..24d85058b420d 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -1,5 +1,5 @@ import { readFile, stat } from 'fs/promises'; -import type { BinaryMetadata, IBinaryData, INodeExecutionData } from 'n8n-workflow'; +import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; import { BINARY_ENCODING } from 'n8n-workflow'; From 115209253dc41367748924a3ff27290eeba4842a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:08:31 +0200 Subject: [PATCH 030/259] Rename to submanagers to client --- .../core/src/BinaryDataManager/FileSystem.ts | 4 +- packages/core/src/BinaryDataManager/index.ts | 72 +++++++++---------- packages/core/src/Interfaces.ts | 2 +- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/FileSystem.ts index 5c4bdd32a9a2c..d8580c3c7e631 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/FileSystem.ts @@ -6,13 +6,13 @@ import type { Readable } from 'stream'; import type { BinaryMetadata } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; -import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; +import type { IBinaryDataConfig, BinaryDataClient } from '../Interfaces'; import { FileNotFoundError } from '../errors'; const executionExtractionRegexp = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; -export class BinaryDataFileSystem implements IBinaryDataManager { +export class BinaryDataFileSystem implements BinaryDataClient { private storagePath: string; constructor(config: IBinaryDataConfig) { diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 24d85058b420d..96b0df694477c 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -3,15 +3,15 @@ import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; import { BINARY_ENCODING } from 'n8n-workflow'; -import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; +import type { IBinaryDataConfig, BinaryDataClient } from '../Interfaces'; import { BinaryDataFileSystem } from './FileSystem'; import { binaryToBuffer } from './utils'; import { Service } from 'typedi'; @Service() export class BinaryDataManager { - private managers: { - [key: string]: IBinaryDataManager; + private clients: { + [key: string]: BinaryDataClient; } = {}; /** @@ -24,34 +24,34 @@ export class BinaryDataManager { private availableModes: string[] = []; - async init(config: IBinaryDataConfig, mainManager = false) { + async init(config: IBinaryDataConfig, mainClient = false) { this.mode = config.mode; this.availableModes = config.availableModes.split(','); - this.managers = {}; + this.clients = {}; if (this.availableModes.includes('filesystem')) { - this.managers.filesystem = new BinaryDataFileSystem(config); - await this.managers.filesystem.init(mainManager); + this.clients.filesystem = new BinaryDataFileSystem(config); + await this.clients.filesystem.init(mainClient); } return undefined; } async copyBinaryFile(binaryData: IBinaryData, filePath: string, executionId: string) { - // If a manager handles this binary, copy over the binary file and return its reference id. - const manager = this.managers[this.mode]; - if (manager) { - const identifier = await manager.copyBinaryFile(filePath, executionId); - // Add data manager reference id. + // If a client handles this binary, copy over the binary file and return its reference id. + const client = this.clients[this.mode]; + if (client) { + const identifier = await client.copyBinaryFile(filePath, executionId); + // Add client reference id. binaryData.id = this.generateBinaryId(identifier); - // Prevent preserving data in memory if handled by a data manager. + // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; - const fileSize = await manager.getFileSize(identifier); + const fileSize = await client.getFileSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await manager.storeBinaryMetadata(identifier, { + await client.storeBinaryMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, @@ -66,21 +66,21 @@ export class BinaryDataManager { } async storeBinaryData(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { - // If a manager handles this binary, return the binary data with its reference id. - const manager = this.managers[this.mode]; - if (manager) { - const identifier = await manager.storeBinaryData(input, executionId); + // If a client handles this binary, return the binary data with its reference id. + const client = this.clients[this.mode]; + if (client) { + const identifier = await client.storeBinaryData(input, executionId); - // Add data manager reference id. + // Add client reference id. binaryData.id = this.generateBinaryId(identifier); - // Prevent preserving data in memory if handled by a data manager. + // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; - const fileSize = await manager.getFileSize(identifier); + const fileSize = await client.getFileSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await manager.storeBinaryMetadata(identifier, { + await client.storeBinaryMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, @@ -96,8 +96,8 @@ export class BinaryDataManager { getBinaryStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.managers[mode]) { - return this.managers[mode].getBinaryStream(id, chunkSize); + if (this.clients[mode]) { + return this.clients[mode].getBinaryStream(id, chunkSize); } throw new Error('Storage mode used to store binary data not available'); @@ -113,8 +113,8 @@ export class BinaryDataManager { async retrieveBinaryDataByIdentifier(identifier: string): Promise { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.managers[mode]) { - return this.managers[mode].retrieveBinaryDataByIdentifier(id); + if (this.clients[mode]) { + return this.clients[mode].retrieveBinaryDataByIdentifier(id); } throw new Error('Storage mode used to store binary data not available'); @@ -122,8 +122,8 @@ export class BinaryDataManager { getBinaryPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.managers[mode]) { - return this.managers[mode].getBinaryPath(id); + if (this.clients[mode]) { + return this.clients[mode].getBinaryPath(id); } throw new Error('Storage mode used to store binary data not available'); @@ -131,21 +131,21 @@ export class BinaryDataManager { async getBinaryMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.managers[mode]) { - return this.managers[mode].getBinaryMetadata(id); + if (this.clients[mode]) { + return this.clients[mode].getBinaryMetadata(id); } throw new Error('Storage mode used to store binary data not available'); } async deleteBinaryDataByExecutionIds(executionIds: string[]) { - if (this.managers[this.mode]) { - await this.managers[this.mode].deleteBinaryDataByExecutionIds(executionIds); + if (this.clients[this.mode]) { + await this.clients[this.mode].deleteBinaryDataByExecutionIds(executionIds); } } async duplicateBinaryData(inputData: Array, executionId: string) { - if (inputData && this.managers[this.mode]) { + if (inputData && this.clients[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { if (executionDataArray) { @@ -183,7 +183,7 @@ export class BinaryDataManager { executionData: INodeExecutionData, executionId: string, ) { - const binaryManager = this.managers[this.mode]; + const client = this.clients[this.mode]; if (executionData.binary) { const binaryDataKeys = Object.keys(executionData.binary); @@ -197,7 +197,7 @@ export class BinaryDataManager { return { key, newId: undefined }; } - return binaryManager + return client ?.duplicateBinaryDataByIdentifier( this.splitBinaryModeFileId(binaryDataId).id, executionId, diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index f1f3f4ff7a5bd..fabce5c841a7a 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -40,7 +40,7 @@ export interface IBinaryDataConfig { localStoragePath: string; } -export interface IBinaryDataManager { +export interface BinaryDataClient { init(startPurger: boolean): Promise; getFileSize(filePath: string): Promise; copyBinaryFile(filePath: string, executionId: string): Promise; From 5627b4cef127f6d2f96c783dcc286cddbd19f1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:09:33 +0200 Subject: [PATCH 031/259] Rename `BinaryDataFileSystem` to `FileSystemClient` --- .../src/BinaryDataManager/{FileSystem.ts => fs.client.ts} | 2 +- packages/core/src/BinaryDataManager/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/core/src/BinaryDataManager/{FileSystem.ts => fs.client.ts} (98%) diff --git a/packages/core/src/BinaryDataManager/FileSystem.ts b/packages/core/src/BinaryDataManager/fs.client.ts similarity index 98% rename from packages/core/src/BinaryDataManager/FileSystem.ts rename to packages/core/src/BinaryDataManager/fs.client.ts index d8580c3c7e631..f23dcd9b2abe7 100644 --- a/packages/core/src/BinaryDataManager/FileSystem.ts +++ b/packages/core/src/BinaryDataManager/fs.client.ts @@ -12,7 +12,7 @@ import { FileNotFoundError } from '../errors'; const executionExtractionRegexp = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; -export class BinaryDataFileSystem implements BinaryDataClient { +export class FileSystemClient implements BinaryDataClient { private storagePath: string; constructor(config: IBinaryDataConfig) { diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 96b0df694477c..bc3e4178e32aa 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -4,7 +4,7 @@ import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; import { BINARY_ENCODING } from 'n8n-workflow'; import type { IBinaryDataConfig, BinaryDataClient } from '../Interfaces'; -import { BinaryDataFileSystem } from './FileSystem'; +import { FileSystemClient } from './fs.client'; import { binaryToBuffer } from './utils'; import { Service } from 'typedi'; @@ -30,7 +30,7 @@ export class BinaryDataManager { this.clients = {}; if (this.availableModes.includes('filesystem')) { - this.clients.filesystem = new BinaryDataFileSystem(config); + this.clients.filesystem = new FileSystemClient(config); await this.clients.filesystem.init(mainClient); } From 64517504a45a3de361391b0fcb5b22dff7fc580a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:22:56 +0200 Subject: [PATCH 032/259] Clean up types --- packages/cli/src/config/types.ts | 4 +- .../core/src/BinaryDataManager/fs.client.ts | 6 +-- packages/core/src/BinaryDataManager/index.ts | 23 ++++------- packages/core/src/BinaryDataManager/types.ts | 38 +++++++++++++++++++ packages/core/src/Interfaces.ts | 23 ----------- packages/core/src/index.ts | 1 + 6 files changed, 51 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/BinaryDataManager/types.ts diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 28dee1e73fba0..b5646ff5ae8fb 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import type { IBinaryDataConfig } from 'n8n-core'; +import type { BinaryData } from 'n8n-core'; import type { schema } from './schema'; // ----------------------------------- @@ -76,7 +76,7 @@ type ToReturnType = T extends NumericPath type ExceptionPaths = { 'queue.bull.redis': object; - binaryDataManager: IBinaryDataConfig; + binaryDataManager: BinaryData.Config; 'nodes.exclude': string[] | undefined; 'nodes.include': string[] | undefined; 'userManagement.isInstanceOwnerSetUp': boolean; diff --git a/packages/core/src/BinaryDataManager/fs.client.ts b/packages/core/src/BinaryDataManager/fs.client.ts index f23dcd9b2abe7..dfd4d315b1a2a 100644 --- a/packages/core/src/BinaryDataManager/fs.client.ts +++ b/packages/core/src/BinaryDataManager/fs.client.ts @@ -6,16 +6,16 @@ import type { Readable } from 'stream'; import type { BinaryMetadata } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; -import type { IBinaryDataConfig, BinaryDataClient } from '../Interfaces'; +import type { BinaryData } from './types'; import { FileNotFoundError } from '../errors'; const executionExtractionRegexp = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; -export class FileSystemClient implements BinaryDataClient { +export class FileSystemClient implements BinaryData.Client { private storagePath: string; - constructor(config: IBinaryDataConfig) { + constructor(config: BinaryData.FileSystemConfig) { this.storagePath = config.localStoragePath; } diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index bc3e4178e32aa..20c4bd1a6310e 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -3,34 +3,25 @@ import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; import { BINARY_ENCODING } from 'n8n-workflow'; -import type { IBinaryDataConfig, BinaryDataClient } from '../Interfaces'; +import type { BinaryData } from './types'; import { FileSystemClient } from './fs.client'; import { binaryToBuffer } from './utils'; import { Service } from 'typedi'; @Service() export class BinaryDataManager { - private clients: { - [key: string]: BinaryDataClient; - } = {}; + private availableModes: BinaryData.Mode[] = []; - /** - * Mode for storing binary data: - * - `default` (in memory) - * - `filesystem` (on disk) - * - `object` (S3) - */ - private mode = 'default'; + private mode: BinaryData.Mode = 'default'; - private availableModes: string[] = []; + private clients: Record = {}; - async init(config: IBinaryDataConfig, mainClient = false) { + async init(config: BinaryData.Config, mainClient = false) { + this.availableModes = config.availableModes.split(',') as BinaryData.Mode[]; // @TODO: Remove assertion this.mode = config.mode; - this.availableModes = config.availableModes.split(','); - this.clients = {}; if (this.availableModes.includes('filesystem')) { - this.clients.filesystem = new FileSystemClient(config); + this.clients.filesystem = new FileSystemClient(config as BinaryData.FileSystemConfig); // @TODO: Remove assertion await this.clients.filesystem.init(mainClient); } diff --git a/packages/core/src/BinaryDataManager/types.ts b/packages/core/src/BinaryDataManager/types.ts new file mode 100644 index 0000000000000..f5a20ef3db96d --- /dev/null +++ b/packages/core/src/BinaryDataManager/types.ts @@ -0,0 +1,38 @@ +import type { Readable } from 'stream'; +import type { BinaryMetadata } from 'n8n-workflow'; + +export namespace BinaryData { + /** + * Mode for storing binary data: + * - `default` (in memory) + * - `filesystem` (on disk) + * - `object` (S3) + */ + export type Mode = 'default' | 'filesystem' | 'object'; + + type ConfigBase = { + mode: Mode; + availableModes: string; // comma-separated list + }; + + type InMemoryConfig = ConfigBase & { mode: 'default' }; + + export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; localStoragePath: string }; + + export type Config = InMemoryConfig | FileSystemConfig; + + export interface Client { + init(startPurger: boolean): Promise; + getFileSize(filePath: string): Promise; + copyBinaryFile(filePath: string, executionId: string): Promise; + storeBinaryMetadata(identifier: string, metadata: BinaryMetadata): Promise; + getBinaryMetadata(identifier: string): Promise; + storeBinaryData(binaryData: Buffer | Readable, executionId: string): Promise; + retrieveBinaryDataByIdentifier(identifier: string): Promise; + getBinaryPath(identifier: string): string; + getBinaryStream(identifier: string, chunkSize?: number): Readable; + deleteBinaryDataByIdentifier(identifier: string): Promise; + duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise; + deleteBinaryDataByExecutionIds(executionIds: string[]): Promise; + } +} diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index fabce5c841a7a..aa723045fdcd1 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -1,9 +1,7 @@ -import type { Readable } from 'stream'; import type { IPollResponse, ITriggerResponse, IWorkflowSettings as IWorkflowSettingsWorkflow, - BinaryMetadata, ValidationResult, } from 'n8n-workflow'; @@ -34,27 +32,6 @@ export interface IWorkflowData { triggerResponses?: ITriggerResponse[]; } -export interface IBinaryDataConfig { - mode: 'default' | 'filesystem'; - availableModes: string; - localStoragePath: string; -} - -export interface BinaryDataClient { - init(startPurger: boolean): Promise; - getFileSize(filePath: string): Promise; - copyBinaryFile(filePath: string, executionId: string): Promise; - storeBinaryMetadata(identifier: string, metadata: BinaryMetadata): Promise; - getBinaryMetadata(identifier: string): Promise; - storeBinaryData(binaryData: Buffer | Readable, executionId: string): Promise; - retrieveBinaryDataByIdentifier(identifier: string): Promise; - getBinaryPath(identifier: string): string; - getBinaryStream(identifier: string, chunkSize?: number): Readable; - deleteBinaryDataByIdentifier(identifier: string): Promise; - duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise; - deleteBinaryDataByExecutionIds(executionIds: string[]): Promise; -} - export namespace n8n { export interface PackageJson { name: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b441605eeb0d..fb79dee6deff3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,3 +15,4 @@ export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; +export * from './BinaryDataManager/types'; From 7a7ad54f2b8e3466be5304670f08b9171b0ee59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:28:52 +0200 Subject: [PATCH 033/259] Move `binaryToBuffer` to manager --- packages/core/src/BinaryDataManager/index.ts | 13 ++++++++++--- packages/core/src/BinaryDataManager/utils.ts | 8 -------- packages/core/src/NodeExecuteFunctions.ts | 13 +++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) delete mode 100644 packages/core/src/BinaryDataManager/utils.ts diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index 20c4bd1a6310e..c61c4b6a462cc 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -5,8 +5,8 @@ import type { Readable } from 'stream'; import { BINARY_ENCODING } from 'n8n-workflow'; import type { BinaryData } from './types'; import { FileSystemClient } from './fs.client'; -import { binaryToBuffer } from './utils'; import { Service } from 'typedi'; +import concatStream from 'concat-stream'; @Service() export class BinaryDataManager { @@ -77,7 +77,7 @@ export class BinaryDataManager { fileSize, }); } else { - const buffer = await binaryToBuffer(input); + const buffer = await this.binaryToBuffer(input); binaryData.data = buffer.toString(BINARY_ENCODING); binaryData.fileSize = prettyBytes(buffer.length); } @@ -85,6 +85,13 @@ export class BinaryDataManager { return binaryData; } + async binaryToBuffer(body: Buffer | Readable) { + return new Promise((resolve) => { + if (Buffer.isBuffer(body)) resolve(body); + else body.pipe(concatStream(resolve)); + }); + } + getBinaryStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { @@ -94,7 +101,7 @@ export class BinaryDataManager { throw new Error('Storage mode used to store binary data not available'); } - async getBinaryDataBuffer(binaryData: IBinaryData): Promise { + async getBinaryDataBuffer(binaryData: IBinaryData) { if (binaryData.id) { return this.retrieveBinaryDataByIdentifier(binaryData.id); } diff --git a/packages/core/src/BinaryDataManager/utils.ts b/packages/core/src/BinaryDataManager/utils.ts deleted file mode 100644 index 85fb05f6870bd..0000000000000 --- a/packages/core/src/BinaryDataManager/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import concatStream from 'concat-stream'; -import type { Readable } from 'stream'; - -export const binaryToBuffer = async (body: Buffer | Readable) => - new Promise((resolve) => { - if (Buffer.isBuffer(body)) resolve(body); - else body.pipe(concatStream(resolve)); - }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index daee20685ed7a..74293a9414fb3 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -129,7 +129,6 @@ import { UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from './Constants'; -import { binaryToBuffer } from './BinaryDataManager/utils'; import { getAllWorkflowExecutionMetadata, getWorkflowExecutionMetadata, @@ -698,9 +697,9 @@ export async function proxyRequestToAxios( let responseData = response.data; if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { - responseData = await binaryToBuffer(responseData).then((buffer) => - buffer.toString('utf-8'), - ); + responseData = await Container.get(BinaryDataManager) + .binaryToBuffer(responseData) + .then((buffer) => buffer.toString('utf-8')); } if (configObject.simple === false) { @@ -2376,7 +2375,8 @@ const getBinaryHelperFunctions = ({ getBinaryPath, getBinaryStream, getBinaryMetadata, - binaryToBuffer, + binaryToBuffer: async (body: Buffer | Readable) => + Container.get(BinaryDataManager).binaryToBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => prepareBinaryData(binaryData, executionId!, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => @@ -2636,7 +2636,8 @@ export function getExecuteFunctions( ); return dataProxy.getDataProxy(); }, - binaryToBuffer, + binaryToBuffer: async (body: Buffer | Readable) => + Container.get(BinaryDataManager).binaryToBuffer(body), async putExecutionToWait(waitTill: Date): Promise { runExecutionData.waitTill = waitTill; if (additionalData.setExecutionStatus) { From cdfe2fce2a9b5054a908b55995d35a6ee4719edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:31:57 +0200 Subject: [PATCH 034/259] Rename `BinaryDataManager` to `BinaryDataService` --- packages/core/src/NodeExecuteFunctions.ts | 22 +++++++++---------- .../binaryData.service.ts} | 2 +- .../fs.client.ts | 0 .../types.ts | 0 packages/core/src/index.ts | 4 ++-- .../core/test/NodeExecuteFunctions.test.ts | 11 +++++----- 6 files changed, 19 insertions(+), 20 deletions(-) rename packages/core/src/{BinaryDataManager/index.ts => binaryData/binaryData.service.ts} (99%) rename packages/core/src/{BinaryDataManager => binaryData}/fs.client.ts (100%) rename packages/core/src/{BinaryDataManager => binaryData}/types.ts (100%) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 74293a9414fb3..38a2118a0504c 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -115,7 +115,7 @@ import { Readable } from 'stream'; import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import { createReadStream } from 'fs'; -import { BinaryDataManager } from './BinaryDataManager'; +import { BinaryDataService } from './binaryData/binaryData.service'; import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; @@ -697,7 +697,7 @@ export async function proxyRequestToAxios( let responseData = response.data; if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { - responseData = await Container.get(BinaryDataManager) + responseData = await Container.get(BinaryDataService) .binaryToBuffer(responseData) .then((buffer) => buffer.toString('utf-8')); } @@ -864,21 +864,21 @@ async function httpRequest( } export function getBinaryPath(binaryDataId: string): string { - return Container.get(BinaryDataManager).getBinaryPath(binaryDataId); + return Container.get(BinaryDataService).getBinaryPath(binaryDataId); } /** * Returns binary file metadata */ export async function getBinaryMetadata(binaryDataId: string): Promise { - return Container.get(BinaryDataManager).getBinaryMetadata(binaryDataId); + return Container.get(BinaryDataService).getBinaryMetadata(binaryDataId); } /** * Returns binary file stream for piping */ export function getBinaryStream(binaryDataId: string, chunkSize?: number): Readable { - return Container.get(BinaryDataManager).getBinaryStream(binaryDataId, chunkSize); + return Container.get(BinaryDataService).getBinaryStream(binaryDataId, chunkSize); } export function assertBinaryData( @@ -915,7 +915,7 @@ export async function getBinaryDataBuffer( inputIndex: number, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return Container.get(BinaryDataManager).getBinaryDataBuffer(binaryData); + return Container.get(BinaryDataService).getBinaryDataBuffer(binaryData); } /** @@ -931,7 +931,7 @@ export async function setBinaryDataBuffer( binaryData: Buffer | Readable, executionId: string, ): Promise { - return Container.get(BinaryDataManager).storeBinaryData(data, binaryData, executionId); + return Container.get(BinaryDataService).storeBinaryData(data, binaryData, executionId); } export async function copyBinaryFile( @@ -984,7 +984,7 @@ export async function copyBinaryFile( returnData.fileName = path.parse(filePath).base; } - return Container.get(BinaryDataManager).copyBinaryFile(returnData, filePath, executionId); + return Container.get(BinaryDataService).copyBinaryFile(returnData, filePath, executionId); } /** @@ -2376,7 +2376,7 @@ const getBinaryHelperFunctions = ({ getBinaryStream, getBinaryMetadata, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataManager).binaryToBuffer(body), + Container.get(BinaryDataService).binaryToBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => prepareBinaryData(binaryData, executionId!, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => @@ -2564,7 +2564,7 @@ export function getExecuteFunctions( parentWorkflowSettings: workflow.settings, }) .then(async (result) => - Container.get(BinaryDataManager).duplicateBinaryData( + Container.get(BinaryDataService).duplicateBinaryData( result, additionalData.executionId!, ), @@ -2637,7 +2637,7 @@ export function getExecuteFunctions( return dataProxy.getDataProxy(); }, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataManager).binaryToBuffer(body), + Container.get(BinaryDataService).binaryToBuffer(body), async putExecutionToWait(waitTill: Date): Promise { runExecutionData.waitTill = waitTill; if (additionalData.setExecutionStatus) { diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/binaryData/binaryData.service.ts similarity index 99% rename from packages/core/src/BinaryDataManager/index.ts rename to packages/core/src/binaryData/binaryData.service.ts index c61c4b6a462cc..d0e81bdfa6444 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -9,7 +9,7 @@ import { Service } from 'typedi'; import concatStream from 'concat-stream'; @Service() -export class BinaryDataManager { +export class BinaryDataService { private availableModes: BinaryData.Mode[] = []; private mode: BinaryData.Mode = 'default'; diff --git a/packages/core/src/BinaryDataManager/fs.client.ts b/packages/core/src/binaryData/fs.client.ts similarity index 100% rename from packages/core/src/BinaryDataManager/fs.client.ts rename to packages/core/src/binaryData/fs.client.ts diff --git a/packages/core/src/BinaryDataManager/types.ts b/packages/core/src/binaryData/types.ts similarity index 100% rename from packages/core/src/BinaryDataManager/types.ts rename to packages/core/src/binaryData/types.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb79dee6deff3..c595e56458de0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,8 @@ import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import * as UserSettings from './UserSettings'; export * from './ActiveWorkflows'; -export * from './BinaryDataManager'; +export * from './binaryData/binaryData.service'; +export * from './binaryData/types'; export * from './ClassLoader'; export * from './Constants'; export * from './Credentials'; @@ -15,4 +16,3 @@ export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; -export * from './BinaryDataManager/types'; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 9fb7890f7081f..f9b25a46ccbe8 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -11,7 +11,7 @@ import type { Workflow, WorkflowHooks, } from 'n8n-workflow'; -import { BinaryDataManager } from '@/BinaryDataManager'; +import { BinaryDataService } from '@/binaryData/binaryData.service'; import { setBinaryDataBuffer, getBinaryDataBuffer, @@ -26,12 +26,11 @@ describe('NodeExecuteFunctions', () => { describe('test binary data helper methods', () => { test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'default' mode", async () => { // Setup a 'default' binary data manager instance - Container.set(BinaryDataManager, new BinaryDataManager()); + Container.set(BinaryDataService, new BinaryDataService()); - await Container.get(BinaryDataManager).init({ + await Container.get(BinaryDataService).init({ mode: 'default', availableModes: 'default', - localStoragePath: temporaryDir, }); // Set our binary data buffer @@ -76,10 +75,10 @@ describe('NodeExecuteFunctions', () => { }); test("test getBinaryDataBuffer(...) & setBinaryDataBuffer(...) methods in 'filesystem' mode", async () => { - Container.set(BinaryDataManager, new BinaryDataManager()); + Container.set(BinaryDataService, new BinaryDataService()); // Setup a 'filesystem' binary data manager instance - await Container.get(BinaryDataManager).init({ + await Container.get(BinaryDataService).init({ mode: 'filesystem', availableModes: 'filesystem', localStoragePath: temporaryDir, From b5bc390d8aae4e00463d7910661a7623df938cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:37:41 +0200 Subject: [PATCH 035/259] Unify storage path naming --- packages/cli/src/config/schema.ts | 2 +- packages/core/src/binaryData/fs.client.ts | 2 +- packages/core/src/binaryData/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index cf9ea86c4b740..059e650d5776d 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -895,7 +895,7 @@ export const schema = { env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', }, - localStoragePath: { + storagePath: { format: String, default: path.join(UserSettings.getUserN8nFolderPath(), 'binaryData'), env: 'N8N_BINARY_DATA_STORAGE_PATH', diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index dfd4d315b1a2a..78887d79780f5 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -16,7 +16,7 @@ export class FileSystemClient implements BinaryData.Client { private storagePath: string; constructor(config: BinaryData.FileSystemConfig) { - this.storagePath = config.localStoragePath; + this.storagePath = config.storagePath; } async init() { diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/binaryData/types.ts index f5a20ef3db96d..36ce9cdae1738 100644 --- a/packages/core/src/binaryData/types.ts +++ b/packages/core/src/binaryData/types.ts @@ -17,7 +17,7 @@ export namespace BinaryData { type InMemoryConfig = ConfigBase & { mode: 'default' }; - export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; localStoragePath: string }; + export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; storagePath: string }; export type Config = InMemoryConfig | FileSystemConfig; From c876697859f3bb278679d17902ef6ce151c0dabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:37:56 +0200 Subject: [PATCH 036/259] Set binary data service in nodes-base test helper --- .../core/test/NodeExecuteFunctions.test.ts | 2 +- packages/nodes-base/package.json | 1 + packages/nodes-base/test/nodes/Helpers.ts | 13 ++++------ pnpm-lock.yaml | 26 +++++++++++-------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index f9b25a46ccbe8..7ea8dbc289724 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -81,7 +81,7 @@ describe('NodeExecuteFunctions', () => { await Container.get(BinaryDataService).init({ mode: 'filesystem', availableModes: 'filesystem', - localStoragePath: temporaryDir, + storagePath: temporaryDir, }); // Set our binary data buffer diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5cf314d1b9e16..9c4e6321c02d6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -870,6 +870,7 @@ "snowflake-sdk": "^1.8.0", "ssh2-sftp-client": "^7.0.0", "tmp-promise": "^3.0.2", + "typedi": "^0.10.0", "uuid": "^8.3.2", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", "xml2js": "^0.5.0" diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index d14ef99577011..8775d0b54dcb7 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -3,7 +3,8 @@ import path from 'path'; import { tmpdir } from 'os'; import { isEmpty } from 'lodash'; import { get } from 'lodash'; -import { BinaryDataManager, Credentials, constructExecutionMetaData } from 'n8n-core'; +import { BinaryDataService, Credentials, constructExecutionMetaData } from 'n8n-core'; +import { Container } from 'typedi'; import type { CredentialLoadingDetails, ICredentialDataDecryptedObject, @@ -217,13 +218,9 @@ export function createTemporaryDir(prefix = 'n8n') { } export async function initBinaryDataManager(mode: 'default' | 'filesystem' = 'default') { - const temporaryDir = createTemporaryDir(); - await BinaryDataManager.init({ - mode, - availableModes: mode, - localStoragePath: temporaryDir, - }); - return temporaryDir; + const binaryDataService = new BinaryDataService(); + await binaryDataService.init({ mode: 'default', availableModes: mode }); + Container.set(BinaryDataService, binaryDataService); } const credentialTypes = new CredentialType(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bda6a0b0a436..667eb59b12779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ importers: dependencies: axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) packages/@n8n_io/eslint-config: devDependencies: @@ -220,7 +220,7 @@ importers: version: 7.28.1 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -587,7 +587,7 @@ importers: version: link:../@n8n/client-oauth2 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -850,7 +850,7 @@ importers: version: 10.2.0(vue@3.3.4) axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -1183,6 +1183,9 @@ importers: tmp-promise: specifier: ^3.0.2 version: 3.0.3 + typedi: + specifier: ^0.10.0 + version: 0.10.0(patch_hash=62r6bc2crgimafeyruodhqlgo4) uuid: specifier: ^8.3.2 version: 8.3.2 @@ -5009,7 +5012,7 @@ packages: dependencies: '@segment/loosely-validate-event': 2.0.0 auto-changelog: 1.16.4 - axios: 0.21.4 + axios: 0.21.4(debug@4.3.2) axios-retry: 3.3.1 bull: 3.29.3 lodash.clonedeep: 4.5.0 @@ -9069,18 +9072,19 @@ packages: is-retry-allowed: 2.2.0 dev: false - /axios@0.21.4: + /axios@0.21.4(debug@4.3.2): resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2(debug@3.2.7) + follow-redirects: 1.15.2(debug@4.3.2) transitivePeerDependencies: - debug dev: false - /axios@0.21.4(debug@4.3.2): - resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + /axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: follow-redirects: 1.15.2(debug@4.3.2) + form-data: 4.0.0 transitivePeerDependencies: - debug dev: false @@ -9106,7 +9110,7 @@ packages: /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: - follow-redirects: 1.15.2(debug@3.2.7) + follow-redirects: 1.15.2(debug@4.3.2) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -18008,7 +18012,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2(debug@3.2.7) + axios: 0.27.2 transitivePeerDependencies: - debug dev: false From 25a796cd545e584f9675d279a89645bb06c58578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:38:41 +0200 Subject: [PATCH 037/259] Rename manager in nodes-base --- packages/nodes-base/nodes/Aws/S3/test/V1/AwsS3.node.test.ts | 4 ++-- packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.test.ts | 4 ++-- packages/nodes-base/nodes/Code/test/Code.node.test.ts | 4 ++-- .../nodes/Compression/test/node/Compression.test.ts | 4 ++-- packages/nodes-base/nodes/Crypto/test/Crypto.test.ts | 4 ++-- .../nodes-base/nodes/ICalendar/test/node/ICalendar.test.ts | 4 ++-- .../nodes/MoveBinaryData/test/MoveBinaryData.test.ts | 2 +- .../nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts | 2 +- .../nodes/ReadBinaryFile/test/ReadBinaryFile.test.ts | 2 +- .../nodes/ReadBinaryFiles/test/ReadBinaryFiles.test.ts | 2 +- packages/nodes-base/nodes/ReadPdf/test/ReadPDF.test.ts | 4 ++-- .../nodes/SpreadsheetFile/test/SpreadsheetFile.test.ts | 2 +- .../nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts | 2 +- packages/nodes-base/test/nodes/Helpers.ts | 2 +- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/test/V1/AwsS3.node.test.ts b/packages/nodes-base/nodes/Aws/S3/test/V1/AwsS3.node.test.ts index 91150c8c86d2d..c13c572ef2758 100644 --- a/packages/nodes-base/nodes/Aws/S3/test/V1/AwsS3.node.test.ts +++ b/packages/nodes-base/nodes/Aws/S3/test/V1/AwsS3.node.test.ts @@ -1,5 +1,5 @@ import nock from 'nock'; -import { getWorkflowFilenames, initBinaryDataManager, testWorkflows } from '@test/nodes/Helpers'; +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; const workflows = getWorkflowFilenames(__dirname); @@ -11,7 +11,7 @@ describe('Test S3 V1 Node', () => { beforeAll(async () => { jest.useFakeTimers({ doNotFake: ['nextTick'], now }); - await initBinaryDataManager(); + await initBinaryDataService(); nock.disableNetConnect(); mock = nock('https://bucket.s3.eu-central-1.amazonaws.com'); diff --git a/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.test.ts b/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.test.ts index b8a9a3d6c0a22..2f3b0516c8cbc 100644 --- a/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.test.ts +++ b/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.test.ts @@ -1,5 +1,5 @@ import nock from 'nock'; -import { getWorkflowFilenames, initBinaryDataManager, testWorkflows } from '@test/nodes/Helpers'; +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; const workflows = getWorkflowFilenames(__dirname); @@ -11,7 +11,7 @@ describe('Test S3 V2 Node', () => { beforeAll(async () => { jest.useFakeTimers({ doNotFake: ['nextTick'], now }); - await initBinaryDataManager(); + await initBinaryDataService(); nock.disableNetConnect(); mock = nock('https://bucket.s3.eu-central-1.amazonaws.com'); diff --git a/packages/nodes-base/nodes/Code/test/Code.node.test.ts b/packages/nodes-base/nodes/Code/test/Code.node.test.ts index 7c47a0a5632f0..da798db3a8206 100644 --- a/packages/nodes-base/nodes/Code/test/Code.node.test.ts +++ b/packages/nodes-base/nodes/Code/test/Code.node.test.ts @@ -3,7 +3,7 @@ import { NodeVM } from '@n8n/vm2'; import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow'; import { normalizeItems } from 'n8n-core'; -import { testWorkflows, getWorkflowFilenames, initBinaryDataManager } from '@test/nodes/Helpers'; +import { testWorkflows, getWorkflowFilenames, initBinaryDataService } from '@test/nodes/Helpers'; import { Code } from '../Code.node'; import { ValidationError } from '../ValidationError'; @@ -11,7 +11,7 @@ describe('Test Code Node', () => { const workflows = getWorkflowFilenames(__dirname); beforeAll(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); }); testWorkflows(workflows); diff --git a/packages/nodes-base/nodes/Compression/test/node/Compression.test.ts b/packages/nodes-base/nodes/Compression/test/node/Compression.test.ts index 5fbe41796346d..ea7ff18819ebd 100644 --- a/packages/nodes-base/nodes/Compression/test/node/Compression.test.ts +++ b/packages/nodes-base/nodes/Compression/test/node/Compression.test.ts @@ -5,7 +5,7 @@ import type { IDataObject } from 'n8n-workflow'; import { getResultNodeData, setup, - initBinaryDataManager, + initBinaryDataService, readJsonFileSync, } from '@test/nodes/Helpers'; import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; @@ -16,7 +16,7 @@ import os from 'node:os'; if (os.platform() !== 'win32') { describe('Execute Compression Node', () => { beforeEach(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); }); const workflowData = readJsonFileSync('nodes/Compression/test/node/workflow.compression.json'); diff --git a/packages/nodes-base/nodes/Crypto/test/Crypto.test.ts b/packages/nodes-base/nodes/Crypto/test/Crypto.test.ts index ab4742b70dd62..b321d4e852b67 100644 --- a/packages/nodes-base/nodes/Crypto/test/Crypto.test.ts +++ b/packages/nodes-base/nodes/Crypto/test/Crypto.test.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import fsPromises from 'fs/promises'; import { Readable } from 'stream'; -import { testWorkflows, getWorkflowFilenames, initBinaryDataManager } from '@test/nodes/Helpers'; +import { testWorkflows, getWorkflowFilenames, initBinaryDataService } from '@test/nodes/Helpers'; const workflows = getWorkflowFilenames(__dirname); @@ -13,7 +13,7 @@ describe('Test Crypto Node', () => { fs.createReadStream = () => Readable.from(Buffer.from('test')) as fs.ReadStream; beforeEach(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); }); testWorkflows(workflows); diff --git a/packages/nodes-base/nodes/ICalendar/test/node/ICalendar.test.ts b/packages/nodes-base/nodes/ICalendar/test/node/ICalendar.test.ts index f9adbf508f22f..9757cd5f2a479 100644 --- a/packages/nodes-base/nodes/ICalendar/test/node/ICalendar.test.ts +++ b/packages/nodes-base/nodes/ICalendar/test/node/ICalendar.test.ts @@ -5,13 +5,13 @@ import { getResultNodeData, setup, readJsonFileSync, - initBinaryDataManager, + initBinaryDataService, } from '@test/nodes/Helpers'; import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; describe('Execute iCalendar Node', () => { beforeEach(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); }); const workflowData = readJsonFileSync('nodes/ICalendar/test/node/workflow.iCalendar.json'); diff --git a/packages/nodes-base/nodes/MoveBinaryData/test/MoveBinaryData.test.ts b/packages/nodes-base/nodes/MoveBinaryData/test/MoveBinaryData.test.ts index e0aef1e046fc1..8abf8e2db53bf 100644 --- a/packages/nodes-base/nodes/MoveBinaryData/test/MoveBinaryData.test.ts +++ b/packages/nodes-base/nodes/MoveBinaryData/test/MoveBinaryData.test.ts @@ -6,7 +6,7 @@ import path from 'path'; describe('Test Move Binary Data Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); }); const workflow = Helpers.readJsonFileSync( diff --git a/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts b/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts index eeaa22449341d..94ea8cc6bd688 100644 --- a/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts +++ b/packages/nodes-base/nodes/QuickChart/test/QuickChart.node.test.ts @@ -6,7 +6,7 @@ import nock from 'nock'; describe('Test QuickChart Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); nock.disableNetConnect(); nock('https://quickchart.io') .persist() diff --git a/packages/nodes-base/nodes/ReadBinaryFile/test/ReadBinaryFile.test.ts b/packages/nodes-base/nodes/ReadBinaryFile/test/ReadBinaryFile.test.ts index 637b1d6b07847..8590bf61fca05 100644 --- a/packages/nodes-base/nodes/ReadBinaryFile/test/ReadBinaryFile.test.ts +++ b/packages/nodes-base/nodes/ReadBinaryFile/test/ReadBinaryFile.test.ts @@ -6,7 +6,7 @@ import path from 'path'; describe('Test Read Binary File Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); }); const workflow = Helpers.readJsonFileSync( diff --git a/packages/nodes-base/nodes/ReadBinaryFiles/test/ReadBinaryFiles.test.ts b/packages/nodes-base/nodes/ReadBinaryFiles/test/ReadBinaryFiles.test.ts index 78f021c504318..d4bf87f04533f 100644 --- a/packages/nodes-base/nodes/ReadBinaryFiles/test/ReadBinaryFiles.test.ts +++ b/packages/nodes-base/nodes/ReadBinaryFiles/test/ReadBinaryFiles.test.ts @@ -6,7 +6,7 @@ import path from 'path'; describe('Test Read Binary Files Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); }); const workflow = Helpers.readJsonFileSync( diff --git a/packages/nodes-base/nodes/ReadPdf/test/ReadPDF.test.ts b/packages/nodes-base/nodes/ReadPdf/test/ReadPDF.test.ts index cd91f69a1df06..8a394c0ce9ba4 100644 --- a/packages/nodes-base/nodes/ReadPdf/test/ReadPDF.test.ts +++ b/packages/nodes-base/nodes/ReadPdf/test/ReadPDF.test.ts @@ -1,10 +1,10 @@ -import { getWorkflowFilenames, initBinaryDataManager, testWorkflows } from '@test/nodes/Helpers'; +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; describe('Test Read PDF Node', () => { const workflows = getWorkflowFilenames(__dirname); beforeAll(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); }); testWorkflows(workflows); diff --git a/packages/nodes-base/nodes/SpreadsheetFile/test/SpreadsheetFile.test.ts b/packages/nodes-base/nodes/SpreadsheetFile/test/SpreadsheetFile.test.ts index ab7313deda2b9..ddbe08c271611 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/test/SpreadsheetFile.test.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/test/SpreadsheetFile.test.ts @@ -6,7 +6,7 @@ import path from 'path'; describe('Execute Spreadsheet File Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); }); // replace workflow json 'Read Binary File' node's filePath to local file diff --git a/packages/nodes-base/nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts b/packages/nodes-base/nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts index 147d4318f3aa5..9cd6df1da5fa7 100644 --- a/packages/nodes-base/nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts +++ b/packages/nodes-base/nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts @@ -6,7 +6,7 @@ import path from 'path'; describe('Test Write Binary File Node', () => { beforeEach(async () => { - await Helpers.initBinaryDataManager(); + await Helpers.initBinaryDataService(); }); const temporaryDir = Helpers.createTemporaryDir(); diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 8775d0b54dcb7..7973221217569 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -217,7 +217,7 @@ export function createTemporaryDir(prefix = 'n8n') { return mkdtempSync(path.join(tmpdir(), prefix)); } -export async function initBinaryDataManager(mode: 'default' | 'filesystem' = 'default') { +export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { const binaryDataService = new BinaryDataService(); await binaryDataService.init({ mode: 'default', availableModes: mode }); Container.set(BinaryDataService, binaryDataService); From 538d31892c7795eb10c72fbc7e056044db1b7f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 18:43:42 +0200 Subject: [PATCH 038/259] More renamings --- packages/cli/src/Server.ts | 12 ++++++------ packages/cli/src/WebhookHelpers.ts | 6 +++--- packages/cli/src/WorkflowRunnerProcess.ts | 6 +++--- packages/cli/src/commands/BaseCommand.ts | 6 +++--- packages/cli/src/config/schema.ts | 2 +- packages/cli/src/config/types.ts | 2 +- .../databases/repositories/execution.repository.ts | 6 +++--- .../test/integration/publicApi/executions.test.ts | 2 +- packages/cli/test/integration/shared/utils/index.ts | 11 ++++++++--- 9 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index dcfafbe3dec58..22c685214e8ae 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -26,7 +26,7 @@ import type { RequestOptions } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; import { - BinaryDataManager, + BinaryDataService, Credentials, LoadMappingOptions, LoadNodeParameterOptions, @@ -201,7 +201,7 @@ export class Server extends AbstractServer { push: Push; - binaryDataManager: BinaryDataManager; + binaryDataService: BinaryDataService; constructor() { super('main'); @@ -358,13 +358,13 @@ export class Server extends AbstractServer { this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); this.push = Container.get(Push); - this.binaryDataManager = Container.get(BinaryDataManager); + this.binaryDataService = Container.get(BinaryDataService); await super.start(); LoggerProxy.debug(`Server ID: ${this.uniqueInstanceId}`); const cpus = os.cpus(); - const binaryDataConfig = config.getEnv('binaryDataManager'); + const binaryDataConfig = config.getEnv('binaryDataService'); const diagnosticInfo: IDiagnosticInfo = { databaseType: config.getEnv('database.type'), disableProductionWebhooksOnMainProcess: config.getEnv( @@ -1425,11 +1425,11 @@ export class Server extends AbstractServer { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; try { - const binaryPath = this.binaryDataManager.getBinaryPath(identifier); + const binaryPath = this.binaryDataService.getBinaryPath(identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataManager.getBinaryMetadata(identifier); + const metadata = await this.binaryDataService.getBinaryMetadata(identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index aa84e798ce2bb..fcefd8dc9741c 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -14,7 +14,7 @@ import stream from 'stream'; import { promisify } from 'util'; import formidable from 'formidable'; -import { BinaryDataManager, NodeExecuteFunctions } from 'n8n-core'; +import { BinaryDataService, NodeExecuteFunctions } from 'n8n-core'; import type { IBinaryData, @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = Container.get(BinaryDataManager).getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataService).getBinaryStream(binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -732,7 +732,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = Container.get(BinaryDataManager).getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataService).getBinaryStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 642a7cb98f727..80f877e58a560 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -10,7 +10,7 @@ import 'source-map-support/register'; import 'reflect-metadata'; import { Container } from 'typedi'; import type { IProcessMessage } from 'n8n-core'; -import { BinaryDataManager, UserSettings, WorkflowExecute } from 'n8n-core'; +import { BinaryDataService, UserSettings, WorkflowExecute } from 'n8n-core'; import type { ExecutionError, @@ -122,8 +122,8 @@ class WorkflowRunnerProcess { await Container.get(PostHogClient).init(instanceId); await Container.get(InternalHooks).init(instanceId); - const binaryDataConfig = config.getEnv('binaryDataManager'); - await Container.get(BinaryDataManager).init(binaryDataConfig); + const binaryDataConfig = config.getEnv('binaryDataService'); + await Container.get(BinaryDataService).init(binaryDataConfig); const license = Container.get(License); await license.init(instanceId); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 012d581be2f25..ebb4d2c51a5c0 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -3,7 +3,7 @@ import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataManager, UserSettings } from 'n8n-core'; +import { BinaryDataService, UserSettings } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -104,8 +104,8 @@ export abstract class BaseCommand extends Command { } protected async initBinaryManager() { - const binaryDataConfig = config.getEnv('binaryDataManager'); - await Container.get(BinaryDataManager).init(binaryDataConfig, true); + const binaryDataConfig = config.getEnv('binaryDataService'); + await Container.get(BinaryDataService).init(binaryDataConfig, true); } protected async initExternalHooks() { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 059e650d5776d..e791f41cd7661 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -882,7 +882,7 @@ export const schema = { }, }, - binaryDataManager: { + binaryDataService: { availableModes: { format: String, default: 'filesystem,object', diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index b5646ff5ae8fb..6b87c8016e3fd 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -76,7 +76,7 @@ type ToReturnType = T extends NumericPath type ExceptionPaths = { 'queue.bull.redis': object; - binaryDataManager: BinaryData.Config; + binaryDataService: BinaryData.Config; 'nodes.exclude': string[] | undefined; 'nodes.include': string[] | undefined; 'userManagement.isInstanceOwnerSetUp': boolean; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 3d08483a4507d..2b5b3c24725fb 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -18,7 +18,7 @@ import type { import { parse, stringify } from 'flatted'; import { LoggerProxy as Logger } from 'n8n-workflow'; import type { IExecutionsSummary, IRunExecutionData } from 'n8n-workflow'; -import { BinaryDataManager } from 'n8n-core'; +import { BinaryDataService } from 'n8n-core'; import type { IExecutionBase, IExecutionDb, @@ -81,7 +81,7 @@ export class ExecutionRepository extends Repository { constructor( dataSource: DataSource, private readonly executionDataRepository: ExecutionDataRepository, - private readonly binaryDataManager: BinaryDataManager, + private readonly binaryDataService: BinaryDataService, ) { super(ExecutionEntity, dataSource.manager); @@ -490,7 +490,7 @@ export class ExecutionRepository extends Repository { }) ).map(({ id }) => id); - await this.binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); + await this.binaryDataService.deleteBinaryDataByExecutionIds(executionIds); // Actually delete these executions await this.delete({ id: In(executionIds) }); diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index d42f8ca62ad35..cb8ae69c41fd0 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -23,7 +23,7 @@ beforeAll(async () => { user1 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); user2 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); - // TODO: mock BinaryDataManager instead + // TODO: mock BinaryDataService instead await utils.initBinaryManager(); await utils.initNodeTypes(); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 6033422db23fc..039e2ee7ce198 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'crypto'; import { existsSync } from 'fs'; import { CronJob } from 'cron'; import set from 'lodash/set'; -import { BinaryDataManager, UserSettings } from 'n8n-core'; +import { BinaryDataService, UserSettings } from 'n8n-core'; import type { ICredentialType, IExecuteFunctions, @@ -388,8 +388,13 @@ export async function initNodeTypes() { * Initialize a BinaryManager for test runs. */ export async function initBinaryManager() { - const binaryDataConfig = config.getEnv('binaryDataManager'); - await BinaryDataManager.init(binaryDataConfig); + const binaryDataConfig = config.getEnv('binaryDataService'); + + const binaryDataService = new BinaryDataService(); + await binaryDataService.init(binaryDataConfig); + Container.set(BinaryDataService, binaryDataService); + + await binaryDataService.init(binaryDataConfig); } /** From c7cf4b185bc39873fb61a6ccc4179cc9d6d58866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:01:21 +0200 Subject: [PATCH 039/259] Generalize interface --- .../core/src/binaryData/binaryData.service.ts | 37 +++++++------- packages/core/src/binaryData/fs.client.ts | 49 ++++++++++--------- packages/core/src/binaryData/types.ts | 27 +++++----- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index d0e81bdfa6444..7b0b9a7f9484a 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -28,29 +28,30 @@ export class BinaryDataService { return undefined; } - async copyBinaryFile(binaryData: IBinaryData, filePath: string, executionId: string) { + async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { // If a client handles this binary, copy over the binary file and return its reference id. const client = this.clients[this.mode]; + if (client) { - const identifier = await client.copyBinaryFile(filePath, executionId); + const identifier = await client.copyByPath(path, executionId); // Add client reference id. binaryData.id = this.generateBinaryId(identifier); // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; - const fileSize = await client.getFileSize(identifier); + const fileSize = await client.getSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await client.storeBinaryMetadata(identifier, { + await client.storeMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, }); } else { - const { size } = await stat(filePath); + const { size } = await stat(path); binaryData.fileSize = prettyBytes(size); - binaryData.data = await readFile(filePath, { encoding: BINARY_ENCODING }); + binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); } return binaryData; @@ -60,7 +61,7 @@ export class BinaryDataService { // If a client handles this binary, return the binary data with its reference id. const client = this.clients[this.mode]; if (client) { - const identifier = await client.storeBinaryData(input, executionId); + const identifier = await client.store(input, executionId); // Add client reference id. binaryData.id = this.generateBinaryId(identifier); @@ -68,10 +69,10 @@ export class BinaryDataService { // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; - const fileSize = await client.getFileSize(identifier); + const fileSize = await client.getSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await client.storeBinaryMetadata(identifier, { + await client.storeMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, @@ -95,7 +96,7 @@ export class BinaryDataService { getBinaryStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].getBinaryStream(id, chunkSize); + return this.clients[mode].toStream(id, chunkSize); } throw new Error('Storage mode used to store binary data not available'); @@ -112,7 +113,7 @@ export class BinaryDataService { async retrieveBinaryDataByIdentifier(identifier: string): Promise { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].retrieveBinaryDataByIdentifier(id); + return this.clients[mode].toBuffer(id); } throw new Error('Storage mode used to store binary data not available'); @@ -121,7 +122,7 @@ export class BinaryDataService { getBinaryPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].getBinaryPath(id); + return this.clients[mode].getPath(id); } throw new Error('Storage mode used to store binary data not available'); @@ -130,15 +131,16 @@ export class BinaryDataService { async getBinaryMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].getBinaryMetadata(id); + return this.clients[mode].getMetadata(id); } throw new Error('Storage mode used to store binary data not available'); } async deleteBinaryDataByExecutionIds(executionIds: string[]) { - if (this.clients[this.mode]) { - await this.clients[this.mode].deleteBinaryDataByExecutionIds(executionIds); + const client = this.clients[this.mode]; + if (client) { + await client.deleteManyByExecutionIds(executionIds); } } @@ -196,10 +198,7 @@ export class BinaryDataService { } return client - ?.duplicateBinaryDataByIdentifier( - this.splitBinaryModeFileId(binaryDataId).id, - executionId, - ) + ?.copyByIdentifier(this.splitBinaryModeFileId(binaryDataId).id, executionId) .then((filename) => ({ newId: this.generateBinaryId(filename), key, diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index 78887d79780f5..12e3b9daa9d7e 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -23,42 +23,36 @@ export class FileSystemClient implements BinaryData.Client { await this.assertFolder(this.storagePath); } - async getFileSize(identifier: string): Promise { - const stats = await fs.stat(this.getBinaryPath(identifier)); + async getSize(identifier: string): Promise { + const stats = await fs.stat(this.getPath(identifier)); return stats.size; } - async copyBinaryFile(filePath: string, executionId: string): Promise { - const binaryDataId = this.generateFileName(executionId); - await this.copyFileToLocalStorage(filePath, binaryDataId); - return binaryDataId; - } - - async storeBinaryMetadata(identifier: string, metadata: BinaryMetadata) { + async storeMetadata(identifier: string, metadata: BinaryMetadata) { await fs.writeFile(this.getMetadataPath(identifier), JSON.stringify(metadata), { encoding: 'utf-8', }); } - async getBinaryMetadata(identifier: string): Promise { + async getMetadata(identifier: string): Promise { return jsonParse(await fs.readFile(this.getMetadataPath(identifier), { encoding: 'utf-8' })); } - async storeBinaryData(binaryData: Buffer | Readable, executionId: string): Promise { + async store(binaryData: Buffer | Readable, executionId: string): Promise { const binaryDataId = this.generateFileName(executionId); await this.saveToLocalStorage(binaryData, binaryDataId); return binaryDataId; } - getBinaryStream(identifier: string, chunkSize?: number): Readable { - return createReadStream(this.getBinaryPath(identifier), { highWaterMark: chunkSize }); + toStream(identifier: string, chunkSize?: number): Readable { + return createReadStream(this.getPath(identifier), { highWaterMark: chunkSize }); } - async retrieveBinaryDataByIdentifier(identifier: string): Promise { + async toBuffer(identifier: string): Promise { return this.retrieveFromLocalStorage(identifier); } - getBinaryPath(identifier: string): string { + getPath(identifier: string): string { return this.resolveStoragePath(identifier); } @@ -66,17 +60,23 @@ export class FileSystemClient implements BinaryData.Client { return this.resolveStoragePath(`${identifier}.metadata`); } - async duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise { + async copyByPath(filePath: string, executionId: string): Promise { + const binaryDataId = this.generateFileName(executionId); + await this.copyFileToLocalStorage(filePath, binaryDataId); + return binaryDataId; + } + + async copyByIdentifier(identifier: string, prefix: string): Promise { const newBinaryDataId = this.generateFileName(prefix); await fs.copyFile( - this.resolveStoragePath(binaryDataId), + this.resolveStoragePath(identifier), this.resolveStoragePath(newBinaryDataId), ); return newBinaryDataId; } - async deleteBinaryDataByExecutionIds(executionIds: string[]): Promise { + async deleteManyByExecutionIds(executionIds: string[]): Promise { const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); const deletedIds = []; @@ -91,7 +91,7 @@ export class FileSystemClient implements BinaryData.Client { return deletedIds; } - async deleteBinaryDataByIdentifier(identifier: string): Promise { + async deleteOne(identifier: string): Promise { return this.deleteFromLocalStorage(identifier); } @@ -108,19 +108,19 @@ export class FileSystemClient implements BinaryData.Client { } private async deleteFromLocalStorage(identifier: string) { - return fs.rm(this.getBinaryPath(identifier)); + return fs.rm(this.getPath(identifier)); } private async copyFileToLocalStorage(source: string, identifier: string): Promise { - await fs.cp(source, this.getBinaryPath(identifier)); + await fs.cp(source, this.getPath(identifier)); } private async saveToLocalStorage(binaryData: Buffer | Readable, identifier: string) { - await fs.writeFile(this.getBinaryPath(identifier), binaryData); + await fs.writeFile(this.getPath(identifier), binaryData); } private async retrieveFromLocalStorage(identifier: string): Promise { - const filePath = this.getBinaryPath(identifier); + const filePath = this.getPath(identifier); try { return await fs.readFile(filePath); } catch (e) { @@ -130,8 +130,9 @@ export class FileSystemClient implements BinaryData.Client { private resolveStoragePath(...args: string[]) { const returnPath = path.join(this.storagePath, ...args); - if (path.relative(this.storagePath, returnPath).startsWith('..')) + if (path.relative(this.storagePath, returnPath).startsWith('..')) { throw new FileNotFoundError('Invalid path detected'); + } return returnPath; } } diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/binaryData/types.ts index 36ce9cdae1738..0a914aa3d33d8 100644 --- a/packages/core/src/binaryData/types.ts +++ b/packages/core/src/binaryData/types.ts @@ -23,16 +23,21 @@ export namespace BinaryData { export interface Client { init(startPurger: boolean): Promise; - getFileSize(filePath: string): Promise; - copyBinaryFile(filePath: string, executionId: string): Promise; - storeBinaryMetadata(identifier: string, metadata: BinaryMetadata): Promise; - getBinaryMetadata(identifier: string): Promise; - storeBinaryData(binaryData: Buffer | Readable, executionId: string): Promise; - retrieveBinaryDataByIdentifier(identifier: string): Promise; - getBinaryPath(identifier: string): string; - getBinaryStream(identifier: string, chunkSize?: number): Readable; - deleteBinaryDataByIdentifier(identifier: string): Promise; - duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise; - deleteBinaryDataByExecutionIds(executionIds: string[]): Promise; + + store(binaryData: Buffer | Readable, executionId: string): Promise; + getPath(identifier: string): string; + getSize(path: string): Promise; + + storeMetadata(identifier: string, metadata: BinaryMetadata): Promise; + getMetadata(identifier: string): Promise; + + toBuffer(identifier: string): Promise; + toStream(identifier: string, chunkSize?: number): Readable; + + copyByPath(path: string, executionId: string): Promise; + copyByIdentifier(identifier: string, prefix: string): Promise; + + deleteOne(identifier: string): Promise; + deleteManyByExecutionIds(executionIds: string[]): Promise; } } From 307bb1b595a56e3d51ccfecefd920651fd1a8c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:33:37 +0200 Subject: [PATCH 040/259] Continue generalizing --- .../core/src/binaryData/binaryData.service.ts | 8 +- packages/core/src/binaryData/fs.client.ts | 144 +++++++++--------- packages/core/src/binaryData/s3.client.ts | 55 +++++++ packages/core/src/binaryData/types.ts | 7 +- 4 files changed, 135 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/binaryData/s3.client.ts diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 7b0b9a7f9484a..38b44b5ee5b96 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -21,7 +21,9 @@ export class BinaryDataService { this.mode = config.mode; if (this.availableModes.includes('filesystem')) { - this.clients.filesystem = new FileSystemClient(config as BinaryData.FileSystemConfig); // @TODO: Remove assertion + this.clients.filesystem = new FileSystemClient( + (config as BinaryData.FileSystemConfig).storagePath, + ); // @TODO: Remove assertion await this.clients.filesystem.init(mainClient); } @@ -96,7 +98,7 @@ export class BinaryDataService { getBinaryStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].toStream(id, chunkSize); + return this.clients[mode].getAsStream(id, chunkSize); } throw new Error('Storage mode used to store binary data not available'); @@ -113,7 +115,7 @@ export class BinaryDataService { async retrieveBinaryDataByIdentifier(identifier: string): Promise { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { - return this.clients[mode].toBuffer(id); + return this.clients[mode].getAsBuffer(id); } throw new Error('Storage mode used to store binary data not available'); diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index 12e3b9daa9d7e..f849b2490f744 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -2,137 +2,137 @@ import { createReadStream } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { v4 as uuid } from 'uuid'; -import type { Readable } from 'stream'; -import type { BinaryMetadata } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; +import { FileNotFoundError } from '../errors'; +import type { Readable } from 'stream'; +import type { BinaryMetadata } from 'n8n-workflow'; import type { BinaryData } from './types'; -import { FileNotFoundError } from '../errors'; -const executionExtractionRegexp = +const EXECUTION_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; export class FileSystemClient implements BinaryData.Client { - private storagePath: string; + constructor(private storagePath: string) {} - constructor(config: BinaryData.FileSystemConfig) { - this.storagePath = config.storagePath; + async init() { + await this.ensureDirExists(this.storagePath); } - async init() { - await this.assertFolder(this.storagePath); + getPath(identifier: string) { + return this.resolvePath(identifier); } - async getSize(identifier: string): Promise { - const stats = await fs.stat(this.getPath(identifier)); + async getSize(identifier: string) { + const filePath = this.getPath(identifier); + const stats = await fs.stat(filePath); + return stats.size; } - async storeMetadata(identifier: string, metadata: BinaryMetadata) { - await fs.writeFile(this.getMetadataPath(identifier), JSON.stringify(metadata), { - encoding: 'utf-8', - }); - } + getAsStream(identifier: string, chunkSize?: number) { + const filePath = this.getPath(identifier); - async getMetadata(identifier: string): Promise { - return jsonParse(await fs.readFile(this.getMetadataPath(identifier), { encoding: 'utf-8' })); + return createReadStream(filePath, { highWaterMark: chunkSize }); } - async store(binaryData: Buffer | Readable, executionId: string): Promise { - const binaryDataId = this.generateFileName(executionId); - await this.saveToLocalStorage(binaryData, binaryDataId); - return binaryDataId; - } + async getAsBuffer(identifier: string) { + const filePath = this.getPath(identifier); - toStream(identifier: string, chunkSize?: number): Readable { - return createReadStream(this.getPath(identifier), { highWaterMark: chunkSize }); + try { + return await fs.readFile(filePath); + } catch { + throw new Error(`Error finding file: ${filePath}`); + } } - async toBuffer(identifier: string): Promise { - return this.retrieveFromLocalStorage(identifier); - } + async storeMetadata(identifier: string, metadata: BinaryMetadata) { + const filePath = this.resolvePath(`${identifier}.metadata`); - getPath(identifier: string): string { - return this.resolveStoragePath(identifier); + await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' }); } - getMetadataPath(identifier: string): string { - return this.resolveStoragePath(`${identifier}.metadata`); + async getMetadata(identifier: string): Promise { + const filePath = this.resolvePath(`${identifier}.metadata`); + + return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } - async copyByPath(filePath: string, executionId: string): Promise { - const binaryDataId = this.generateFileName(executionId); - await this.copyFileToLocalStorage(filePath, binaryDataId); - return binaryDataId; + async store(binaryData: Buffer | Readable, executionId: string) { + const identifier = this.createIdentifier(executionId); + const filePath = this.getPath(identifier); + + await fs.writeFile(filePath, binaryData); + + return identifier; } - async copyByIdentifier(identifier: string, prefix: string): Promise { - const newBinaryDataId = this.generateFileName(prefix); + async deleteOne(identifier: string) { + const filePath = this.getPath(identifier); - await fs.copyFile( - this.resolveStoragePath(identifier), - this.resolveStoragePath(newBinaryDataId), - ); - return newBinaryDataId; + return fs.rm(filePath); } - async deleteManyByExecutionIds(executionIds: string[]): Promise { + async deleteManyByExecutionIds(executionIds: string[]) { const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); const deletedIds = []; + for (const fileName of fileNames) { - const executionId = fileName.match(executionExtractionRegexp)?.[1]; + const executionId = fileName.match(EXECUTION_EXTRACTOR)?.[1]; + if (executionId && set.has(executionId)) { - const filePath = this.resolveStoragePath(fileName); + const filePath = this.resolvePath(fileName); + await Promise.all([fs.rm(filePath), fs.rm(`${filePath}.metadata`)]); + deletedIds.push(executionId); } } + return deletedIds; } - async deleteOne(identifier: string): Promise { - return this.deleteFromLocalStorage(identifier); - } + async copyByPath(filePath: string, executionId: string) { + const identifier = this.createIdentifier(executionId); - private async assertFolder(folder: string): Promise { - try { - await fs.access(folder); - } catch { - await fs.mkdir(folder, { recursive: true }); - } - } + await fs.cp(filePath, this.getPath(identifier)); - private generateFileName(prefix: string): string { - return [prefix, uuid()].join(''); + return identifier; } - private async deleteFromLocalStorage(identifier: string) { - return fs.rm(this.getPath(identifier)); - } + async copyByIdentifier(identifier: string, executionId: string) { + const newIdentifier = this.createIdentifier(executionId); - private async copyFileToLocalStorage(source: string, identifier: string): Promise { - await fs.cp(source, this.getPath(identifier)); - } + await fs.copyFile(this.resolvePath(identifier), this.resolvePath(newIdentifier)); - private async saveToLocalStorage(binaryData: Buffer | Readable, identifier: string) { - await fs.writeFile(this.getPath(identifier), binaryData); + return newIdentifier; } - private async retrieveFromLocalStorage(identifier: string): Promise { - const filePath = this.getPath(identifier); + // ---------------------------------- + // private methods + // ---------------------------------- + + private async ensureDirExists(dir: string) { try { - return await fs.readFile(filePath); - } catch (e) { - throw new Error(`Error finding file: ${filePath}`); + await fs.access(dir); + } catch { + await fs.mkdir(dir, { recursive: true }); } } - private resolveStoragePath(...args: string[]) { + private createIdentifier(executionId: string) { + return [executionId, uuid()].join(''); + } + + // @TODO: Variadic needed? + private resolvePath(...args: string[]) { const returnPath = path.join(this.storagePath, ...args); + if (path.relative(this.storagePath, returnPath).startsWith('..')) { throw new FileNotFoundError('Invalid path detected'); } + return returnPath; } } diff --git a/packages/core/src/binaryData/s3.client.ts b/packages/core/src/binaryData/s3.client.ts new file mode 100644 index 0000000000000..de69464d2afd4 --- /dev/null +++ b/packages/core/src/binaryData/s3.client.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import type { BinaryMetadata } from 'n8n-workflow'; +import type { Readable } from 'stream'; +import type { BinaryData } from './types'; + +export class S3Client implements BinaryData.Client { + async init() { + throw new Error('TODO'); + } + + async store(binaryData: Buffer | Readable, executionId: string): Promise { + throw new Error('TODO'); + } + + getPath(identifier: string): string { + throw new Error('TODO'); + } + + async getSize(path: string): Promise { + throw new Error('TODO'); + } + + async getAsBuffer(identifier: string): Promise { + throw new Error('TODO'); + } + + getAsStream(identifier: string, chunkSize?: number): Readable { + throw new Error('TODO'); + } + + async storeMetadata(identifier: string, metadata: BinaryMetadata): Promise { + throw new Error('TODO'); + } + + async getMetadata(identifier: string): Promise { + throw new Error('TODO'); + } + + async copyByPath(path: string, executionId: string): Promise { + throw new Error('TODO'); + } + + async copyByIdentifier(identifier: string, executionId: string): Promise { + throw new Error('TODO'); + } + + async deleteOne(identifier: string): Promise { + throw new Error('TODO'); + } + + async deleteManyByExecutionIds(executionIds: string[]): Promise { + throw new Error('TODO'); + } +} diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/binaryData/types.ts index 0a914aa3d33d8..41fd65457d28b 100644 --- a/packages/core/src/binaryData/types.ts +++ b/packages/core/src/binaryData/types.ts @@ -26,14 +26,13 @@ export namespace BinaryData { store(binaryData: Buffer | Readable, executionId: string): Promise; getPath(identifier: string): string; - getSize(path: string): Promise; + getSize(path: string): Promise; // @TODO: Refactor to use identifier? + getAsBuffer(identifier: string): Promise; + getAsStream(identifier: string, chunkSize?: number): Readable; storeMetadata(identifier: string, metadata: BinaryMetadata): Promise; getMetadata(identifier: string): Promise; - toBuffer(identifier: string): Promise; - toStream(identifier: string, chunkSize?: number): Readable; - copyByPath(path: string, executionId: string): Promise; copyByIdentifier(identifier: string, prefix: string): Promise; From a2528d707a6f4922720b601a2dadd2c05aa0ee3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:38:09 +0200 Subject: [PATCH 041/259] Fix test --- .../nodes/HttpRequest/test/node/HttpRequest.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequest.test.ts b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequest.test.ts index 9be90520ca86d..49617225e96f6 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequest.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequest.test.ts @@ -1,4 +1,10 @@ -import { setup, equalityTest, workflowToTests, getWorkflowFilenames } from '@test/nodes/Helpers'; +import { + initBinaryDataService, + setup, + equalityTest, + workflowToTests, + getWorkflowFilenames, +} from '@test/nodes/Helpers'; import nock from 'nock'; @@ -8,7 +14,8 @@ describe('Test HTTP Request Node', () => { const baseUrl = 'https://dummyjson.com'; - beforeAll(() => { + beforeAll(async () => { + await initBinaryDataService(); nock.disableNetConnect(); //GET From d0cd85e37e54eed747a7fa7fbc2ccced8f31f53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:48:48 +0200 Subject: [PATCH 042/259] Better naming --- packages/cli/src/Server.ts | 4 +-- packages/cli/src/WebhookHelpers.ts | 4 +-- .../repositories/execution.repository.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 8 +++--- .../core/src/binaryData/binaryData.service.ts | 26 ++++++++++++------- packages/core/src/binaryData/fs.client.ts | 2 +- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 22c685214e8ae..a69dbc1c7b1be 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1425,11 +1425,11 @@ export class Server extends AbstractServer { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; try { - const binaryPath = this.binaryDataService.getBinaryPath(identifier); + const binaryPath = this.binaryDataService.getPath(identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getBinaryMetadata(identifier); + const metadata = await this.binaryDataService.getMetadata(identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index fcefd8dc9741c..ea052f507ec18 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = Container.get(BinaryDataService).getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -732,7 +732,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = Container.get(BinaryDataService).getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 2b5b3c24725fb..1c243276ed13b 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -490,7 +490,7 @@ export class ExecutionRepository extends Repository { }) ).map(({ id }) => id); - await this.binaryDataService.deleteBinaryDataByExecutionIds(executionIds); + await this.binaryDataService.deleteManyByExecutionIds(executionIds); // Actually delete these executions await this.delete({ id: In(executionIds) }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 38a2118a0504c..cae93405729b8 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -864,21 +864,21 @@ async function httpRequest( } export function getBinaryPath(binaryDataId: string): string { - return Container.get(BinaryDataService).getBinaryPath(binaryDataId); + return Container.get(BinaryDataService).getPath(binaryDataId); } /** * Returns binary file metadata */ export async function getBinaryMetadata(binaryDataId: string): Promise { - return Container.get(BinaryDataService).getBinaryMetadata(binaryDataId); + return Container.get(BinaryDataService).getMetadata(binaryDataId); } /** * Returns binary file stream for piping */ export function getBinaryStream(binaryDataId: string, chunkSize?: number): Readable { - return Container.get(BinaryDataService).getBinaryStream(binaryDataId, chunkSize); + return Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); } export function assertBinaryData( @@ -931,7 +931,7 @@ export async function setBinaryDataBuffer( binaryData: Buffer | Readable, executionId: string, ): Promise { - return Container.get(BinaryDataService).storeBinaryData(data, binaryData, executionId); + return Container.get(BinaryDataService).store(data, binaryData, executionId); } export async function copyBinaryFile( diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 38b44b5ee5b96..7fc6b6b609b64 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -37,7 +37,7 @@ export class BinaryDataService { if (client) { const identifier = await client.copyByPath(path, executionId); // Add client reference id. - binaryData.id = this.generateBinaryId(identifier); + binaryData.id = this.createIdentifier(identifier); // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; @@ -59,14 +59,14 @@ export class BinaryDataService { return binaryData; } - async storeBinaryData(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { + async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { // If a client handles this binary, return the binary data with its reference id. const client = this.clients[this.mode]; if (client) { const identifier = await client.store(input, executionId); // Add client reference id. - binaryData.id = this.generateBinaryId(identifier); + binaryData.id = this.createIdentifier(identifier); // Prevent preserving data in memory if handled by a client. binaryData.data = this.mode; @@ -95,8 +95,9 @@ export class BinaryDataService { }); } - getBinaryStream(identifier: string, chunkSize?: number) { + getAsStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); + if (this.clients[mode]) { return this.clients[mode].getAsStream(id, chunkSize); } @@ -114,6 +115,7 @@ export class BinaryDataService { async retrieveBinaryDataByIdentifier(identifier: string): Promise { const { mode, id } = this.splitBinaryModeFileId(identifier); + if (this.clients[mode]) { return this.clients[mode].getAsBuffer(id); } @@ -121,8 +123,9 @@ export class BinaryDataService { throw new Error('Storage mode used to store binary data not available'); } - getBinaryPath(identifier: string) { + getPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); + if (this.clients[mode]) { return this.clients[mode].getPath(id); } @@ -130,7 +133,7 @@ export class BinaryDataService { throw new Error('Storage mode used to store binary data not available'); } - async getBinaryMetadata(identifier: string) { + async getMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); if (this.clients[mode]) { return this.clients[mode].getMetadata(id); @@ -139,7 +142,7 @@ export class BinaryDataService { throw new Error('Storage mode used to store binary data not available'); } - async deleteBinaryDataByExecutionIds(executionIds: string[]) { + async deleteManyByExecutionIds(executionIds: string[]) { const client = this.clients[this.mode]; if (client) { await client.deleteManyByExecutionIds(executionIds); @@ -172,12 +175,17 @@ export class BinaryDataService { return inputData as INodeExecutionData[][]; } - private generateBinaryId(filename: string) { + // ---------------------------------- + // private methods + // ---------------------------------- + + private createIdentifier(filename: string) { return `${this.mode}:${filename}`; } private splitBinaryModeFileId(fileId: string): { mode: string; id: string } { const [mode, id] = fileId.split(':'); + return { mode, id }; } @@ -202,7 +210,7 @@ export class BinaryDataService { return client ?.copyByIdentifier(this.splitBinaryModeFileId(binaryDataId).id, executionId) .then((filename) => ({ - newId: this.generateBinaryId(filename), + newId: this.createIdentifier(filename), key, })); }); diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index f849b2490f744..aeb0d26fd2294 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -110,7 +110,7 @@ export class FileSystemClient implements BinaryData.Client { } // ---------------------------------- - // private methods + // private methods // ---------------------------------- private async ensureDirExists(dir: string) { From bfdbdf5c37160f3dbd8b17ff3ddbc1f5912edacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:50:54 +0200 Subject: [PATCH 043/259] More renamings --- packages/cli/src/commands/BaseCommand.ts | 2 +- packages/cli/src/commands/execute.ts | 2 +- packages/cli/src/commands/executeBatch.ts | 2 +- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/commands/webhook.ts | 2 +- packages/cli/src/commands/worker.ts | 2 +- packages/cli/test/integration/publicApi/executions.test.ts | 2 +- packages/cli/test/integration/shared/utils/index.ts | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index ebb4d2c51a5c0..9950af15c7e36 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -103,7 +103,7 @@ export abstract class BaseCommand extends Command { process.exit(1); } - protected async initBinaryManager() { + protected async initBinaryDataService() { const binaryDataConfig = config.getEnv('binaryDataService'); await Container.get(BinaryDataService).init(binaryDataConfig, true); } diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index c708e664decbe..f50d6c7f53d51 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -33,7 +33,7 @@ export class Execute extends BaseCommand { async init() { await super.init(); - await this.initBinaryManager(); + await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index 4822747478da2..a20348c92e5da 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -180,7 +180,7 @@ export class ExecuteBatch extends BaseCommand { async init() { await super.init(); - await this.initBinaryManager(); + await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 15000ab56ae2d..371e3cf35e736 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -198,7 +198,7 @@ export class Start extends BaseCommand { this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner); await this.initLicense(); - await this.initBinaryManager(); + await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 7d5bf4630232e..a4b7fe5ae0752 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -78,7 +78,7 @@ export class Webhook extends BaseCommand { await super.init(); await this.initLicense(); - await this.initBinaryManager(); + await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); } diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index dfaf0e34c0c62..ad8691b3a63a2 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -237,7 +237,7 @@ export class Worker extends BaseCommand { this.logger.debug('Starting n8n worker...'); await this.initLicense(); - await this.initBinaryManager(); + await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); } diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index cb8ae69c41fd0..0eece7838d851 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -24,7 +24,7 @@ beforeAll(async () => { user2 = await testDb.createUser({ globalRole: globalUserRole, apiKey: randomApiKey() }); // TODO: mock BinaryDataService instead - await utils.initBinaryManager(); + await utils.initBinaryDataService(); await utils.initNodeTypes(); workflowRunner = await utils.initActiveWorkflowRunner(); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 039e2ee7ce198..96594b533a3b6 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -385,9 +385,9 @@ export async function initNodeTypes() { } /** - * Initialize a BinaryManager for test runs. + * Initialize a BinaryDataService for test runs. */ -export async function initBinaryManager() { +export async function initBinaryDataService() { const binaryDataConfig = config.getEnv('binaryDataService'); const binaryDataService = new BinaryDataService(); From 25e80b90c0a0fe0a68aa8752b0c97a8727dd741c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 19:54:40 +0200 Subject: [PATCH 044/259] Minor cleanup --- .../core/src/binaryData/binaryData.service.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 7fc6b6b609b64..e6980c85961b5 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -98,27 +98,25 @@ export class BinaryDataService { getAsStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.clients[mode]) { - return this.clients[mode].getAsStream(id, chunkSize); - } + const client = this.clients[mode]; + + if (client) return client.getAsStream(id, chunkSize); throw new Error('Storage mode used to store binary data not available'); } async getBinaryDataBuffer(binaryData: IBinaryData) { - if (binaryData.id) { - return this.retrieveBinaryDataByIdentifier(binaryData.id); - } + if (binaryData.id) return this.retrieveBinaryDataByIdentifier(binaryData.id); return Buffer.from(binaryData.data, BINARY_ENCODING); } - async retrieveBinaryDataByIdentifier(identifier: string): Promise { + async retrieveBinaryDataByIdentifier(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.clients[mode]) { - return this.clients[mode].getAsBuffer(id); - } + const client = this.clients[mode]; + + if (client) return client.getAsBuffer(id); throw new Error('Storage mode used to store binary data not available'); } @@ -126,27 +124,27 @@ export class BinaryDataService { getPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.clients[mode]) { - return this.clients[mode].getPath(id); - } + const client = this.clients[mode]; + + if (client) return client.getPath(id); throw new Error('Storage mode used to store binary data not available'); } async getMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - if (this.clients[mode]) { - return this.clients[mode].getMetadata(id); - } + + const client = this.clients[mode]; + + if (client) return client.getMetadata(id); throw new Error('Storage mode used to store binary data not available'); } async deleteManyByExecutionIds(executionIds: string[]) { const client = this.clients[this.mode]; - if (client) { - await client.deleteManyByExecutionIds(executionIds); - } + + if (client) await client.deleteManyByExecutionIds(executionIds); } async duplicateBinaryData(inputData: Array, executionId: string) { From 3476f6c9ae02674027eb837c6b519b7984c62e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 20:07:10 +0200 Subject: [PATCH 045/259] More minor cleanup --- .../core/src/binaryData/binaryData.service.ts | 116 ++++++++---------- packages/core/src/binaryData/fs.client.ts | 1 + 2 files changed, 53 insertions(+), 64 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index e6980c85961b5..9a43b5495923c 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -1,12 +1,14 @@ import { readFile, stat } from 'fs/promises'; -import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; +import concatStream from 'concat-stream'; import prettyBytes from 'pretty-bytes'; -import type { Readable } from 'stream'; +import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; -import type { BinaryData } from './types'; + import { FileSystemClient } from './fs.client'; -import { Service } from 'typedi'; -import concatStream from 'concat-stream'; + +import type { Readable } from 'stream'; +import type { BinaryData } from './types'; +import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; @Service() export class BinaryDataService { @@ -31,60 +33,56 @@ export class BinaryDataService { } async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { - // If a client handles this binary, copy over the binary file and return its reference id. const client = this.clients[this.mode]; - if (client) { - const identifier = await client.copyByPath(path, executionId); - // Add client reference id. - binaryData.id = this.createIdentifier(identifier); - - // Prevent preserving data in memory if handled by a client. - binaryData.data = this.mode; - - const fileSize = await client.getSize(identifier); - binaryData.fileSize = prettyBytes(fileSize); - - await client.storeMetadata(identifier, { - fileName: binaryData.fileName, - mimeType: binaryData.mimeType, - fileSize, - }); - } else { + if (!client) { const { size } = await stat(path); binaryData.fileSize = prettyBytes(size); binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); + + return binaryData; } + const identifier = await client.copyByPath(path, executionId); + binaryData.id = this.createIdentifier(identifier); + binaryData.data = this.mode; // clear from memory + + const fileSize = await client.getSize(identifier); + binaryData.fileSize = prettyBytes(fileSize); + + await client.storeMetadata(identifier, { + fileName: binaryData.fileName, + mimeType: binaryData.mimeType, + fileSize, + }); + return binaryData; } async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { - // If a client handles this binary, return the binary data with its reference id. const client = this.clients[this.mode]; - if (client) { - const identifier = await client.store(input, executionId); - - // Add client reference id. - binaryData.id = this.createIdentifier(identifier); - // Prevent preserving data in memory if handled by a client. - binaryData.data = this.mode; - - const fileSize = await client.getSize(identifier); - binaryData.fileSize = prettyBytes(fileSize); - - await client.storeMetadata(identifier, { - fileName: binaryData.fileName, - mimeType: binaryData.mimeType, - fileSize, - }); - } else { + if (!client) { const buffer = await this.binaryToBuffer(input); binaryData.data = buffer.toString(BINARY_ENCODING); binaryData.fileSize = prettyBytes(buffer.length); + + return binaryData; } + const identifier = await client.store(input, executionId); + binaryData.id = this.createIdentifier(identifier); + binaryData.data = this.mode; // clear from memory + + const fileSize = await client.getSize(identifier); + binaryData.fileSize = prettyBytes(fileSize); + + await client.storeMetadata(identifier, { + fileName: binaryData.fileName, + mimeType: binaryData.mimeType, + fileSize, + }); + return binaryData; } @@ -98,11 +96,7 @@ export class BinaryDataService { getAsStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); - const client = this.clients[mode]; - - if (client) return client.getAsStream(id, chunkSize); - - throw new Error('Storage mode used to store binary data not available'); + return this.getClient(mode).getAsStream(id, chunkSize); } async getBinaryDataBuffer(binaryData: IBinaryData) { @@ -114,37 +108,23 @@ export class BinaryDataService { async retrieveBinaryDataByIdentifier(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - const client = this.clients[mode]; - - if (client) return client.getAsBuffer(id); - - throw new Error('Storage mode used to store binary data not available'); + return this.getClient(mode).getAsBuffer(id); } getPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - const client = this.clients[mode]; - - if (client) return client.getPath(id); - - throw new Error('Storage mode used to store binary data not available'); + return this.getClient(mode).getPath(id); } async getMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - const client = this.clients[mode]; - - if (client) return client.getMetadata(id); - - throw new Error('Storage mode used to store binary data not available'); + return this.getClient(mode).getMetadata(id); } async deleteManyByExecutionIds(executionIds: string[]) { - const client = this.clients[this.mode]; - - if (client) await client.deleteManyByExecutionIds(executionIds); + await this.getClient(this.mode).deleteManyByExecutionIds(executionIds); } async duplicateBinaryData(inputData: Array, executionId: string) { @@ -226,4 +206,12 @@ export class BinaryDataService { return executionData; } + + private getClient(mode: string) { + const client = this.clients[mode]; + + if (!client) throw new Error('This method is not supported by in-memory storage mode'); + + return client; + } } diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index aeb0d26fd2294..7a9b052991fee 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -3,6 +3,7 @@ import fs from 'fs/promises'; import path from 'path'; import { v4 as uuid } from 'uuid'; import { jsonParse } from 'n8n-workflow'; + import { FileNotFoundError } from '../errors'; import type { Readable } from 'stream'; From 9a25301ee96535d1eed8b5223bc5f0af56e7e3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Sep 2023 20:12:03 +0200 Subject: [PATCH 046/259] Add clarifying comment --- packages/cli/src/constants.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index ec66cbdf32062..c94af1f5c64b1 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -95,6 +95,9 @@ export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a export const UM_FIX_INSTRUCTION = 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; +/** + * Units of time in milliseconds + */ export const TIME = { SECOND: 1000, MINUTE: 60 * 1000, From 0e075c257d26f5f34fb722fdd2f5120a96f09023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 10:51:50 +0200 Subject: [PATCH 047/259] Speed up pruning if high volume --- .../repositories/execution.repository.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 6cd7561026b3a..6998d9292cff7 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -78,6 +78,8 @@ function parseFiltersToQueryBuilder( export class ExecutionRepository extends Repository { deletionBatchSize = 100; + hardDeletionInterval: NodeJS.Timer | null = null; + constructor( dataSource: DataSource, private readonly executionDataRepository: ExecutionDataRepository, @@ -88,7 +90,15 @@ export class ExecutionRepository extends Repository { setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); } - setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); + this.setHardDeletionInterval(); + } + + setHardDeletionInterval() { + this.hardDeletionInterval = setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); + } + + clearHardDeletionInterval() { + if (this.hardDeletionInterval) clearInterval(this.hardDeletionInterval); } async findMultipleExecutions( @@ -495,8 +505,22 @@ export class ExecutionRepository extends Repository { // Actually delete these executions await this.delete({ id: In(executionIds) }); + /** + * If the volume of executions to prune is as high as the batch size, there is a risk + * that the pruning process is unable to catch up to the creation of new executions, + * with high concurrency possibly leading to errors from duplicate deletions. + * + * Therefore, in this high-volume case we speed up the hard deletion cycle, until + * the number of executions to prune is low enough to fit in a single batch. + */ if (executionIds.length === this.deletionBatchSize) { - setTimeout(async () => this.hardDelete(), 1000); + this.clearHardDeletionInterval(); + + setTimeout(async () => this.hardDelete(), 1 * TIME.SECOND); + } else { + if (this.hardDeletionInterval) return; + + this.setHardDeletionInterval(); } } } From 2071c7fb677b8e4818356d55d095e056704f8573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 11:08:04 +0200 Subject: [PATCH 048/259] Remove call from hook --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 193acce3a53aa..6ed7d4bb0d38f 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -599,11 +599,6 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); try { - // Prune old execution data - if (config.getEnv('executions.pruneData')) { - await pruneExecutionData.call(this); - } - if (isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { From 1d50b61f89622f0d48889c6237fb50ea8938126a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 15:37:06 +0200 Subject: [PATCH 049/259] Refix conflict --- packages/cli/src/WebhookHelpers.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 2c9bf77ab4b8d..703e4b63b800d 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -734,7 +734,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = BinaryDataManager.getInstance().getBinaryStream(binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); @@ -756,17 +756,9 @@ export async function executeWebhook( } if (!didSendResponse) { - // Send the webhook response manually - res.setHeader('Content-Type', binaryData.mimeType); - if (binaryData.id) { - const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); - await pipeline(stream, res); - } else { - res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); - } - responseCallback(null, { - noWebhookResponse: true, + data, + responseCode, }); } } From e042e91eaefad4008859353a543e426cc4160cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 17:09:10 +0200 Subject: [PATCH 050/259] Add formats to schema --- packages/cli/src/config/schema.ts | 37 +++++++++++++------------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index fbca60deefaad..0ff74c7207fb2 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -2,28 +2,21 @@ import path from 'path'; import convict from 'convict'; import { UserSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; +import { ensureStringArray } from './utils'; convict.addFormat({ - name: 'nodes-list', - // @ts-ignore - validate(values: string[], { env }: { env: string }): void { - try { - if (!Array.isArray(values)) { - throw new Error(); - } + name: 'json-string-array', + coerce: (rawStr: string) => + jsonParse(rawStr, { + errorMessage: `Expected this value "${rawStr}" to be valid JSON`, + }), + validate: ensureStringArray, +}); - for (const value of values) { - if (typeof value !== 'string') { - throw new Error(); - } - } - } catch (error) { - throw new TypeError(`${env} is not a valid Array of strings.`); - } - }, - coerce(rawValue: string): string[] { - return jsonParse(rawValue, { errorMessage: 'nodes-list needs to be valid JSON' }); - }, +convict.addFormat({ + name: 'comma-separated-list', + coerce: (rawStr: string) => rawStr.split(','), + validate: ensureStringArray, }); export const schema = { @@ -782,13 +775,13 @@ export const schema = { nodes: { include: { doc: 'Nodes to load', - format: 'nodes-list', + format: 'json-string-array', default: undefined, env: 'NODES_INCLUDE', }, exclude: { doc: 'Nodes not to load', - format: 'nodes-list', + format: 'json-string-array', default: undefined, env: 'NODES_EXCLUDE', }, @@ -896,7 +889,7 @@ export const schema = { binaryDataService: { availableModes: { - format: String, + format: 'comma-separated-list', default: 'filesystem,object', env: 'N8N_AVAILABLE_BINARY_DATA_MODES', doc: 'Available modes of binary data storage, as comma separated strings', From e948f8f2c2e9a36ce336e2dc19087e00fb90bbe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 17:09:20 +0200 Subject: [PATCH 051/259] Add `ensureStringArray` util --- packages/cli/src/config/utils.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/cli/src/config/utils.ts diff --git a/packages/cli/src/config/utils.ts b/packages/cli/src/config/utils.ts new file mode 100644 index 0000000000000..3432e3a78242c --- /dev/null +++ b/packages/cli/src/config/utils.ts @@ -0,0 +1,18 @@ +import type { SchemaObj } from 'convict'; + +class NotStringArrayError extends Error { + constructor(env: string) { + super(); + this.message = `${env} is not a string array.`; + } +} + +export const ensureStringArray = (values: string[], { env }: SchemaObj) => { + if (!env) throw new Error(`Missing env: ${env}`); + + if (!Array.isArray(values)) throw new NotStringArrayError(env); + + for (const value of values) { + if (typeof value !== 'string') throw new NotStringArrayError(env); + } +}; From 0feb926334c452eca8700531c6bc2646c70eea6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 17:09:28 +0200 Subject: [PATCH 052/259] Adjust types based on parsing --- packages/core/src/binaryData/types.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/binaryData/types.ts index 41fd65457d28b..ce4185ff66456 100644 --- a/packages/core/src/binaryData/types.ts +++ b/packages/core/src/binaryData/types.ts @@ -1,18 +1,13 @@ import type { Readable } from 'stream'; import type { BinaryMetadata } from 'n8n-workflow'; +import type { BINARY_DATA_MODES } from './utils'; export namespace BinaryData { - /** - * Mode for storing binary data: - * - `default` (in memory) - * - `filesystem` (on disk) - * - `object` (S3) - */ - export type Mode = 'default' | 'filesystem' | 'object'; + export type Mode = (typeof BINARY_DATA_MODES)[number]; type ConfigBase = { mode: Mode; - availableModes: string; // comma-separated list + availableModes: string[]; }; type InMemoryConfig = ConfigBase & { mode: 'default' }; From c73f18c2e2dbac85c9ba4b90dc609c1a8dcd4ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 17:09:37 +0200 Subject: [PATCH 053/259] Add type guard --- packages/core/src/binaryData/utils.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/core/src/binaryData/utils.ts diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/binaryData/utils.ts new file mode 100644 index 0000000000000..a3dd1f5ab640a --- /dev/null +++ b/packages/core/src/binaryData/utils.ts @@ -0,0 +1,15 @@ +import type { BinaryData } from './types'; + +export class InvalidModeError extends Error {} + +/** + * Modes for storing binary data: + * - `default` (in memory) + * - `filesystem` (on disk) + * - `object` (S3) + */ +export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; + +export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { + return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); +} From f8fec927b420048be1e389775df2fbeb3bb65f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 17:09:48 +0200 Subject: [PATCH 054/259] Clean up types in `init()` --- packages/core/src/binaryData/binaryData.service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 9a43b5495923c..875cea8ae9925 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -5,6 +5,7 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemClient } from './fs.client'; +import { InvalidModeError, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -19,13 +20,14 @@ export class BinaryDataService { private clients: Record = {}; async init(config: BinaryData.Config, mainClient = false) { - this.availableModes = config.availableModes.split(',') as BinaryData.Mode[]; // @TODO: Remove assertion + if (!areValidModes(config.availableModes)) throw new InvalidModeError(); + + this.availableModes = config.availableModes; this.mode = config.mode; - if (this.availableModes.includes('filesystem')) { - this.clients.filesystem = new FileSystemClient( - (config as BinaryData.FileSystemConfig).storagePath, - ); // @TODO: Remove assertion + if (this.availableModes.includes('filesystem') && config.mode === 'filesystem') { + this.clients.filesystem = new FileSystemClient(config.storagePath); + await this.clients.filesystem.init(mainClient); } From 93ea57ceb60a02fe3eebbc5f69d2a9335ffec4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 18:42:02 +0200 Subject: [PATCH 055/259] Fix test --- packages/core/test/NodeExecuteFunctions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 7ea8dbc289724..0f5d42ed6e4ce 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -30,7 +30,7 @@ describe('NodeExecuteFunctions', () => { await Container.get(BinaryDataService).init({ mode: 'default', - availableModes: 'default', + availableModes: ['default'], }); // Set our binary data buffer @@ -80,7 +80,7 @@ describe('NodeExecuteFunctions', () => { // Setup a 'filesystem' binary data manager instance await Container.get(BinaryDataService).init({ mode: 'filesystem', - availableModes: 'filesystem', + availableModes: ['filesystem'], storagePath: temporaryDir, }); From c8676befdb043fffa38b00a0480242f67dd40d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 18:46:12 +0200 Subject: [PATCH 056/259] Remove unneeded modifier --- packages/cli/src/commands/BaseCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 8f29db79505e3..cf3a7bbb2028f 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -103,7 +103,7 @@ export abstract class BaseCommand extends Command { process.exit(1); } - protected async initBinaryDataService() { + async initBinaryDataService() { const binaryDataConfig = config.getEnv('binaryDataService'); await Container.get(BinaryDataService).init(binaryDataConfig, true); } From 25006e8899e046ee5c6af81cf5779a2131791d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 18:50:02 +0200 Subject: [PATCH 057/259] Cleanup --- packages/cli/test/integration/shared/utils/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index b2f51fca22771..c4412119b6416 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -75,13 +75,11 @@ export async function initNodeTypes() { * Initialize a BinaryDataService for test runs. */ export async function initBinaryDataService() { - const binaryDataConfig = config.getEnv('binaryDataService'); - const binaryDataService = new BinaryDataService(); - await binaryDataService.init(binaryDataConfig); - Container.set(BinaryDataService, binaryDataService); - await binaryDataService.init(binaryDataConfig); + await binaryDataService.init(config.getEnv('binaryDataService')); + + Container.set(BinaryDataService, binaryDataService); } /** From a7156f845e8ee92fd12ec5f800134accf5e8e816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:02:56 +0200 Subject: [PATCH 058/259] Fix node tests --- packages/nodes-base/test/nodes/Helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 7973221217569..6735223e3fdb3 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -219,7 +219,7 @@ export function createTemporaryDir(prefix = 'n8n') { export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { const binaryDataService = new BinaryDataService(); - await binaryDataService.init({ mode: 'default', availableModes: mode }); + await binaryDataService.init({ mode: 'default', availableModes: [mode] }); Container.set(BinaryDataService, binaryDataService); } From e0e90b6b36446755db8a8f6102ba82ac88e4d24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:03:45 +0200 Subject: [PATCH 059/259] Remove unneeded line --- packages/core/src/binaryData/binaryData.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 875cea8ae9925..5fc543c3bc9cc 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -30,8 +30,6 @@ export class BinaryDataService { await this.clients.filesystem.init(mainClient); } - - return undefined; } async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { From 219addba31d1bb02df000af11fe6d1906e8950d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:12:08 +0200 Subject: [PATCH 060/259] Rename constant --- packages/core/src/binaryData/fs.client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index 7a9b052991fee..9914aa18fd2d0 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -10,7 +10,7 @@ import type { Readable } from 'stream'; import type { BinaryMetadata } from 'n8n-workflow'; import type { BinaryData } from './types'; -const EXECUTION_EXTRACTOR = +const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; export class FileSystemClient implements BinaryData.Client { @@ -80,7 +80,7 @@ export class FileSystemClient implements BinaryData.Client { const deletedIds = []; for (const fileName of fileNames) { - const executionId = fileName.match(EXECUTION_EXTRACTOR)?.[1]; + const executionId = fileName.match(EXECUTION_ID_EXTRACTOR)?.[1]; if (executionId && set.has(executionId)) { const filePath = this.resolvePath(fileName); From d7c7f5fb5145ee4232bdfc79767bfc74fafb86ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:12:45 +0200 Subject: [PATCH 061/259] Remove comment --- packages/core/src/binaryData/fs.client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/binaryData/fs.client.ts index 9914aa18fd2d0..36d2f476be3ef 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/binaryData/fs.client.ts @@ -126,7 +126,6 @@ export class FileSystemClient implements BinaryData.Client { return [executionId, uuid()].join(''); } - // @TODO: Variadic needed? private resolvePath(...args: string[]) { const returnPath = path.join(this.storagePath, ...args); From 1ea380debf77cb74be2d4807b65091f8e13d2435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:21:31 +0200 Subject: [PATCH 062/259] Fix tests --- packages/cli/test/integration/commands/worker.cmd.test.ts | 8 ++++---- .../nodes/HttpRequest/test/binaryData/HttpRequest.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index d860579ee392b..2d9191c124db7 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -5,7 +5,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { Telemetry } from '@/telemetry'; import { getLogger } from '@/Logger'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; -import { BinaryDataManager } from 'n8n-core'; +import { BinaryDataService } from 'n8n-core'; import { CacheService } from '@/services/cache.service'; import { RedisServicePubSubPublisher } from '@/services/redis/RedisServicePubSubPublisher'; import { RedisServicePubSubSubscriber } from '@/services/redis/RedisServicePubSubSubscriber'; @@ -26,7 +26,7 @@ beforeAll(async () => { mockInstance(InternalHooks); mockInstance(CacheService); mockInstance(ExternalSecretsManager); - mockInstance(BinaryDataManager); + mockInstance(BinaryDataService); mockInstance(MessageEventBus); mockInstance(LoadNodesAndCredentials); mockInstance(CredentialTypes); @@ -41,7 +41,7 @@ test('worker initializes all its components', async () => { jest.spyOn(worker, 'init'); jest.spyOn(worker, 'initLicense').mockImplementation(async () => {}); - jest.spyOn(worker, 'initBinaryManager').mockImplementation(async () => {}); + jest.spyOn(worker, 'initBinaryDataService').mockImplementation(async () => {}); jest.spyOn(worker, 'initExternalHooks').mockImplementation(async () => {}); jest.spyOn(worker, 'initExternalSecrets').mockImplementation(async () => {}); jest.spyOn(worker, 'initEventBus').mockImplementation(async () => {}); @@ -64,7 +64,7 @@ test('worker initializes all its components', async () => { expect(worker.uniqueInstanceId).toContain('worker'); expect(worker.uniqueInstanceId.length).toBeGreaterThan(15); expect(worker.initLicense).toHaveBeenCalled(); - expect(worker.initBinaryManager).toHaveBeenCalled(); + expect(worker.initBinaryDataService).toHaveBeenCalled(); expect(worker.initExternalHooks).toHaveBeenCalled(); expect(worker.initExternalSecrets).toHaveBeenCalled(); expect(worker.initEventBus).toHaveBeenCalled(); diff --git a/packages/nodes-base/nodes/HttpRequest/test/binaryData/HttpRequest.test.ts b/packages/nodes-base/nodes/HttpRequest/test/binaryData/HttpRequest.test.ts index c539f4bc44273..4ef4a1720189a 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/binaryData/HttpRequest.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/binaryData/HttpRequest.test.ts @@ -4,7 +4,7 @@ import { equalityTest, workflowToTests, getWorkflowFilenames, - initBinaryDataManager, + initBinaryDataService, } from '@test/nodes/Helpers'; describe('Test Binary Data Download', () => { @@ -14,7 +14,7 @@ describe('Test Binary Data Download', () => { const baseUrl = 'https://dummy.domain'; beforeAll(async () => { - await initBinaryDataManager(); + await initBinaryDataService(); nock.disableNetConnect(); From a5b388b80a899bbe881a8886dc5b77e33bba3196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:28:04 +0200 Subject: [PATCH 063/259] Cleanup --- packages/cli/src/config/utils.ts | 3 +-- packages/core/src/binaryData/utils.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/utils.ts b/packages/cli/src/config/utils.ts index 3432e3a78242c..4fee1a41108fd 100644 --- a/packages/cli/src/config/utils.ts +++ b/packages/cli/src/config/utils.ts @@ -2,8 +2,7 @@ import type { SchemaObj } from 'convict'; class NotStringArrayError extends Error { constructor(env: string) { - super(); - this.message = `${env} is not a string array.`; + super(`${env} is not a string array.`); } } diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/binaryData/utils.ts index a3dd1f5ab640a..ff48d9b8c0e81 100644 --- a/packages/core/src/binaryData/utils.ts +++ b/packages/core/src/binaryData/utils.ts @@ -1,6 +1,6 @@ import type { BinaryData } from './types'; -export class InvalidModeError extends Error {} +export class InvalidBinaryModeError extends Error {} /** * Modes for storing binary data: From 92047547c936a8b283a140551ef4d3d160501612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:32:12 +0200 Subject: [PATCH 064/259] Rename error --- packages/core/src/binaryData/binaryData.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 5fc543c3bc9cc..1800b86915548 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -5,7 +5,7 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemClient } from './fs.client'; -import { InvalidModeError, areValidModes } from './utils'; +import { InvalidBinaryModeError, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -20,7 +20,7 @@ export class BinaryDataService { private clients: Record = {}; async init(config: BinaryData.Config, mainClient = false) { - if (!areValidModes(config.availableModes)) throw new InvalidModeError(); + if (!areValidModes(config.availableModes)) throw new InvalidBinaryModeError(); this.availableModes = config.availableModes; this.mode = config.mode; From 9597508e2f574466fd4aef5229197f0480045362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:35:14 +0200 Subject: [PATCH 065/259] Improve error message --- packages/core/src/binaryData/binaryData.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/binaryData/binaryData.service.ts index 1800b86915548..78640461513cc 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/binaryData/binaryData.service.ts @@ -210,7 +210,7 @@ export class BinaryDataService { private getClient(mode: string) { const client = this.clients[mode]; - if (!client) throw new Error('This method is not supported by in-memory storage mode'); + if (!client) throw new Error('No binary data client found'); return client; } From 8fde8be2b3afc0757f8ef896a0727644a9044250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:38:14 +0200 Subject: [PATCH 066/259] Better error --- packages/core/src/binaryData/utils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/binaryData/utils.ts index ff48d9b8c0e81..2392aed312c0e 100644 --- a/packages/core/src/binaryData/utils.ts +++ b/packages/core/src/binaryData/utils.ts @@ -1,7 +1,5 @@ import type { BinaryData } from './types'; -export class InvalidBinaryModeError extends Error {} - /** * Modes for storing binary data: * - `default` (in memory) @@ -13,3 +11,12 @@ export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } + +export class InvalidBinaryModeError extends Error { + constructor() { + const validModes = BINARY_DATA_MODES.join(', '); + super( + `Invalid binary data mode. Set N8N_AVAILABLE_BINARY_DATA_MODES using only valid modes: ${validModes}`, + ); + } +} From ada012e1abbb7138356e7cd4dcba14ad20849e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Sep 2023 19:39:25 +0200 Subject: [PATCH 067/259] Better message --- packages/core/src/binaryData/utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/binaryData/utils.ts index 2392aed312c0e..3e59792469add 100644 --- a/packages/core/src/binaryData/utils.ts +++ b/packages/core/src/binaryData/utils.ts @@ -14,9 +14,6 @@ export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { export class InvalidBinaryModeError extends Error { constructor() { - const validModes = BINARY_DATA_MODES.join(', '); - super( - `Invalid binary data mode. Set N8N_AVAILABLE_BINARY_DATA_MODES using only valid modes: ${validModes}`, - ); + super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); } } From 67e163fb240f505de42248bcb8924fa69fd9d09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 Sep 2023 10:46:33 +0200 Subject: [PATCH 068/259] Make execution ID non-nullable --- packages/cli/src/Interfaces.ts | 2 +- .../PublicApi/v1/handlers/executions/executions.handler.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index d7718efa90b81..40d6a354c3dda 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -171,7 +171,7 @@ export type ICredentialsDecryptedResponse = ICredentialsDecryptedDb; export type SaveExecutionDataType = 'all' | 'none'; export interface IExecutionBase { - id?: string; + id: string; mode: WorkflowExecuteMode; startedAt: Date; stoppedAt?: Date; // empty value means execution is still running diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 90ed886bfa62a..a6b42c96ee38d 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -27,7 +27,7 @@ export = { // look for the execution on the workflow the user owns const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false); - if (!execution?.id) { + if (!execution) { return res.status(404).json({ message: 'Not Found' }); } @@ -103,7 +103,7 @@ export = { const executions = await getExecutions(filters); - const newLastId = !executions.length ? '0' : (executions.slice(-1)[0].id as string); + const newLastId = !executions.length ? '0' : executions.slice(-1)[0].id; filters.lastId = newLastId; From 12636dd7406ed1ecc11202e97d10828153708c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 Sep 2023 10:52:16 +0200 Subject: [PATCH 069/259] Readability improvements --- .../repositories/execution.repository.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 6998d9292cff7..a892e370cb096 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -86,19 +86,25 @@ export class ExecutionRepository extends Repository { ) { super(ExecutionEntity, dataSource.manager); - if (config.getEnv('executions.pruneData')) { - setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); - } + if (config.getEnv('executions.pruneData')) this.setPruningInterval(); this.setHardDeletionInterval(); } + setPruningInterval() { + setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); + } + setHardDeletionInterval() { + if (this.hardDeletionInterval) return; + this.hardDeletionInterval = setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); } clearHardDeletionInterval() { - if (this.hardDeletionInterval) clearInterval(this.hardDeletionInterval); + if (!this.hardDeletionInterval) return; + + clearInterval(this.hardDeletionInterval); } async findMultipleExecutions( @@ -518,8 +524,6 @@ export class ExecutionRepository extends Repository { setTimeout(async () => this.hardDelete(), 1 * TIME.SECOND); } else { - if (this.hardDeletionInterval) return; - this.setHardDeletionInterval(); } } From e5c8c7296ffce6df822cdb665e3a13475b0ceedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 Sep 2023 11:01:28 +0200 Subject: [PATCH 070/259] Adjust types, followup to 67e163f --- packages/cli/src/ActiveExecutions.ts | 3 ++- packages/cli/src/GenericHelpers.ts | 4 ++-- packages/cli/src/Interfaces.ts | 5 +++++ packages/cli/src/WorkflowExecuteAdditionalData.ts | 3 ++- packages/cli/src/WorkflowHelpers.ts | 8 ++++++-- .../src/databases/repositories/execution.repository.ts | 3 ++- .../executionLifecycleHooks/shared/sharedHookFunctions.ts | 6 +++--- 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index 26feeee589b28..f794335204773 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -12,6 +12,7 @@ import { createDeferredPromise, LoggerProxy } from 'n8n-workflow'; import type { ChildProcess } from 'child_process'; import type PCancelable from 'p-cancelable'; import type { + ExecutionPayload, IExecutingWorkflowData, IExecutionDb, IExecutionsCurrentSummary, @@ -38,7 +39,7 @@ export class ActiveExecutions { if (executionId === undefined) { // Is a new execution so save in DB - const fullExecutionData: IExecutionDb = { + const fullExecutionData: ExecutionPayload = { data: executionData.executionData!, mode: executionData.executionMode, finished: false, diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 0034ebea90074..ef9e82a9fdbd6 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -11,7 +11,7 @@ import { Container } from 'typedi'; import { Like } from 'typeorm'; import config from '@/config'; import * as Db from '@/Db'; -import type { ICredentialsDb, IExecutionDb, IWorkflowDb } from '@/Interfaces'; +import type { ExecutionPayload, ICredentialsDb, IWorkflowDb } from '@/Interfaces'; import * as ResponseHelper from '@/ResponseHelper'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; @@ -178,7 +178,7 @@ export async function createErrorExecution( }, }; - const fullExecutionData: IExecutionDb = { + const fullExecutionData: ExecutionPayload = { data: executionData, mode, finished: false, diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 40d6a354c3dda..3f373e9d9f750 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -189,6 +189,11 @@ export interface IExecutionDb extends IExecutionBase { workflowData?: IWorkflowBase; } +/** + * Payload for creating or updating an execution. + */ +export type ExecutionPayload = Omit; + export interface IExecutionPushResponse { executionId?: string; waitingForWebhook?: boolean; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 6ed7d4bb0d38f..9c2d309733dfd 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -47,6 +47,7 @@ import type { IWorkflowExecuteProcess, IWorkflowExecutionDataProcess, IWorkflowErrorData, + ExecutionPayload, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; @@ -885,7 +886,7 @@ async function executeWorkflow( // Therefore, database might not contain finished errors. // Force an update to db as there should be no harm doing this - const fullExecutionData: IExecutionDb = { + const fullExecutionData: ExecutionPayload = { data: fullRunData.data, mode: fullRunData.mode, finished: fullRunData.finished ? fullRunData.finished : false, diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index e1a6e7057f09c..093f713e4ef3b 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -23,7 +23,11 @@ import { } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import * as Db from '@/Db'; -import type { IExecutionDb, IWorkflowErrorData, IWorkflowExecutionDataProcess } from '@/Interfaces'; +import type { + ExecutionPayload, + IWorkflowErrorData, + IWorkflowExecutionDataProcess, +} from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; // eslint-disable-next-line import/no-cycle import { WorkflowRunner } from '@/WorkflowRunner'; @@ -186,7 +190,7 @@ export async function executeErrorWorkflow( initialNode, ); - const fullExecutionData: IExecutionDb = { + const fullExecutionData: ExecutionPayload = { data: fakeExecution.data, mode: fakeExecution.mode, finished: false, diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index a892e370cb096..6706ceaefba0d 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -20,6 +20,7 @@ import { LoggerProxy as Logger } from 'n8n-workflow'; import type { IExecutionsSummary, IRunExecutionData } from 'n8n-workflow'; import { BinaryDataManager } from 'n8n-core'; import type { + ExecutionPayload, IExecutionBase, IExecutionDb, IExecutionFlattedDb, @@ -242,7 +243,7 @@ export class ExecutionRepository extends Repository { return rest; } - async createNewExecution(execution: IExecutionDb) { + async createNewExecution(execution: ExecutionPayload) { const { data, workflowData, ...rest } = execution; const newExecution = await this.save(rest); diff --git a/packages/cli/src/executionLifecycleHooks/shared/sharedHookFunctions.ts b/packages/cli/src/executionLifecycleHooks/shared/sharedHookFunctions.ts index cf68a1930bed2..113bb2980a9af 100644 --- a/packages/cli/src/executionLifecycleHooks/shared/sharedHookFunctions.ts +++ b/packages/cli/src/executionLifecycleHooks/shared/sharedHookFunctions.ts @@ -1,5 +1,5 @@ import type { ExecutionStatus, IRun, IWorkflowBase } from 'n8n-workflow'; -import type { IExecutionDb } from '@/Interfaces'; +import type { ExecutionPayload, IExecutionDb } from '@/Interfaces'; import pick from 'lodash/pick'; import { isWorkflowIdValid } from '@/utils'; import { LoggerProxy } from 'n8n-workflow'; @@ -24,7 +24,7 @@ export function prepareExecutionDataForDbUpdate(parameters: { workflowData: IWorkflowBase; workflowStatusFinal: ExecutionStatus; retryOf?: string; -}): IExecutionDb { +}) { const { runData, workflowData, workflowStatusFinal, retryOf } = parameters; // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive // As a result, we should create an IWorkflowBase object with only the data we want to save in it. @@ -41,7 +41,7 @@ export function prepareExecutionDataForDbUpdate(parameters: { 'pinData', ]); - const fullExecutionData: IExecutionDb = { + const fullExecutionData: ExecutionPayload = { data: runData.data, mode: runData.mode, finished: runData.finished ? runData.finished : false, From b7062e59120307f1ed051201715e80f7f754a182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 Sep 2023 11:25:58 +0200 Subject: [PATCH 071/259] Fix lint --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 1 - packages/cli/src/databases/repositories/execution.repository.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 9c2d309733dfd..08bfd71031c8e 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -42,7 +42,6 @@ import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsHelper } from '@/CredentialsHelper'; import { ExternalHooks } from '@/ExternalHooks'; import type { - IExecutionDb, IPushDataExecutionFinished, IWorkflowExecuteProcess, IWorkflowExecutionDataProcess, diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 6706ceaefba0d..e1d4c3840e2bb 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -22,7 +22,6 @@ import { BinaryDataManager } from 'n8n-core'; import type { ExecutionPayload, IExecutionBase, - IExecutionDb, IExecutionFlattedDb, IExecutionResponse, } from '@/Interfaces'; From 79ecaa96c8ae1aadf1be3aa05f14b15552f8d2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 Sep 2023 11:23:18 +0200 Subject: [PATCH 072/259] Fix lint --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 1 - packages/cli/src/databases/repositories/execution.repository.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 9c2d309733dfd..08bfd71031c8e 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -42,7 +42,6 @@ import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsHelper } from '@/CredentialsHelper'; import { ExternalHooks } from '@/ExternalHooks'; import type { - IExecutionDb, IPushDataExecutionFinished, IWorkflowExecuteProcess, IWorkflowExecutionDataProcess, diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index a45b71e594af0..4cc026b98620d 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -22,7 +22,6 @@ import { BinaryDataService } from 'n8n-core'; import type { ExecutionPayload, IExecutionBase, - IExecutionDb, IExecutionFlattedDb, IExecutionResponse, } from '@/Interfaces'; From 02b6d864d93cd0cb80b840f550bf08d163b56c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 09:06:00 +0200 Subject: [PATCH 073/259] Cleanup --- packages/cli/src/config/schema.ts | 4 +- .../BinaryData.service.ts} | 64 +++++++++---------- .../FileSystem.manager.ts} | 6 +- .../ObjectStore.manager.ts} | 6 +- packages/core/src/NodeExecuteFunctions.ts | 2 +- packages/core/src/binaryData/types.ts | 20 ++++-- packages/core/src/binaryData/utils.ts | 8 ++- packages/core/src/index.ts | 4 +- .../core/test/NodeExecuteFunctions.test.ts | 2 +- 9 files changed, 67 insertions(+), 49 deletions(-) rename packages/core/src/{binaryData/binaryData.service.ts => BinaryData/BinaryData.service.ts} (73%) rename packages/core/src/{binaryData/fs.client.ts => BinaryData/FileSystem.manager.ts} (95%) rename packages/core/src/{binaryData/s3.client.ts => BinaryData/ObjectStore.manager.ts} (86%) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index ee548386a73ea..f3c83d0e93b85 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -890,12 +890,12 @@ export const schema = { binaryDataService: { availableModes: { format: 'comma-separated-list', - default: 'filesystem,object', + default: 'filesystem', env: 'N8N_AVAILABLE_BINARY_DATA_MODES', doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: ['default', 'filesystem', 'object'] as const, + format: ['default', 'filesystem'] as const, default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', diff --git a/packages/core/src/binaryData/binaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts similarity index 73% rename from packages/core/src/binaryData/binaryData.service.ts rename to packages/core/src/BinaryData/BinaryData.service.ts index 78640461513cc..d08e670254abb 100644 --- a/packages/core/src/binaryData/binaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -4,8 +4,8 @@ import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; -import { FileSystemClient } from './fs.client'; -import { InvalidBinaryModeError, areValidModes } from './utils'; +import { FileSystemManager } from './FileSystem.manager'; +import { InvalidBinaryDataManagerError, InvalidBinaryDataModeError, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -17,25 +17,25 @@ export class BinaryDataService { private mode: BinaryData.Mode = 'default'; - private clients: Record = {}; + private managers: Record = {}; - async init(config: BinaryData.Config, mainClient = false) { - if (!areValidModes(config.availableModes)) throw new InvalidBinaryModeError(); + async init(config: BinaryData.Config, mainManager = false) { + if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataModeError(); this.availableModes = config.availableModes; this.mode = config.mode; if (this.availableModes.includes('filesystem') && config.mode === 'filesystem') { - this.clients.filesystem = new FileSystemClient(config.storagePath); + this.managers.filesystem = new FileSystemManager(config.storagePath); - await this.clients.filesystem.init(mainClient); + await this.managers.filesystem.init(mainManager); } } async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { - const client = this.clients[this.mode]; + const manager = this.managers[this.mode]; - if (!client) { + if (!manager) { const { size } = await stat(path); binaryData.fileSize = prettyBytes(size); binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); @@ -43,14 +43,14 @@ export class BinaryDataService { return binaryData; } - const identifier = await client.copyByPath(path, executionId); + const identifier = await manager.copyByPath(path, executionId); binaryData.id = this.createIdentifier(identifier); - binaryData.data = this.mode; // clear from memory + binaryData.data = this.mode; // clear binary data from memory - const fileSize = await client.getSize(identifier); + const fileSize = await manager.getSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await client.storeMetadata(identifier, { + await manager.storeMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, @@ -60,9 +60,9 @@ export class BinaryDataService { } async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { - const client = this.clients[this.mode]; + const manager = this.managers[this.mode]; - if (!client) { + if (!manager) { const buffer = await this.binaryToBuffer(input); binaryData.data = buffer.toString(BINARY_ENCODING); binaryData.fileSize = prettyBytes(buffer.length); @@ -70,14 +70,14 @@ export class BinaryDataService { return binaryData; } - const identifier = await client.store(input, executionId); + const identifier = await manager.store(input, executionId); binaryData.id = this.createIdentifier(identifier); - binaryData.data = this.mode; // clear from memory + binaryData.data = this.mode; // clear binary data from memory - const fileSize = await client.getSize(identifier); + const fileSize = await manager.getSize(identifier); binaryData.fileSize = prettyBytes(fileSize); - await client.storeMetadata(identifier, { + await manager.storeMetadata(identifier, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, fileSize, @@ -96,7 +96,7 @@ export class BinaryDataService { getAsStream(identifier: string, chunkSize?: number) { const { mode, id } = this.splitBinaryModeFileId(identifier); - return this.getClient(mode).getAsStream(id, chunkSize); + return this.getManager(mode).getStream(id, chunkSize); } async getBinaryDataBuffer(binaryData: IBinaryData) { @@ -108,27 +108,27 @@ export class BinaryDataService { async retrieveBinaryDataByIdentifier(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - return this.getClient(mode).getAsBuffer(id); + return this.getManager(mode).getBuffer(id); } getPath(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - return this.getClient(mode).getPath(id); + return this.getManager(mode).getPath(id); } async getMetadata(identifier: string) { const { mode, id } = this.splitBinaryModeFileId(identifier); - return this.getClient(mode).getMetadata(id); + return this.getManager(mode).getMetadata(id); } async deleteManyByExecutionIds(executionIds: string[]) { - await this.getClient(this.mode).deleteManyByExecutionIds(executionIds); + await this.getManager(this.mode).deleteManyByExecutionIds(executionIds); } async duplicateBinaryData(inputData: Array, executionId: string) { - if (inputData && this.clients[this.mode]) { + if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { if (executionDataArray) { @@ -161,7 +161,7 @@ export class BinaryDataService { return `${this.mode}:${filename}`; } - private splitBinaryModeFileId(fileId: string): { mode: string; id: string } { + private splitBinaryModeFileId(fileId: string) { const [mode, id] = fileId.split(':'); return { mode, id }; @@ -171,7 +171,7 @@ export class BinaryDataService { executionData: INodeExecutionData, executionId: string, ) { - const client = this.clients[this.mode]; + const manager = this.managers[this.mode]; if (executionData.binary) { const binaryDataKeys = Object.keys(executionData.binary); @@ -185,7 +185,7 @@ export class BinaryDataService { return { key, newId: undefined }; } - return client + return manager ?.copyByIdentifier(this.splitBinaryModeFileId(binaryDataId).id, executionId) .then((filename) => ({ newId: this.createIdentifier(filename), @@ -207,11 +207,11 @@ export class BinaryDataService { return executionData; } - private getClient(mode: string) { - const client = this.clients[mode]; + private getManager(mode: string) { + const manager = this.managers[mode]; - if (!client) throw new Error('No binary data client found'); + if (manager) return manager; - return client; + throw new InvalidBinaryDataManagerError(); } } diff --git a/packages/core/src/binaryData/fs.client.ts b/packages/core/src/BinaryData/FileSystem.manager.ts similarity index 95% rename from packages/core/src/binaryData/fs.client.ts rename to packages/core/src/BinaryData/FileSystem.manager.ts index 36d2f476be3ef..1e00774382fd0 100644 --- a/packages/core/src/binaryData/fs.client.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -13,7 +13,7 @@ import type { BinaryData } from './types'; const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; -export class FileSystemClient implements BinaryData.Client { +export class FileSystemManager implements BinaryData.Manager { constructor(private storagePath: string) {} async init() { @@ -31,13 +31,13 @@ export class FileSystemClient implements BinaryData.Client { return stats.size; } - getAsStream(identifier: string, chunkSize?: number) { + getStream(identifier: string, chunkSize?: number) { const filePath = this.getPath(identifier); return createReadStream(filePath, { highWaterMark: chunkSize }); } - async getAsBuffer(identifier: string) { + async getBuffer(identifier: string) { const filePath = this.getPath(identifier); try { diff --git a/packages/core/src/binaryData/s3.client.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts similarity index 86% rename from packages/core/src/binaryData/s3.client.ts rename to packages/core/src/BinaryData/ObjectStore.manager.ts index de69464d2afd4..685f7a967a06b 100644 --- a/packages/core/src/binaryData/s3.client.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -4,7 +4,7 @@ import type { BinaryMetadata } from 'n8n-workflow'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; -export class S3Client implements BinaryData.Client { +export class ObjectStoreManager implements BinaryData.Manager { async init() { throw new Error('TODO'); } @@ -21,11 +21,11 @@ export class S3Client implements BinaryData.Client { throw new Error('TODO'); } - async getAsBuffer(identifier: string): Promise { + async getBuffer(identifier: string): Promise { throw new Error('TODO'); } - getAsStream(identifier: string, chunkSize?: number): Readable { + getStream(identifier: string, chunkSize?: number): Readable { throw new Error('TODO'); } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index ae8ba47d09568..fa7dede9ec37b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -115,7 +115,7 @@ import { Readable } from 'stream'; import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import { createReadStream } from 'fs'; -import { BinaryDataService } from './binaryData/binaryData.service'; +import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/binaryData/types.ts index ce4185ff66456..1f0c668ce9a4d 100644 --- a/packages/core/src/binaryData/types.ts +++ b/packages/core/src/binaryData/types.ts @@ -16,22 +16,34 @@ export namespace BinaryData { export type Config = InMemoryConfig | FileSystemConfig; - export interface Client { + export interface Manager { init(startPurger: boolean): Promise; store(binaryData: Buffer | Readable, executionId: string): Promise; getPath(identifier: string): string; - getSize(path: string): Promise; // @TODO: Refactor to use identifier? - getAsBuffer(identifier: string): Promise; - getAsStream(identifier: string, chunkSize?: number): Readable; + // @TODO: Refactor to use identifier + getSize(path: string): Promise; + + getBuffer(identifier: string): Promise; + getStream(identifier: string, chunkSize?: number): Readable; + + // @TODO: Refactor out - not needed for object storage storeMetadata(identifier: string, metadata: BinaryMetadata): Promise; + + // @TODO: Refactor out - not needed for object storage getMetadata(identifier: string): Promise; + // @TODO: Refactor to also use `workflowId` to support full path-like identifier: + // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` copyByPath(path: string, executionId: string): Promise; + copyByIdentifier(identifier: string, prefix: string): Promise; deleteOne(identifier: string): Promise; + + // @TODO: Refactor to also receive `workflowId` to support full path-like identifier: + // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` deleteManyByExecutionIds(executionIds: string[]): Promise; } } diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/binaryData/utils.ts index 3e59792469add..05dc84dc6308c 100644 --- a/packages/core/src/binaryData/utils.ts +++ b/packages/core/src/binaryData/utils.ts @@ -12,8 +12,14 @@ export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } -export class InvalidBinaryModeError extends Error { +export class InvalidBinaryDataModeError extends Error { constructor() { super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); } } + +export class InvalidBinaryDataManagerError extends Error { + constructor() { + super('No binary data manager found'); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c595e56458de0..9d6b5bfeebda3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,8 +2,8 @@ import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import * as UserSettings from './UserSettings'; export * from './ActiveWorkflows'; -export * from './binaryData/binaryData.service'; -export * from './binaryData/types'; +export * from './BinaryData/BinaryData.service'; +export * from './BinaryData/types'; export * from './ClassLoader'; export * from './Constants'; export * from './Credentials'; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 0f5d42ed6e4ce..96b45e907f478 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -11,7 +11,7 @@ import type { Workflow, WorkflowHooks, } from 'n8n-workflow'; -import { BinaryDataService } from '@/binaryData/binaryData.service'; +import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { setBinaryDataBuffer, getBinaryDataBuffer, From 745437fc22dea978dcd0969d18cd29fc551b888e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 10:17:46 +0200 Subject: [PATCH 074/259] Install `aws4` and `@types/aws4` --- packages/core/package.json | 2 ++ pnpm-lock.yaml | 29 +++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 3aaa0eacd0b1c..08b0c71fa767e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "bin" ], "devDependencies": { + "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", "@types/content-disposition": "^0.5.5", "@types/content-type": "^1.1.5", @@ -50,6 +51,7 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", + "aws4": "^1.8.0", "axios": "^0.21.1", "concat-stream": "^2.0.0", "content-disposition": "^0.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3df4705e49ac..e91da31084a34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ importers: dependencies: axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 packages/@n8n_io/eslint-config: devDependencies: @@ -217,7 +217,7 @@ importers: version: 7.28.1 axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -570,9 +570,12 @@ importers: '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 + aws4: + specifier: ^1.8.0 + version: 1.11.0 axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -631,6 +634,9 @@ importers: specifier: ^8.3.2 version: 8.3.2 devDependencies: + '@types/aws4': + specifier: ^1.5.1 + version: 1.11.2 '@types/concat-stream': specifier: ^2.0.0 version: 2.0.0 @@ -850,7 +856,7 @@ importers: version: 10.2.0(vue@3.3.4) axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -5015,7 +5021,7 @@ packages: dependencies: '@segment/loosely-validate-event': 2.0.0 auto-changelog: 1.16.4 - axios: 0.21.4(debug@4.3.2) + axios: 0.21.4 axios-retry: 3.3.1 bull: 3.29.3 lodash.clonedeep: 4.5.0 @@ -9075,19 +9081,18 @@ packages: is-retry-allowed: 2.2.0 dev: false - /axios@0.21.4(debug@4.3.2): + /axios@0.21.4: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2(debug@4.3.2) + follow-redirects: 1.15.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false - /axios@0.27.2: - resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + /axios@0.21.4(debug@4.3.2): + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: follow-redirects: 1.15.2(debug@4.3.2) - form-data: 4.0.0 transitivePeerDependencies: - debug dev: false @@ -9113,7 +9118,7 @@ packages: /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: - follow-redirects: 1.15.2(debug@4.3.2) + follow-redirects: 1.15.2(debug@3.2.7) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -18010,7 +18015,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2 + axios: 0.27.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false From ccd3abbea278b53cb73b29d601803eadb75a406b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 10:50:25 +0200 Subject: [PATCH 075/259] Add to config schema --- packages/cli/src/config/schema.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index f3c83d0e93b85..a5c9409f12583 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -908,6 +908,40 @@ export const schema = { }, }, + externalStorage: { + s3: { + bucket: { + name: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', + doc: 'Name of the n8n bucket in S3-compatible external storage', + }, + // @TODO: Is region AWS-specific? + region: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', + doc: 'Region of the n8n bucket in S3-compatible external storage', + }, + }, + credentials: { + accountId: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', + doc: 'Account ID in S3-compatible external storage', + }, + secretKey: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', + doc: 'Secret key in S3-compatible external storage', + }, + }, + }, + }, + deployment: { type: { format: String, From afcc4743069c373d04b6158acea3bcb376d626bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 10:50:46 +0200 Subject: [PATCH 076/259] Set up object store service --- .../src/ObjectStore/ObjectStore.service.ts | 67 +++++++++++++++++++ packages/core/src/ObjectStore/utils.ts | 19 ++++++ packages/core/src/index.ts | 1 + 3 files changed, 87 insertions(+) create mode 100644 packages/core/src/ObjectStore/ObjectStore.service.ts create mode 100644 packages/core/src/ObjectStore/utils.ts diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ts b/packages/core/src/ObjectStore/ObjectStore.service.ts new file mode 100644 index 0000000000000..118a449f8f987 --- /dev/null +++ b/packages/core/src/ObjectStore/ObjectStore.service.ts @@ -0,0 +1,67 @@ +import axios from 'axios'; +import { Service } from 'typedi'; +import { sign } from 'aws4'; +import { isStream, DownloadTypeError, RequestToObjectStorageFailed } from './utils'; +import type { AxiosPromise, Method } from 'axios'; +import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; + +/** + * `/workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` + */ + +@Service() +export class ObjectStoreService { + constructor( + private bucket: { region: string; name: string }, + private credentials: { accountId: string; secretKey: string }, + ) { + // @TODO: Confirm connection + } + + async getStream(objectPath: string) { + const result = await this.request('GET', objectPath, { mode: 'stream' }); + + if (isStream(result)) return result; + + throw new DownloadTypeError('stream', typeof result); + } + + async getBuffer(objectPath: string) { + const result = await this.request('GET', objectPath, { mode: 'buffer' }); + + if (Buffer.isBuffer(result)) return result; + + throw new DownloadTypeError('buffer', typeof result); + } + + private async request( + method: Method, + objectPath: string, + { mode }: { mode: 'stream' | 'buffer' }, + ) { + // @TODO Decouple host from AWS + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const options: Aws4Options = { + host, + path: `/${objectPath}`, + }; + + const credentials: Aws4Credentials = { + accessKeyId: this.credentials.accountId, + secretAccessKey: this.credentials.secretKey, + }; + + const signed = sign(options, credentials); + + const response: Awaited> = await axios(`https://${host}/${objectPath}`, { + method, + headers: signed.headers, + responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', + }); + + if (response.status !== 200) throw new RequestToObjectStorageFailed(); + + return response.data; + } +} diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts new file mode 100644 index 0000000000000..a9df2c98e14db --- /dev/null +++ b/packages/core/src/ObjectStore/utils.ts @@ -0,0 +1,19 @@ +import { Stream } from 'node:stream'; + +export function isStream(maybeStream: unknown): maybeStream is Stream { + return maybeStream instanceof Stream; +} + +// @TODO: Add more info to errors + +export class DownloadTypeError extends TypeError { + constructor(expectedType: 'stream' | 'buffer', actualType: string) { + super(`Expected ${expectedType} but received ${actualType} from external storage download.`); + } +} + +export class RequestToObjectStorageFailed extends Error { + constructor() { + super('Request to external object storage failed'); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d6b5bfeebda3..8ada828aef63c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from './LoadMappingOptions'; export * from './LoadNodeParameterOptions'; export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; +export * from './ObjectStore/ObjectStore.service'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; From 0f909b0d0ecf7b4723d36cd69a45eeb7d1f1327b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 10:50:58 +0200 Subject: [PATCH 077/259] Set up temp dev code --- packages/cli/src/commands/BaseCommand.ts | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index cf3a7bbb2028f..0d7807dbcfe80 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,9 +1,11 @@ +import fs from 'node:fs'; +import { pipeline } from 'node:stream/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings } from 'n8n-core'; +import { BinaryDataService, UserSettings, ObjectStoreService } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -104,6 +106,31 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { + /** + * @TODO: Only for dev, remove later + */ + + const objectStoreService = new ObjectStoreService( + { + name: config.getEnv('externalStorage.s3.bucket.name'), + region: config.getEnv('externalStorage.s3.bucket.region'), + }, + { + accountId: config.getEnv('externalStorage.s3.credentials.accountId'), + secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), + }, + ); + + const stream = await objectStoreService.getStream('happy-dog.jpg'); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); + console.log('✅ Pipeline succeeded'); + } catch (error) { + console.log('❌ Pipeline failed', error); + } + const binaryDataConfig = config.getEnv('binaryDataService'); await Container.get(BinaryDataService).init(binaryDataConfig, true); } From 96ea9eab6de78a9b8e494beacdcf413161373e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 11:07:56 +0200 Subject: [PATCH 078/259] Comment out cache keys --- .github/workflows/ci-pull-requests.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index e87b1a6d925f6..0fb9809582f7e 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-node@v3.7.0 with: node-version: 18.x - cache: pnpm + # cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile @@ -26,19 +26,19 @@ jobs: - name: Build run: pnpm build - - name: Cache build artifacts - uses: actions/cache/save@v3.3.1 - with: - path: ./packages/**/dist - key: ${{ github.sha }}-base:18-test-lint + # - name: Cache build artifacts + # uses: actions/cache/save@v3.3.1 + # with: + # path: ./packages/**/dist + # key: ${{ github.sha }}-base:18-test-lint unit-test: name: Unit tests uses: ./.github/workflows/units-tests-reusable.yml needs: install - with: - ref: ${{ github.event.pull_request.head.ref }} - cacheKey: ${{ github.sha }}-base:18-test-lint + # with: + # ref: ${{ github.event.pull_request.head.ref }} + # cacheKey: ${{ github.sha }}-base:18-test-lint lint: name: Lint changes @@ -63,9 +63,9 @@ jobs: - name: Restore cached build artifacts uses: actions/cache/restore@v3.3.1 - with: - path: ./packages/**/dist - key: ${{ github.sha }}-base:18-test-lint + # with: + # path: ./packages/**/dist + # key: ${{ github.sha }}-base:18-test-lint - name: Lint run: pnpm lint From 3d76b21999b5f71a3eb916b3bf390223e6df2ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 11:11:22 +0200 Subject: [PATCH 079/259] Revert "Comment out cache keys" This reverts commit 96ea9eab6de78a9b8e494beacdcf413161373e17. --- .github/workflows/ci-pull-requests.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 0fb9809582f7e..e87b1a6d925f6 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-node@v3.7.0 with: node-version: 18.x - # cache: pnpm + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile @@ -26,19 +26,19 @@ jobs: - name: Build run: pnpm build - # - name: Cache build artifacts - # uses: actions/cache/save@v3.3.1 - # with: - # path: ./packages/**/dist - # key: ${{ github.sha }}-base:18-test-lint + - name: Cache build artifacts + uses: actions/cache/save@v3.3.1 + with: + path: ./packages/**/dist + key: ${{ github.sha }}-base:18-test-lint unit-test: name: Unit tests uses: ./.github/workflows/units-tests-reusable.yml needs: install - # with: - # ref: ${{ github.event.pull_request.head.ref }} - # cacheKey: ${{ github.sha }}-base:18-test-lint + with: + ref: ${{ github.event.pull_request.head.ref }} + cacheKey: ${{ github.sha }}-base:18-test-lint lint: name: Lint changes @@ -63,9 +63,9 @@ jobs: - name: Restore cached build artifacts uses: actions/cache/restore@v3.3.1 - # with: - # path: ./packages/**/dist - # key: ${{ github.sha }}-base:18-test-lint + with: + path: ./packages/**/dist + key: ${{ github.sha }}-base:18-test-lint - name: Lint run: pnpm lint From 455c327caaf6d28852663f721037f528352d6d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 11:20:33 +0200 Subject: [PATCH 080/259] Rename dir to `TempBinaryData` --- packages/core/src/NodeExecuteFunctions.ts | 2 +- .../src/{BinaryData => TempBinaryData}/BinaryData.service.ts | 0 .../src/{BinaryData => TempBinaryData}/FileSystem.manager.ts | 0 .../src/{BinaryData => TempBinaryData}/ObjectStore.manager.ts | 0 packages/core/src/{binaryData => TempBinaryData}/types.ts | 0 packages/core/src/{binaryData => TempBinaryData}/utils.ts | 0 packages/core/src/index.ts | 4 ++-- packages/core/test/NodeExecuteFunctions.test.ts | 2 +- 8 files changed, 4 insertions(+), 4 deletions(-) rename packages/core/src/{BinaryData => TempBinaryData}/BinaryData.service.ts (100%) rename packages/core/src/{BinaryData => TempBinaryData}/FileSystem.manager.ts (100%) rename packages/core/src/{BinaryData => TempBinaryData}/ObjectStore.manager.ts (100%) rename packages/core/src/{binaryData => TempBinaryData}/types.ts (100%) rename packages/core/src/{binaryData => TempBinaryData}/utils.ts (100%) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index fa7dede9ec37b..f48caeadaeef3 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -115,7 +115,7 @@ import { Readable } from 'stream'; import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import { createReadStream } from 'fs'; -import { BinaryDataService } from './BinaryData/BinaryData.service'; +import { BinaryDataService } from './TempBinaryData/BinaryData.service'; import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/TempBinaryData/BinaryData.service.ts similarity index 100% rename from packages/core/src/BinaryData/BinaryData.service.ts rename to packages/core/src/TempBinaryData/BinaryData.service.ts diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/TempBinaryData/FileSystem.manager.ts similarity index 100% rename from packages/core/src/BinaryData/FileSystem.manager.ts rename to packages/core/src/TempBinaryData/FileSystem.manager.ts diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/TempBinaryData/ObjectStore.manager.ts similarity index 100% rename from packages/core/src/BinaryData/ObjectStore.manager.ts rename to packages/core/src/TempBinaryData/ObjectStore.manager.ts diff --git a/packages/core/src/binaryData/types.ts b/packages/core/src/TempBinaryData/types.ts similarity index 100% rename from packages/core/src/binaryData/types.ts rename to packages/core/src/TempBinaryData/types.ts diff --git a/packages/core/src/binaryData/utils.ts b/packages/core/src/TempBinaryData/utils.ts similarity index 100% rename from packages/core/src/binaryData/utils.ts rename to packages/core/src/TempBinaryData/utils.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d6b5bfeebda3..ea351ffbbc7e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,8 +2,8 @@ import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import * as UserSettings from './UserSettings'; export * from './ActiveWorkflows'; -export * from './BinaryData/BinaryData.service'; -export * from './BinaryData/types'; +export * from './TempBinaryData/BinaryData.service'; +export * from './TempBinaryData/types'; export * from './ClassLoader'; export * from './Constants'; export * from './Credentials'; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 96b45e907f478..6a4aeab8bbd8a 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -11,7 +11,7 @@ import type { Workflow, WorkflowHooks, } from 'n8n-workflow'; -import { BinaryDataService } from '@/BinaryData/BinaryData.service'; +import { BinaryDataService } from '@/TempBinaryData/BinaryData.service'; import { setBinaryDataBuffer, getBinaryDataBuffer, From 65b4fb0e699c25186a1f997b0922837ddf9488b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 11:41:49 +0200 Subject: [PATCH 081/259] Rename back to `BinaryData` --- .../src/{TempBinaryData => BinaryData}/BinaryData.service.ts | 0 .../src/{TempBinaryData => BinaryData}/FileSystem.manager.ts | 0 .../src/{TempBinaryData => BinaryData}/ObjectStore.manager.ts | 0 packages/core/src/{TempBinaryData => BinaryData}/types.ts | 0 packages/core/src/{TempBinaryData => BinaryData}/utils.ts | 0 packages/core/src/NodeExecuteFunctions.ts | 2 +- packages/core/src/index.ts | 4 ++-- packages/core/test/NodeExecuteFunctions.test.ts | 2 +- 8 files changed, 4 insertions(+), 4 deletions(-) rename packages/core/src/{TempBinaryData => BinaryData}/BinaryData.service.ts (100%) rename packages/core/src/{TempBinaryData => BinaryData}/FileSystem.manager.ts (100%) rename packages/core/src/{TempBinaryData => BinaryData}/ObjectStore.manager.ts (100%) rename packages/core/src/{TempBinaryData => BinaryData}/types.ts (100%) rename packages/core/src/{TempBinaryData => BinaryData}/utils.ts (100%) diff --git a/packages/core/src/TempBinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts similarity index 100% rename from packages/core/src/TempBinaryData/BinaryData.service.ts rename to packages/core/src/BinaryData/BinaryData.service.ts diff --git a/packages/core/src/TempBinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts similarity index 100% rename from packages/core/src/TempBinaryData/FileSystem.manager.ts rename to packages/core/src/BinaryData/FileSystem.manager.ts diff --git a/packages/core/src/TempBinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts similarity index 100% rename from packages/core/src/TempBinaryData/ObjectStore.manager.ts rename to packages/core/src/BinaryData/ObjectStore.manager.ts diff --git a/packages/core/src/TempBinaryData/types.ts b/packages/core/src/BinaryData/types.ts similarity index 100% rename from packages/core/src/TempBinaryData/types.ts rename to packages/core/src/BinaryData/types.ts diff --git a/packages/core/src/TempBinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts similarity index 100% rename from packages/core/src/TempBinaryData/utils.ts rename to packages/core/src/BinaryData/utils.ts diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index f48caeadaeef3..fa7dede9ec37b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -115,7 +115,7 @@ import { Readable } from 'stream'; import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import { createReadStream } from 'fs'; -import { BinaryDataService } from './TempBinaryData/BinaryData.service'; +import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ea351ffbbc7e1..9d6b5bfeebda3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,8 +2,8 @@ import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import * as UserSettings from './UserSettings'; export * from './ActiveWorkflows'; -export * from './TempBinaryData/BinaryData.service'; -export * from './TempBinaryData/types'; +export * from './BinaryData/BinaryData.service'; +export * from './BinaryData/types'; export * from './ClassLoader'; export * from './Constants'; export * from './Credentials'; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 6a4aeab8bbd8a..96b45e907f478 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -11,7 +11,7 @@ import type { Workflow, WorkflowHooks, } from 'n8n-workflow'; -import { BinaryDataService } from '@/TempBinaryData/BinaryData.service'; +import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { setBinaryDataBuffer, getBinaryDataBuffer, From abe6d9df1d298dd9d602fb6198b824e87193beaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 15:11:57 +0200 Subject: [PATCH 082/259] Clear timers on shutdown --- packages/cli/src/commands/start.ts | 3 +++ .../databases/repositories/execution.repository.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b0a9b0b73988b..0cebdb633ec8e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -29,6 +29,7 @@ import { eventBus } from '@/eventbus'; import { BaseCommand } from './BaseCommand'; import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -103,6 +104,8 @@ export class Start extends BaseCommand { // Note: While this saves a new license cert to DB, the previous entitlements are still kept in memory so that the shutdown process can complete await Container.get(License).shutdown(); + Container.get(ExecutionRepository).clearTimers(); + await Container.get(InternalHooks).onN8nStop(); const skipWebhookDeregistration = config.getEnv( diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index e1d4c3840e2bb..7b2e436416902 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -78,7 +78,9 @@ function parseFiltersToQueryBuilder( export class ExecutionRepository extends Repository { deletionBatchSize = 100; - hardDeletionInterval: NodeJS.Timer | null = null; + private pruningInterval: NodeJS.Timer | null = null; + + private hardDeletionInterval: NodeJS.Timer | null = null; constructor( dataSource: DataSource, @@ -91,8 +93,13 @@ export class ExecutionRepository extends Repository { this.setHardDeletionInterval(); } + clearTimers() { + if (this.hardDeletionInterval) clearInterval(this.hardDeletionInterval); + if (this.pruningInterval) clearInterval(this.pruningInterval); + } + setPruningInterval() { - setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); + this.pruningInterval = setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); } setHardDeletionInterval() { From 2682b3d596dcbfa3aca8fd697f1f5af589217aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 16:20:03 +0200 Subject: [PATCH 083/259] Set timers only on main instance --- packages/cli/src/commands/BaseCommand.ts | 2 ++ packages/cli/src/config/schema.ts | 6 ++++++ .../cli/src/databases/repositories/execution.repository.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 9b2658ac7f7ff..6aa1e12176999 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -114,6 +114,8 @@ export abstract class BaseCommand extends Command { } async initLicense(instanceType: N8nInstanceType = 'main'): Promise { + config.set('generic.instanceType', instanceType); + const license = Container.get(License); await license.init(this.instanceId, instanceType); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 05ed7000ee186..1704be42ba8b2 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -432,6 +432,12 @@ export const schema = { default: 'America/New_York', env: 'GENERIC_TIMEZONE', }, + + instanceType: { + doc: 'Type of n8n instance', + format: ['main', 'webhook', 'worker'] as const, + default: 'main', + }, }, // How n8n can be reached (Editor & REST-API) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 7b2e436416902..11177325c4835 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -87,7 +87,10 @@ export class ExecutionRepository extends Repository { private readonly executionDataRepository: ExecutionDataRepository, ) { super(ExecutionEntity, dataSource.manager); + if (config.get('generic.instanceType') === 'main') this.setTimers(); + } + setTimers() { if (config.getEnv('executions.pruneData')) this.setPruningInterval(); this.setHardDeletionInterval(); From 1c788fe9e7620fd98dcc832a665daa195acf2780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 16:21:57 +0200 Subject: [PATCH 084/259] Also for clearing timers --- .../cli/src/databases/repositories/execution.repository.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 11177325c4835..9b3dd338370e2 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -87,6 +87,7 @@ export class ExecutionRepository extends Repository { private readonly executionDataRepository: ExecutionDataRepository, ) { super(ExecutionEntity, dataSource.manager); + if (config.get('generic.instanceType') === 'main') this.setTimers(); } @@ -97,6 +98,8 @@ export class ExecutionRepository extends Repository { } clearTimers() { + if (config.get('generic.instanceType') !== 'main') return; + if (this.hardDeletionInterval) clearInterval(this.hardDeletionInterval); if (this.pruningInterval) clearInterval(this.pruningInterval); } From 04f4b838840ea3e40c261a3d38d618d55ade3be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 16:26:38 +0200 Subject: [PATCH 085/259] Rename back to `localStoragePath` --- packages/cli/src/config/schema.ts | 2 +- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- packages/core/src/BinaryData/types.ts | 2 +- packages/core/test/NodeExecuteFunctions.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index c03a70d469b0a..2529354c15334 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -906,7 +906,7 @@ export const schema = { env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', }, - storagePath: { + localStoragePath: { format: String, default: path.join(UserSettings.getUserN8nFolderPath(), 'binaryData'), env: 'N8N_BINARY_DATA_STORAGE_PATH', diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index d08e670254abb..fe7e5c46a771a 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -26,7 +26,7 @@ export class BinaryDataService { this.mode = config.mode; if (this.availableModes.includes('filesystem') && config.mode === 'filesystem') { - this.managers.filesystem = new FileSystemManager(config.storagePath); + this.managers.filesystem = new FileSystemManager(config.localStoragePath); await this.managers.filesystem.init(mainManager); } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 1f0c668ce9a4d..a691bbb3b83ba 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -12,7 +12,7 @@ export namespace BinaryData { type InMemoryConfig = ConfigBase & { mode: 'default' }; - export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; storagePath: string }; + export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; localStoragePath: string }; export type Config = InMemoryConfig | FileSystemConfig; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 96b45e907f478..d59c976a52cb5 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -81,7 +81,7 @@ describe('NodeExecuteFunctions', () => { await Container.get(BinaryDataService).init({ mode: 'filesystem', availableModes: ['filesystem'], - storagePath: temporaryDir, + localStoragePath: temporaryDir, }); // Set our binary data buffer From 03264f941d04a2ca98279d6c456a701a21b33088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 16:28:48 +0200 Subject: [PATCH 086/259] Remove unused arg --- packages/core/src/BinaryData/BinaryData.service.ts | 4 ++-- packages/core/src/BinaryData/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index fe7e5c46a771a..629022ac36fd8 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -19,7 +19,7 @@ export class BinaryDataService { private managers: Record = {}; - async init(config: BinaryData.Config, mainManager = false) { + async init(config: BinaryData.Config) { if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataModeError(); this.availableModes = config.availableModes; @@ -28,7 +28,7 @@ export class BinaryDataService { if (this.availableModes.includes('filesystem') && config.mode === 'filesystem') { this.managers.filesystem = new FileSystemManager(config.localStoragePath); - await this.managers.filesystem.init(mainManager); + await this.managers.filesystem.init(); } } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index a691bbb3b83ba..c843f14d30188 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -17,7 +17,7 @@ export namespace BinaryData { export type Config = InMemoryConfig | FileSystemConfig; export interface Manager { - init(startPurger: boolean): Promise; + init(): Promise; store(binaryData: Buffer | Readable, executionId: string): Promise; getPath(identifier: string): string; From a6e33d498ff8ab15331cffecc20181437646cbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 16:34:56 +0200 Subject: [PATCH 087/259] Fix lint --- packages/cli/src/commands/BaseCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index eeca8f751075d..17a95a155f6fe 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -105,7 +105,7 @@ export abstract class BaseCommand extends Command { async initBinaryDataService() { const binaryDataConfig = config.getEnv('binaryDataService'); - await Container.get(BinaryDataService).init(binaryDataConfig, true); + await Container.get(BinaryDataService).init(binaryDataConfig); } async initExternalHooks() { From 552860a92db2091de69507a8e751c05a4fad22bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 18 Sep 2023 18:53:52 +0200 Subject: [PATCH 088/259] Make as enterprise edition --- .../{ObjectStore.manager.ts => ObjectStore.manager.ee.ts} | 0 .../{ObjectStore.service.ts => ObjectStore.service.ee.ts} | 0 packages/core/src/index.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/core/src/BinaryData/{ObjectStore.manager.ts => ObjectStore.manager.ee.ts} (100%) rename packages/core/src/ObjectStore/{ObjectStore.service.ts => ObjectStore.service.ee.ts} (100%) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts similarity index 100% rename from packages/core/src/BinaryData/ObjectStore.manager.ts rename to packages/core/src/BinaryData/ObjectStore.manager.ee.ts diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts similarity index 100% rename from packages/core/src/ObjectStore/ObjectStore.service.ts rename to packages/core/src/ObjectStore/ObjectStore.service.ee.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ada828aef63c..4de42dbae5ecf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,7 +13,7 @@ export * from './LoadMappingOptions'; export * from './LoadNodeParameterOptions'; export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; -export * from './ObjectStore/ObjectStore.service'; +export * from './ObjectStore/ObjectStore.service.ee'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; From 88235544adec7f55718d2dba72244e218d10af09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:12:25 +0200 Subject: [PATCH 089/259] Add logging, refactor for readability --- .../repositories/execution.repository.ts | 59 ++++++++++++------- .../repositories/execution.repository.test.ts | 4 +- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 9b3dd338370e2..53d66d5ef1a4a 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -76,11 +76,23 @@ function parseFiltersToQueryBuilder( @Service() export class ExecutionRepository extends Repository { + private logger = Logger; + deletionBatchSize = 100; - private pruningInterval: NodeJS.Timer | null = null; + private intervals: Record = { + softDeletion: undefined, + hardDeletion: undefined, + }; + + private rates: Record = { + softDeletion: 1 * TIME.HOUR, + hardDeletion: 15 * TIME.MINUTE, + }; + + private isMainInstance = config.get('generic.instanceType') === 'main'; - private hardDeletionInterval: NodeJS.Timer | null = null; + private isPruningEnabled = config.getEnv('executions.pruneData'); constructor( dataSource: DataSource, @@ -88,36 +100,35 @@ export class ExecutionRepository extends Repository { ) { super(ExecutionEntity, dataSource.manager); - if (config.get('generic.instanceType') === 'main') this.setTimers(); - } + if (!this.isMainInstance) return; - setTimers() { - if (config.getEnv('executions.pruneData')) this.setPruningInterval(); + if (this.isPruningEnabled) this.setSoftDeletionInterval(); this.setHardDeletionInterval(); } clearTimers() { - if (config.get('generic.instanceType') !== 'main') return; + if (!this.isMainInstance) return; - if (this.hardDeletionInterval) clearInterval(this.hardDeletionInterval); - if (this.pruningInterval) clearInterval(this.pruningInterval); - } + this.logger.info('Clearing soft-deletion and hard-deletion intervals for executions'); - setPruningInterval() { - this.pruningInterval = setInterval(async () => this.pruneBySoftDeleting(), 1 * TIME.HOUR); + clearInterval(this.intervals.softDeletion); + clearInterval(this.intervals.hardDeletion); } - setHardDeletionInterval() { - if (this.hardDeletionInterval) return; + setSoftDeletionInterval() { + this.logger.info('Setting soft-deletion interval (pruning) for executions'); - this.hardDeletionInterval = setInterval(async () => this.hardDelete(), 15 * TIME.MINUTE); + this.intervals.softDeletion = setInterval(async () => this.prune(), this.rates.hardDeletion); } - clearHardDeletionInterval() { - if (!this.hardDeletionInterval) return; + setHardDeletionInterval() { + this.logger.info('Setting hard-deletion interval for executions'); - clearInterval(this.hardDeletionInterval); + this.intervals.hardDeletion = setInterval( + async () => this.hardDelete(), + this.rates.hardDeletion, + ); } async findMultipleExecutions( @@ -457,8 +468,8 @@ export class ExecutionRepository extends Repository { } while (executionIds.length > 0); } - async pruneBySoftDeleting() { - Logger.verbose('Pruning (soft-deleting) execution data from database'); + async prune() { + Logger.verbose('Soft-deleting (pruning) execution data from database'); const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxCount = config.getEnv('executions.pruneDataMaxCount'); @@ -521,6 +532,10 @@ export class ExecutionRepository extends Repository { const binaryDataManager = BinaryDataManager.getInstance(); await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); + this.logger.info(`Hard-deleting ${executionIds.length} executions from database`, { + executionIds, + }); + // Actually delete these executions await this.delete({ id: In(executionIds) }); @@ -533,10 +548,12 @@ export class ExecutionRepository extends Repository { * the number of executions to prune is low enough to fit in a single batch. */ if (executionIds.length === this.deletionBatchSize) { - this.clearHardDeletionInterval(); + clearInterval(this.intervals.hardDeletion); setTimeout(async () => this.hardDelete(), 1 * TIME.SECOND); } else { + if (this.intervals.hardDeletion) return; + this.setHardDeletionInterval(); } } diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts index ac15979504a6f..500e1ae0b6a33 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -51,7 +51,7 @@ describe('ExecutionRepository', () => { jest.spyOn(ExecutionRepository.prototype, 'createQueryBuilder').mockReturnValueOnce(qb); - await executionRepository.pruneBySoftDeleting(); + await executionRepository.prune(); expect(find.mock.calls[0][0]).toEqual(objectContaining({ skip: maxCount })); }); @@ -70,7 +70,7 @@ describe('ExecutionRepository', () => { const now = Date.now(); - await executionRepository.pruneBySoftDeleting(); + await executionRepository.prune(); const argDate = dateFormat.mock.calls[0][0]; const difference = now - argDate.valueOf(); From a5def408265a59a800a772fb17e25b887cfcf2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:18:00 +0200 Subject: [PATCH 090/259] Ensure hard-deletion select includes soft-deleted rows --- .../cli/src/databases/repositories/execution.repository.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 53d66d5ef1a4a..0e1814fbe65d8 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -526,6 +526,12 @@ export class ExecutionRepository extends Repository { deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), }, take: this.deletionBatchSize, + + /** + * @important This ensures soft-deleted executions are included, + * else `@DeleteDateColumn()` at `deletedAt` will exclude them. + */ + withDeleted: true, }) ).map(({ id }) => id); From 77955e4ea0fe74e9ac9edef82b2f07ddd5a6a3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:18:51 +0200 Subject: [PATCH 091/259] Switch `info` to `debug` --- .../src/databases/repositories/execution.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 0e1814fbe65d8..4e2def452fdb5 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -110,20 +110,20 @@ export class ExecutionRepository extends Repository { clearTimers() { if (!this.isMainInstance) return; - this.logger.info('Clearing soft-deletion and hard-deletion intervals for executions'); + this.logger.debug('Clearing soft-deletion and hard-deletion intervals for executions'); clearInterval(this.intervals.softDeletion); clearInterval(this.intervals.hardDeletion); } setSoftDeletionInterval() { - this.logger.info('Setting soft-deletion interval (pruning) for executions'); + this.logger.debug('Setting soft-deletion interval (pruning) for executions'); this.intervals.softDeletion = setInterval(async () => this.prune(), this.rates.hardDeletion); } setHardDeletionInterval() { - this.logger.info('Setting hard-deletion interval for executions'); + this.logger.debug('Setting hard-deletion interval for executions'); this.intervals.hardDeletion = setInterval( async () => this.hardDelete(), @@ -538,7 +538,7 @@ export class ExecutionRepository extends Repository { const binaryDataManager = BinaryDataManager.getInstance(); await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); - this.logger.info(`Hard-deleting ${executionIds.length} executions from database`, { + this.logger.debug(`Hard-deleting ${executionIds.length} executions from database`, { executionIds, }); From b930e3e12d986a497708774ad3764b44e27ff92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:30:30 +0200 Subject: [PATCH 092/259] Fix tests --- packages/cli/test/integration/executions.controller.test.ts | 6 +++++- packages/cli/test/integration/publicApi/executions.test.ts | 4 ++++ .../cli/test/unit/repositories/execution.repository.test.ts | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index fa6a70499cc3f..bc2f783674a1c 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -1,8 +1,12 @@ import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; import type { User } from '@/databases/entities/User'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; -const testServer = setupTestServer({ endpointGroups: ['executions'] }); +LoggerProxy.init(getLogger()); + +let testServer = setupTestServer({ endpointGroups: ['executions'] }); let owner: User; diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index d42f8ca62ad35..9b208548a5b05 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -5,6 +5,8 @@ import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; let owner: User; let user1: User; @@ -14,6 +16,8 @@ let authUser1Agent: SuperAgentTest; let authUser2Agent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; +LoggerProxy.init(getLogger()); + const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/test/unit/repositories/execution.repository.test.ts index 500e1ae0b6a33..7d2d0350dbe93 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/test/unit/repositories/execution.repository.test.ts @@ -11,6 +11,8 @@ import { DateUtils } from 'typeorm/util/DateUtils'; jest.mock('typeorm/util/DateUtils'); +LoggerProxy.init(getLogger()); + const { objectContaining } = expect; // eslint-disable-next-line @typescript-eslint/no-explicit-any From e91f3de30a60e7c463af5c002cb1411e8d6e02f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:32:25 +0200 Subject: [PATCH 093/259] Remove redundant checks for `deletedAt` being `NULL` The column `deletedAt` is marked `@DeleteDateColumn()` so all reads from the repository automatically add a `WHERE` clause checking that the column `IS NULL`. --- .../src/databases/repositories/execution.repository.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 4e2def452fdb5..d9a0839f33b08 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -166,10 +166,6 @@ export class ExecutionRepository extends Repository { (queryParams.relations as string[]).push('executionData'); } - if (queryParams.where && !Array.isArray(queryParams.where)) { - queryParams.where.deletedAt = IsNull(); - } - const executions = await this.find(queryParams); if (options?.includeData && options?.unflattenData) { @@ -234,7 +230,6 @@ export class ExecutionRepository extends Repository { where: { id, ...options?.where, - deletedAt: IsNull(), }, }; if (options?.includeData) { @@ -393,9 +388,7 @@ export class ExecutionRepository extends Repository { .limit(limit) // eslint-disable-next-line @typescript-eslint/naming-convention .orderBy({ 'execution.id': 'DESC' }) - .andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }) - .andWhere('execution.deletedAt IS NULL'); - + .andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }); if (excludedExecutionIds.length > 0) { query.andWhere('execution.id NOT IN (:...excludedExecutionIds)', { excludedExecutionIds }); } From 2c3704d0e3aec8fe16ec5775fbe3decd16e48a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 10:43:41 +0200 Subject: [PATCH 094/259] Fix lint --- .../src/databases/repositories/execution.repository.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index d9a0839f33b08..651e6d7bfc6d7 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,13 +1,5 @@ import { Service } from 'typedi'; -import { - Brackets, - DataSource, - In, - IsNull, - LessThanOrEqual, - MoreThanOrEqual, - Repository, -} from 'typeorm'; +import { Brackets, DataSource, In, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, From 215f58e7ba1bc3c2312746d1ededf412c1cb6c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:23:02 +0200 Subject: [PATCH 095/259] Fix last test --- packages/cli/test/integration/publicApi/workflows.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 910bfbb3970c8..980986cf96d44 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -10,6 +10,8 @@ import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; let workflowOwnerRole: Role; let owner: User; @@ -18,6 +20,8 @@ let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; +LoggerProxy.init(getLogger()); + const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { From fabeceaa70ac00ffe9b944ed906a8e2b7684e65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:27:04 +0200 Subject: [PATCH 096/259] More setup --- packages/cli/src/commands/BaseCommand.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 081b5da6647e7..5e2288bcf1248 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { pipeline } from 'node:stream/promises'; +// import { readFile } from 'node:fs/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; @@ -121,6 +122,12 @@ export abstract class BaseCommand extends Command { }, ); + // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; + // const buffer = Buffer.from(await readFile(filePath)); + // await objectStoreService.put('object-store-service-happy-dog.jpg', buffer); + + // await objectStoreService.checkConnection(); + const stream = await objectStoreService.getStream('happy-dog.jpg'); try { From 33344bc72dd06d53efcce9ee59ca2388598c48cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:27:18 +0200 Subject: [PATCH 097/259] Add sample path structure --- packages/core/src/BinaryData/ObjectStore.manager.ee.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index 685f7a967a06b..b82082b63ade9 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -4,6 +4,8 @@ import type { BinaryMetadata } from 'n8n-workflow'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; +// `/workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` + export class ObjectStoreManager implements BinaryData.Manager { async init() { throw new Error('TODO'); From 70ab53b61814bb287b6bc5c0b69c072bf2a664c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:27:25 +0200 Subject: [PATCH 098/259] Set up some errors --- packages/core/src/ObjectStore/errors.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/core/src/ObjectStore/errors.ts diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts new file mode 100644 index 0000000000000..ae19b6c41e75b --- /dev/null +++ b/packages/core/src/ObjectStore/errors.ts @@ -0,0 +1,21 @@ +// import { jsonStringify } from 'n8n-workflow'; +import type { AxiosRequestConfig } from 'axios'; + +export namespace ObjectStorageError { + export class TypeMismatch extends TypeError { + constructor(expectedType: 'stream' | 'buffer', actualType: string) { + super(`Expected ${expectedType} but received ${actualType} from external storage download.`); + } + } + + export class RequestFailed extends Error { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(requestConfig: AxiosRequestConfig) { + const msg = 'Request to external object storage failed'; + // const config = jsonStringify(requestConfig); + + // super([msg, config].join(': ')); + super([msg].join(': ')); + } + } +} From e22f0bf6147de97c6667fa33c73e00ad1fc3c6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:27:31 +0200 Subject: [PATCH 099/259] Remove unused --- packages/core/src/ObjectStore/utils.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts index a9df2c98e14db..4f8f8cd7720c0 100644 --- a/packages/core/src/ObjectStore/utils.ts +++ b/packages/core/src/ObjectStore/utils.ts @@ -3,17 +3,3 @@ import { Stream } from 'node:stream'; export function isStream(maybeStream: unknown): maybeStream is Stream { return maybeStream instanceof Stream; } - -// @TODO: Add more info to errors - -export class DownloadTypeError extends TypeError { - constructor(expectedType: 'stream' | 'buffer', actualType: string) { - super(`Expected ${expectedType} but received ${actualType} from external storage download.`); - } -} - -export class RequestToObjectStorageFailed extends Error { - constructor() { - super('Request to external object storage failed'); - } -} From d5c893586068959d3c7b588cbe3252c3cc0ee2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:35:10 +0200 Subject: [PATCH 100/259] Cleanup --- .../src/ObjectStore/ObjectStore.service.ee.ts | 126 +++++++++++++----- 1 file changed, 92 insertions(+), 34 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 118a449f8f987..6466fab84ed72 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -1,67 +1,125 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; -import { isStream, DownloadTypeError, RequestToObjectStorageFailed } from './utils'; -import type { AxiosPromise, Method } from 'axios'; +import { isStream } from './utils'; +import { ObjectStorageError } from './errors'; +import { createHash } from 'node:crypto'; +import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; -/** - * `/workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` - */ - @Service() export class ObjectStoreService { + private credentials: Aws4Credentials; + constructor( private bucket: { region: string; name: string }, - private credentials: { accountId: string; secretKey: string }, + credentials: { accountId: string; secretKey: string }, ) { - // @TODO: Confirm connection + this.credentials = { + accessKeyId: credentials.accountId, + secretAccessKey: credentials.secretKey, + }; + } + + /** + * Confirm that the configured bucket exists and that the caller has permission to access it. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html + */ + async checkConnection() { + const host = `s3.${this.bucket.region}.amazonaws.com`; + + return this.request('HEAD', host, this.bucket.name); // @TODO: Not working with Axios + } + + /** + * Upload an object to the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + */ + async put(filename: string, buffer: Buffer) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const headers = { + 'Content-Length': buffer.length, + 'Content-Type': 'image/jpg', // @TODO: Derive + 'Content-MD5': createHash('md5').update(buffer).digest('base64'), + }; + + return this.request('PUT', host, `/${filename}`, { body: buffer, headers }); } - async getStream(objectPath: string) { - const result = await this.request('GET', objectPath, { mode: 'stream' }); + /** + * Download an object as a stream from the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + */ + async getStream(path: string) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const { data } = await this.request('GET', host, path, { responseType: 'stream' }); - if (isStream(result)) return result; + if (isStream(data)) return data; - throw new DownloadTypeError('stream', typeof result); + throw new ObjectStorageError.TypeMismatch('stream', typeof data); } - async getBuffer(objectPath: string) { - const result = await this.request('GET', objectPath, { mode: 'buffer' }); + /** + * Download an object as a buffer from the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + */ + async getBuffer(path: string) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const { data } = await this.request('GET', host, path, { responseType: 'arraybuffer' }); - if (Buffer.isBuffer(result)) return result; + if (Buffer.isBuffer(data)) return data; - throw new DownloadTypeError('buffer', typeof result); + throw new ObjectStorageError.TypeMismatch('buffer', typeof data); } private async request( method: Method, - objectPath: string, - { mode }: { mode: 'stream' | 'buffer' }, + host: string, + path: string, + { + headers, + responseType, + }: { + headers?: Record; + body?: Buffer; + responseType?: ResponseType; + } = {}, ) { - // @TODO Decouple host from AWS - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + const slashPath = path.startsWith('/') ? path : `/${path}`; - const options: Aws4Options = { + const optionsToSign: Aws4Options = { host, - path: `/${objectPath}`, - }; - - const credentials: Aws4Credentials = { - accessKeyId: this.credentials.accountId, - secretAccessKey: this.credentials.secretKey, + path: slashPath, + ...headers, }; - const signed = sign(options, credentials); + const signedOptions = sign(optionsToSign, this.credentials); - const response: Awaited> = await axios(`https://${host}/${objectPath}`, { + const config: AxiosRequestConfig = { method, - headers: signed.headers, - responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', - }); + url: `https://${host}${slashPath}`, + headers: signedOptions.headers, + }; + + if (responseType) config.responseType = responseType; - if (response.status !== 200) throw new RequestToObjectStorageFailed(); + console.log('Axios request config', config); // @TODO: Remove - return response.data; + try { + return await axios.request(config); + } catch (error) { + if (error instanceof Error) console.log(error.message); // @TODO: Remove + // throw new ObjectStorageError.RequestFailed(config); // @TODO: Restore + throw error; + } } } From 55b6b6fe3ac44ff7dad8fa4f40a07aab483e36b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:52:21 +0200 Subject: [PATCH 101/259] Fix `HEAD` request --- packages/cli/src/commands/BaseCommand.ts | 23 +++++++++---------- .../src/ObjectStore/ObjectStore.service.ee.ts | 17 ++++++++++---- packages/core/src/ObjectStore/errors.ts | 6 +++++ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 5e2288bcf1248..81c210ccc0784 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs'; -import { pipeline } from 'node:stream/promises'; +// import fs from 'node:fs'; +// import { pipeline } from 'node:stream/promises'; // import { readFile } from 'node:fs/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; @@ -126,17 +126,16 @@ export abstract class BaseCommand extends Command { // const buffer = Buffer.from(await readFile(filePath)); // await objectStoreService.put('object-store-service-happy-dog.jpg', buffer); - // await objectStoreService.checkConnection(); + await objectStoreService.checkConnection(); - const stream = await objectStoreService.getStream('happy-dog.jpg'); - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); - console.log('✅ Pipeline succeeded'); - } catch (error) { - console.log('❌ Pipeline failed', error); - } + // const stream = await objectStoreService.getStream('happy-dog.jpg'); + // try { + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); + // console.log('✅ Pipeline succeeded'); + // } catch (error) { + // console.log('❌ Pipeline failed', error); + // } const binaryDataConfig = config.getEnv('binaryDataService'); await Container.get(BinaryDataService).init(binaryDataConfig); diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 6466fab84ed72..478c02bff046c 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -24,14 +24,18 @@ export class ObjectStoreService { } /** - * Confirm that the configured bucket exists and that the caller has permission to access it. + * Confirm that the configured bucket exists and the caller has permission to access it. * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html */ async checkConnection() { - const host = `s3.${this.bucket.region}.amazonaws.com`; + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - return this.request('HEAD', host, this.bucket.name); // @TODO: Not working with Axios + try { + return await this.request('HEAD', host); + } catch { + throw new ObjectStorageError.ConnectionFailed(); + } } /** @@ -84,7 +88,7 @@ export class ObjectStoreService { private async request( method: Method, host: string, - path: string, + path = '', { headers, responseType, @@ -97,6 +101,9 @@ export class ObjectStoreService { const slashPath = path.startsWith('/') ? path : `/${path}`; const optionsToSign: Aws4Options = { + method, + service: 's3', + region: this.bucket.region, host, path: slashPath, ...headers, @@ -118,8 +125,8 @@ export class ObjectStoreService { return await axios.request(config); } catch (error) { if (error instanceof Error) console.log(error.message); // @TODO: Remove + throw error; // @TODO: Remove // throw new ObjectStorageError.RequestFailed(config); // @TODO: Restore - throw error; } } } diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts index ae19b6c41e75b..77dc7ce2ebf2c 100644 --- a/packages/core/src/ObjectStore/errors.ts +++ b/packages/core/src/ObjectStore/errors.ts @@ -8,6 +8,12 @@ export namespace ObjectStorageError { } } + export class ConnectionFailed extends TypeError { + constructor() { + super('Failed to connect to external storage. Please recheck your credentials.'); + } + } + export class RequestFailed extends Error { // eslint-disable-next-line @typescript-eslint/no-unused-vars constructor(requestConfig: AxiosRequestConfig) { From c33d164913ba3c231b6489de55b20b8f725a4e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 11:54:50 +0200 Subject: [PATCH 102/259] More missing loggers in tests --- .../cli/test/integration/workflows.controller.ee.test.ts | 5 +++++ packages/cli/test/integration/workflows.controller.test.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index 6c63bbc8ae9ff..4833c36c1e2e6 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -13,6 +13,9 @@ import type { SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils/'; import { randomCredentialPayload } from './shared/random'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + let owner: User; let member: User; let anotherMember: User; @@ -21,6 +24,8 @@ let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +LoggerProxy.init(getLogger()); + const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index fb56237fd7a6b..db4298b933503 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -12,9 +12,14 @@ import { RoleService } from '@/services/role.service'; import Container from 'typedi'; import type { ListQuery } from '@/requests'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + let owner: User; let authOwnerAgent: SuperAgentTest; +LoggerProxy.init(getLogger()); + jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); From 69764f7b9f1d0f913c0cca95b840b227cf65c67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 12:39:59 +0200 Subject: [PATCH 103/259] Add logger to even more test --- packages/cli/test/integration/audit/credentials.risk.test.ts | 5 +++++ packages/cli/test/integration/credentials.controller.test.ts | 5 +++++ packages/cli/test/integration/credentials.ee.test.ts | 5 +++++ packages/cli/test/integration/credentials.test.ts | 4 ++++ packages/cli/test/integration/publicApi/credentials.test.ts | 5 +++++ 5 files changed, 24 insertions(+) diff --git a/packages/cli/test/integration/audit/credentials.risk.test.ts b/packages/cli/test/integration/audit/credentials.risk.test.ts index 10d1d9ecbd042..49a399841870a 100644 --- a/packages/cli/test/integration/audit/credentials.risk.test.ts +++ b/packages/cli/test/integration/audit/credentials.risk.test.ts @@ -7,6 +7,11 @@ import { getRiskSection } from './utils'; import * as testDb from '../shared/testDb'; import { generateNanoId } from '@db/utils/generators'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeAll(async () => { await testDb.init(); }); diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials.controller.test.ts index 1b55c71d46c74..cab87149b12f3 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials.controller.test.ts @@ -12,6 +12,11 @@ const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; let member: User; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'Credentials']); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index a1dc4f7be00b5..e9fa4f088f268 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -14,6 +14,11 @@ import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 526d3bb28c77b..72c0aea730c2e 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -12,6 +12,10 @@ import { randomCredentialPayload, randomName, randomString } from './shared/rand import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); // mock that credentialsSharing is not enabled jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index bc5c414142a17..fc576a9f7eed7 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -10,6 +10,11 @@ import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + let globalMemberRole: Role; let credentialOwnerRole: Role; let owner: User; From 6b313ee2dcb512866ab9bd7143b63c4ab6de6758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:12:32 +0200 Subject: [PATCH 104/259] Fix upload --- packages/cli/src/commands/BaseCommand.ts | 3 ++- .../core/src/ObjectStore/ObjectStore.service.ee.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 81c210ccc0784..d29f321882f6a 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -124,7 +124,8 @@ export abstract class BaseCommand extends Command { // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; // const buffer = Buffer.from(await readFile(filePath)); - // await objectStoreService.put('object-store-service-happy-dog.jpg', buffer); + // const res = await objectStoreService.put('object-store-service-dog.jpg', buffer); + // console.log('upload result', res.status); await objectStoreService.checkConnection(); diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 478c02bff046c..af530db2de04f 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -48,11 +48,10 @@ export class ObjectStoreService { const headers = { 'Content-Length': buffer.length, - 'Content-Type': 'image/jpg', // @TODO: Derive 'Content-MD5': createHash('md5').update(buffer).digest('base64'), }; - return this.request('PUT', host, `/${filename}`, { body: buffer, headers }); + return this.request('PUT', host, `/${filename}`, { headers, body: buffer }); } /** @@ -91,6 +90,7 @@ export class ObjectStoreService { path = '', { headers, + body, responseType, }: { headers?: Record; @@ -106,9 +106,11 @@ export class ObjectStoreService { region: this.bucket.region, host, path: slashPath, - ...headers, }; + if (headers) optionsToSign.headers = headers; + if (body) optionsToSign.body = body; + const signedOptions = sign(optionsToSign, this.credentials); const config: AxiosRequestConfig = { @@ -117,13 +119,13 @@ export class ObjectStoreService { headers: signedOptions.headers, }; + if (body) config.data = body; if (responseType) config.responseType = responseType; - console.log('Axios request config', config); // @TODO: Remove - try { return await axios.request(config); } catch (error) { + console.log('Axios error', error); if (error instanceof Error) console.log(error.message); // @TODO: Remove throw error; // @TODO: Remove // throw new ObjectStorageError.RequestFailed(config); // @TODO: Restore From 7c3e58db2f8cb80983d885fc6096207089287941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:28:53 +0200 Subject: [PATCH 105/259] Refactor logging for tests --- packages/cli/test/integration/audit/database.risk.test.ts | 5 +++++ packages/cli/test/integration/audit/filesystem.risk.test.ts | 5 +++++ packages/cli/test/integration/audit/instance.risk.test.ts | 5 +++++ packages/cli/test/integration/audit/nodes.risk.test.ts | 5 +++++ packages/cli/test/integration/commands/import.cmd.test.ts | 5 +++++ .../cli/test/integration/credentials.controller.test.ts | 5 ----- packages/cli/test/integration/credentials.ee.test.ts | 5 ----- packages/cli/test/integration/credentials.test.ts | 4 ---- packages/cli/test/integration/executions.controller.test.ts | 4 ---- packages/cli/test/integration/ldap/ldap.api.test.ts | 5 +++++ packages/cli/test/integration/publicApi/credentials.test.ts | 5 ----- packages/cli/test/integration/publicApi/workflows.test.ts | 4 ---- packages/cli/test/integration/shared/utils/testServer.ts | 6 +++--- .../cli/test/integration/workflows.controller.ee.test.ts | 5 ----- packages/cli/test/integration/workflows.controller.test.ts | 5 ----- packages/cli/test/unit/PermissionChecker.test.ts | 5 +++++ 16 files changed, 38 insertions(+), 40 deletions(-) diff --git a/packages/cli/test/integration/audit/database.risk.test.ts b/packages/cli/test/integration/audit/database.risk.test.ts index a4068f5d77704..b2e485663feb4 100644 --- a/packages/cli/test/integration/audit/database.risk.test.ts +++ b/packages/cli/test/integration/audit/database.risk.test.ts @@ -10,6 +10,11 @@ import { getRiskSection, saveManualTriggerWorkflow } from './utils'; import * as testDb from '../shared/testDb'; import { generateNanoId } from '@db/utils/generators'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeAll(async () => { await testDb.init(); }); diff --git a/packages/cli/test/integration/audit/filesystem.risk.test.ts b/packages/cli/test/integration/audit/filesystem.risk.test.ts index d8f3e711e7b55..33d0ef8fa5782 100644 --- a/packages/cli/test/integration/audit/filesystem.risk.test.ts +++ b/packages/cli/test/integration/audit/filesystem.risk.test.ts @@ -5,6 +5,11 @@ import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/co import { getRiskSection, saveManualTriggerWorkflow } from './utils'; import * as testDb from '../shared/testDb'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeAll(async () => { await testDb.init(); }); diff --git a/packages/cli/test/integration/audit/instance.risk.test.ts b/packages/cli/test/integration/audit/instance.risk.test.ts index aa186c4d7cfc3..349f5de38eb46 100644 --- a/packages/cli/test/integration/audit/instance.risk.test.ts +++ b/packages/cli/test/integration/audit/instance.risk.test.ts @@ -14,6 +14,11 @@ import { toReportTitle } from '@/audit/utils'; import config from '@/config'; import { generateNanoId } from '@db/utils/generators'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeAll(async () => { await testDb.init(); diff --git a/packages/cli/test/integration/audit/nodes.risk.test.ts b/packages/cli/test/integration/audit/nodes.risk.test.ts index fb966d23697fb..dbe414a951930 100644 --- a/packages/cli/test/integration/audit/nodes.risk.test.ts +++ b/packages/cli/test/integration/audit/nodes.risk.test.ts @@ -11,6 +11,11 @@ import { NodeTypes } from '@/NodeTypes'; import { CommunityPackageService } from '@/services/communityPackage.service'; import Container from 'typedi'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + const nodesAndCredentials = mockInstance(LoadNodesAndCredentials); nodesAndCredentials.getCustomDirectories.mockReturnValue([]); mockInstance(NodeTypes); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index 750ed86b440f7..a191267f622be 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -4,6 +4,11 @@ import { InternalHooks } from '@/InternalHooks'; import { ImportWorkflowsCommand } from '@/commands/import/workflow'; import * as Config from '@oclif/config'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + beforeAll(async () => { mockInstance(InternalHooks); await testDb.init(); diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials.controller.test.ts index cab87149b12f3..1b55c71d46c74 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials.controller.test.ts @@ -12,11 +12,6 @@ const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; let member: User; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - -LoggerProxy.init(getLogger()); - beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'Credentials']); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index e9fa4f088f268..a1dc4f7be00b5 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -14,11 +14,6 @@ import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - -LoggerProxy.init(getLogger()); - const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 72c0aea730c2e..526d3bb28c77b 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -12,10 +12,6 @@ import { randomCredentialPayload, randomName, randomString } from './shared/rand import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - -LoggerProxy.init(getLogger()); // mock that credentialsSharing is not enabled jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index bc2f783674a1c..2af9ec5b8597d 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -1,10 +1,6 @@ import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; import type { User } from '@/databases/entities/User'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - -LoggerProxy.init(getLogger()); let testServer = setupTestServer({ endpointGroups: ['executions'] }); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 8b00566d1187d..1a3e23cd8ccf8 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -17,6 +17,9 @@ import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import * as utils from '../shared/utils/'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + jest.mock('@/telemetry'); jest.mock('@/UserManagement/email/NodeMailer'); @@ -24,6 +27,8 @@ let globalMemberRole: Role; let owner: User; let authOwnerAgent: SuperAgentTest; +LoggerProxy.init(getLogger()); + const defaultLdapConfig = { ...LDAP_DEFAULT_CONFIGURATION, loginEnabled: true, diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index fc576a9f7eed7..bc5c414142a17 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -10,11 +10,6 @@ import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - -LoggerProxy.init(getLogger()); - let globalMemberRole: Role; let credentialOwnerRole: Role; let owner: User; diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 980986cf96d44..910bfbb3970c8 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -10,8 +10,6 @@ import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; let workflowOwnerRole: Role; let owner: User; @@ -20,8 +18,6 @@ let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; -LoggerProxy.init(getLogger()); - const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index b33c3afb04387..21e381ff830ee 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -141,6 +141,9 @@ export const setupTestServer = ({ app.use(rawBodyReader); app.use(cookieParser()); + const logger = getLogger(); + LoggerProxy.init(logger); + const testServer: TestServer = { app, httpServer: app.listen(0), @@ -152,9 +155,6 @@ export const setupTestServer = ({ beforeAll(async () => { await testDb.init(); - const logger = getLogger(); - LoggerProxy.init(logger); - // Mock all telemetry. mockInstance(InternalHooks); mockInstance(PostHogClient); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index 4833c36c1e2e6..6c63bbc8ae9ff 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -13,9 +13,6 @@ import type { SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils/'; import { randomCredentialPayload } from './shared/random'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - let owner: User; let member: User; let anotherMember: User; @@ -24,8 +21,6 @@ let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; -LoggerProxy.init(getLogger()); - const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index db4298b933503..fb56237fd7a6b 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -12,14 +12,9 @@ import { RoleService } from '@/services/role.service'; import Container from 'typedi'; import type { ListQuery } from '@/requests'; -import { LoggerProxy } from 'n8n-workflow'; -import { getLogger } from '@/Logger'; - let owner: User; let authOwnerAgent: SuperAgentTest; -LoggerProxy.init(getLogger()); - jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts index ab1443c0c7ee3..139cf0571003b 100644 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ b/packages/cli/test/unit/PermissionChecker.test.ts @@ -25,6 +25,11 @@ import type { SaveCredentialFunction } from '../integration/shared/types'; import { mockInstance } from '../integration/shared/utils/'; import { OwnershipService } from '@/services/ownership.service'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; + +LoggerProxy.init(getLogger()); + let mockNodeTypes: INodeTypes; let credentialOwnerRole: Role; let workflowOwnerRole: Role; From 7f6d66ae551f675b3b4b1c6c427858c51a75dcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:34:48 +0200 Subject: [PATCH 106/259] Consolidate `getStream` and `getBuffer` --- packages/cli/bin/happy-dog.jpg | Bin 0 -> 137084 bytes packages/cli/src/commands/BaseCommand.ts | 2 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 28 ++++++------------ 3 files changed, 10 insertions(+), 20 deletions(-) create mode 100644 packages/cli/bin/happy-dog.jpg diff --git a/packages/cli/bin/happy-dog.jpg b/packages/cli/bin/happy-dog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c8acc96f6b099519679e781023d935a8da8d9a48 GIT binary patch literal 137084 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�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 z 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 literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index d29f321882f6a..90a803d5e69c2 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -129,7 +129,7 @@ export abstract class BaseCommand extends Command { await objectStoreService.checkConnection(); - // const stream = await objectStoreService.getStream('happy-dog.jpg'); + // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); // try { // // eslint-disable-next-line @typescript-eslint/no-explicit-any // await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index af530db2de04f..d8454a7f12671 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -9,6 +9,8 @@ import { createHash } from 'node:crypto'; import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; +// @TODO: Decouple from AWS + @Service() export class ObjectStoreService { private credentials: Aws4Credentials; @@ -55,33 +57,21 @@ export class ObjectStoreService { } /** - * Download an object as a stream from the configured bucket. + * Download an object as a stream or buffer from the configured bucket. * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ - async getStream(path: string) { + async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + const responseType = mode === 'buffer' ? 'arraybuffer' : 'stream'; - const { data } = await this.request('GET', host, path, { responseType: 'stream' }); - - if (isStream(data)) return data; - - throw new ObjectStorageError.TypeMismatch('stream', typeof data); - } - - /** - * Download an object as a buffer from the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html - */ - async getBuffer(path: string) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + const { data } = await this.request('GET', host, path, { responseType }); - const { data } = await this.request('GET', host, path, { responseType: 'arraybuffer' }); + if (mode === 'stream' && isStream(data)) return data; - if (Buffer.isBuffer(data)) return data; + if (mode === 'buffer' && Buffer.isBuffer(data)) return data; - throw new ObjectStorageError.TypeMismatch('buffer', typeof data); + throw new ObjectStorageError.TypeMismatch(mode, typeof data); } private async request( From 3408a316d36a5ca2cb6f517600f8527c5b0789e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:36:22 +0200 Subject: [PATCH 107/259] Cleanup --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index d8454a7f12671..74006f527f23f 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -63,9 +63,10 @@ export class ObjectStoreService { */ async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const responseType = mode === 'buffer' ? 'arraybuffer' : 'stream'; - const { data } = await this.request('GET', host, path, { responseType }); + const { data } = await this.request('GET', host, path, { + responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', + }); if (mode === 'stream' && isStream(data)) return data; From 2aeb9acf7ba2ae12a198c69dcca341b47ff8de88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:55:41 +0200 Subject: [PATCH 108/259] Support single deletion --- packages/cli/src/commands/BaseCommand.ts | 2 ++ .../src/ObjectStore/ObjectStore.service.ee.ts | 30 ++++++++++++++----- packages/core/src/ObjectStore/errors.ts | 27 ----------------- 3 files changed, 24 insertions(+), 35 deletions(-) delete mode 100644 packages/core/src/ObjectStore/errors.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 90a803d5e69c2..9425f31e1cc4e 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -129,6 +129,8 @@ export abstract class BaseCommand extends Command { await objectStoreService.checkConnection(); + // await objectStoreService.delete('object-store-service-dog.jpg'); + // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); // try { // // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 74006f527f23f..2af7ff31ccccc 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -4,7 +4,6 @@ import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream } from './utils'; -import { ObjectStorageError } from './errors'; import { createHash } from 'node:crypto'; import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; @@ -35,8 +34,9 @@ export class ObjectStoreService { try { return await this.request('HEAD', host); - } catch { - throw new ObjectStorageError.ConnectionFailed(); + } catch (error) { + const msg = 'Failed to connect to external storage. Please recheck your credentials.'; + throw new Error(msg, { cause: error as unknown }); } } @@ -72,7 +72,20 @@ export class ObjectStoreService { if (mode === 'buffer' && Buffer.isBuffer(data)) return data; - throw new ObjectStorageError.TypeMismatch(mode, typeof data); + throw new TypeError(`Expected ${mode} but received ${typeof data}.`); + } + + /** + * Delete an object in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + */ + async delete(filename: string) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const path = `/${encodeURIComponent(filename)}`; + + return this.request('DELETE', host, path); } private async request( @@ -113,13 +126,14 @@ export class ObjectStoreService { if (body) config.data = body; if (responseType) config.responseType = responseType; + console.log(config); + try { return await axios.request(config); } catch (error) { - console.log('Axios error', error); - if (error instanceof Error) console.log(error.message); // @TODO: Remove - throw error; // @TODO: Remove - // throw new ObjectStorageError.RequestFailed(config); // @TODO: Restore + throw new Error('Request to external object storage failed', { + cause: { error: error as unknown, details: config }, + }); } } } diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts deleted file mode 100644 index 77dc7ce2ebf2c..0000000000000 --- a/packages/core/src/ObjectStore/errors.ts +++ /dev/null @@ -1,27 +0,0 @@ -// import { jsonStringify } from 'n8n-workflow'; -import type { AxiosRequestConfig } from 'axios'; - -export namespace ObjectStorageError { - export class TypeMismatch extends TypeError { - constructor(expectedType: 'stream' | 'buffer', actualType: string) { - super(`Expected ${expectedType} but received ${actualType} from external storage download.`); - } - } - - export class ConnectionFailed extends TypeError { - constructor() { - super('Failed to connect to external storage. Please recheck your credentials.'); - } - } - - export class RequestFailed extends Error { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - constructor(requestConfig: AxiosRequestConfig) { - const msg = 'Request to external object storage failed'; - // const config = jsonStringify(requestConfig); - - // super([msg, config].join(': ')); - super([msg].join(': ')); - } - } -} From cc60aeb58b85550092fcdd0a6bcab4328b531e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:56:39 +0200 Subject: [PATCH 109/259] Cleanup --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 2af7ff31ccccc..c59ccbccbf354 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -80,12 +80,10 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ - async delete(filename: string) { + async delete(path: string) { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const path = `/${encodeURIComponent(filename)}`; - - return this.request('DELETE', host, path); + return this.request('DELETE', host, `/${encodeURIComponent(path)}`); } private async request( From e52ca88a6934541bea15613578f9571f9cd4def3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 13:57:19 +0200 Subject: [PATCH 110/259] More cleanup --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index c59ccbccbf354..3b4f87efccad0 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -32,12 +32,7 @@ export class ObjectStoreService { async checkConnection() { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - try { - return await this.request('HEAD', host); - } catch (error) { - const msg = 'Failed to connect to external storage. Please recheck your credentials.'; - throw new Error(msg, { cause: error as unknown }); - } + return this.request('HEAD', host); } /** From 6008c41c1feebbc5855adbeabe2a8df0a2f82ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 14:02:38 +0200 Subject: [PATCH 111/259] Remove sample image --- packages/cli/bin/happy-dog.jpg | Bin 137084 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/cli/bin/happy-dog.jpg diff --git a/packages/cli/bin/happy-dog.jpg b/packages/cli/bin/happy-dog.jpg deleted file mode 100644 index c8acc96f6b099519679e781023d935a8da8d9a48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 137084 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�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 z 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 From f1b0ca6756dab1667cdaba4190d33b49382dc469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 19:51:20 +0200 Subject: [PATCH 112/259] Add listing --- packages/cli/src/commands/BaseCommand.ts | 3 + packages/core/package.json | 6 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 76 ++++++++++++++++++- packages/core/src/ObjectStore/types.ts | 21 +++++ packages/core/src/ObjectStore/utils.ts | 11 +++ pnpm-lock.yaml | 6 ++ 6 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/ObjectStore/types.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 9425f31e1cc4e..4de99bc5be860 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -129,6 +129,9 @@ export abstract class BaseCommand extends Command { await objectStoreService.checkConnection(); + // const res = await objectStoreService.list('uploaded'); + // console.log('res', res); + // await objectStoreService.delete('object-store-service-dog.jpg'); // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); diff --git a/packages/core/package.json b/packages/core/package.json index 08b0c71fa767e..f5d65cc284e4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,7 +44,8 @@ "@types/lodash": "^4.14.195", "@types/mime-types": "^2.1.0", "@types/request-promise-native": "~1.0.15", - "@types/uuid": "^8.3.2" + "@types/uuid": "^8.3.2", + "@types/xml2js": "^0.4.11" }, "peerDependencies": { "n8n-nodes-base": "workspace:*" @@ -70,6 +71,7 @@ "pretty-bytes": "^5.6.0", "qs": "^6.10.1", "typedi": "^0.10.0", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "xml2js": "^0.5.0" } } diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 3b4f87efccad0..883fa2255f477 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -3,12 +3,13 @@ import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; -import { isStream } from './utils'; +import { isStream, parseXml } from './utils'; import { createHash } from 'node:crypto'; import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; +import type { ListPage, RawListPage } from './types'; -// @TODO: Decouple from AWS +// @TODO: Decouple host from AWS @Service() export class ObjectStoreService { @@ -81,21 +82,87 @@ export class ObjectStoreService { return this.request('DELETE', host, `/${encodeURIComponent(path)}`); } + /** + * List all objects with a common prefix in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + */ + async list(prefix: string) { + const items = []; + + let isTruncated; + let token; // for next page + + do { + const listPage = await this.getListPage(prefix, token); + + if (listPage.contents?.length > 0) items.push(...listPage.contents); + + isTruncated = listPage.isTruncated; + token = listPage.nextContinuationToken; + } while (isTruncated && token); + + return items; + } + + /** + * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. + */ + private async getListPage(prefix: string, nextPageToken?: string) { + const host = `s3.${this.bucket.region}.amazonaws.com`; + + const qs: Record = { 'list-type': 2, prefix }; + + if (nextPageToken) qs['continuation-token'] = nextPageToken; + + const response = await this.request('GET', host, `/${this.bucket.name}`, { qs }); + + if (typeof response.data !== 'string') { + throw new TypeError('Expected string'); + } + + const { listBucketResult: listPage } = await parseXml(response.data); + + // restore array wrapper removed by `explicitArray: false` on single item array + + if (!Array.isArray(listPage.contents)) { + listPage.contents = [listPage.contents]; + } + + // remove null prototype - https://github.com/Leonidas-from-XIV/node-xml2js/issues/670 + + listPage.contents.forEach((item) => { + Object.setPrototypeOf(item, Object.prototype); + }); + + return listPage as ListPage; + } + private async request( method: Method, host: string, path = '', { + qs, headers, body, responseType, }: { + qs?: Record; headers?: Record; body?: Buffer; responseType?: ResponseType; } = {}, ) { - const slashPath = path.startsWith('/') ? path : `/${path}`; + let slashPath = path.startsWith('/') ? path : `/${path}`; + + if (qs) { + const qsParams = Object.keys(qs) + .map((key) => `${key}=${qs[key]}`) + .join('&'); + + slashPath = slashPath.concat(`?${qsParams}`); + } const optionsToSign: Aws4Options = { method, @@ -119,11 +186,12 @@ export class ObjectStoreService { if (body) config.data = body; if (responseType) config.responseType = responseType; - console.log(config); + // console.log(config); try { return await axios.request(config); } catch (error) { + // console.log(error); throw new Error('Request to external object storage failed', { cause: { error: error as unknown, details: config }, }); diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts new file mode 100644 index 0000000000000..1bd6ba0084cd9 --- /dev/null +++ b/packages/core/src/ObjectStore/types.ts @@ -0,0 +1,21 @@ +export type RawListPage = { + listBucketResult: { + name: string; + prefix: string; + keyCount: number; + maxKeys: number; + isTruncated: boolean; + nextContinuationToken?: string; // only if isTruncated is true + contents: Item | Item[]; + }; +}; + +type Item = { + key: string; + lastModified: string; + eTag: string; + size: number; + storageClass: string; +}; + +export type ListPage = Omit & { contents: Item[] }; diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts index 4f8f8cd7720c0..76dcb1f076b1d 100644 --- a/packages/core/src/ObjectStore/utils.ts +++ b/packages/core/src/ObjectStore/utils.ts @@ -1,5 +1,16 @@ import { Stream } from 'node:stream'; +import { parseStringPromise } from 'xml2js'; +import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors'; export function isStream(maybeStream: unknown): maybeStream is Stream { return maybeStream instanceof Stream; } + +export async function parseXml(xml: string): Promise { + return parseStringPromise(xml, { + explicitArray: false, + ignoreAttrs: true, + tagNameProcessors: [firstCharLowerCase], + valueProcessors: [parseNumbers, parseBooleans], + }) as Promise; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01779a6e74d4b..5302ed0fd84aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,9 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 + xml2js: + specifier: ^0.5.0 + version: 0.5.0 devDependencies: '@types/aws4': specifier: ^1.5.1 @@ -667,6 +670,9 @@ importers: '@types/uuid': specifier: ^8.3.2 version: 8.3.4 + '@types/xml2js': + specifier: ^0.4.11 + version: 0.4.11 packages/design-system: dependencies: From d82749b7b3925697b6ccb6e988f3fc81479c56eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 20:14:20 +0200 Subject: [PATCH 113/259] Implement `deleteMany` --- packages/cli/src/commands/BaseCommand.ts | 3 + .../src/ObjectStore/ObjectStore.service.ee.ts | 56 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 4de99bc5be860..87b85ded00d32 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -129,6 +129,9 @@ export abstract class BaseCommand extends Command { await objectStoreService.checkConnection(); + // const res = await objectStoreService.deleteMany('uploaded'); + // console.log('res', res); + // const res = await objectStoreService.list('uploaded'); // console.log('res', res); diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 883fa2255f477..6f5ab94fed2fd 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -45,6 +45,7 @@ export class ObjectStoreService { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; const headers = { + // derive content-type from filename 'Content-Length': buffer.length, 'Content-MD5': createHash('md5').update(buffer).digest('base64'), }; @@ -83,7 +84,30 @@ export class ObjectStoreService { } /** - * List all objects with a common prefix in the configured bucket. + * Delete objects with a common prefix in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html + */ + async deleteMany(prefix: string) { + const objects = await this.list(prefix); + + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const innerXml = objects.map((o) => `${o.key}`).join('\n'); + + const body = ['', innerXml, ''].join('\n'); + + const headers = { + 'Content-Type': 'application/xml', + 'Content-Length': body.length, + 'Content-MD5': createHash('md5').update(body).digest('base64'), + }; + + return this.request('POST', host, '/?delete', { headers, body }); + } + + /** + * List objects with a common prefix in the configured bucket. * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html */ @@ -138,10 +162,22 @@ export class ObjectStoreService { return listPage as ListPage; } + private toPath(rawPath: string, qs?: Record) { + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + + if (!qs) return path; + + const qsParams = Object.keys(qs) + .map((key) => `${key}=${qs[key]}`) + .join('&'); + + return path.concat(`?${qsParams}`); + } + private async request( method: Method, host: string, - path = '', + rawPath = '', { qs, headers, @@ -150,26 +186,18 @@ export class ObjectStoreService { }: { qs?: Record; headers?: Record; - body?: Buffer; + body?: string | Buffer; responseType?: ResponseType; } = {}, ) { - let slashPath = path.startsWith('/') ? path : `/${path}`; - - if (qs) { - const qsParams = Object.keys(qs) - .map((key) => `${key}=${qs[key]}`) - .join('&'); - - slashPath = slashPath.concat(`?${qsParams}`); - } + const path = this.toPath(rawPath, qs); const optionsToSign: Aws4Options = { method, service: 's3', region: this.bucket.region, host, - path: slashPath, + path, }; if (headers) optionsToSign.headers = headers; @@ -179,7 +207,7 @@ export class ObjectStoreService { const config: AxiosRequestConfig = { method, - url: `https://${host}${slashPath}`, + url: `https://${host}${path}`, headers: signedOptions.headers, }; From 9e43623a61cb53dcd3f7906227840d40499c9df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 19 Sep 2023 20:21:23 +0200 Subject: [PATCH 114/259] Rename arg --- packages/core/src/BinaryData/ObjectStore.manager.ee.ts | 2 +- packages/core/src/BinaryData/types.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index b82082b63ade9..0d7a42c7388bb 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -19,7 +19,7 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async getSize(path: string): Promise { + async getSize(identifier: string): Promise { throw new Error('TODO'); } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index c843f14d30188..73222254149c1 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -22,8 +22,7 @@ export namespace BinaryData { store(binaryData: Buffer | Readable, executionId: string): Promise; getPath(identifier: string): string; - // @TODO: Refactor to use identifier - getSize(path: string): Promise; + getSize(identifier: string): Promise; getBuffer(identifier: string): Promise; getStream(identifier: string, chunkSize?: number): Readable; From 09ace0ca12eaaf39d7909e10975352149612b0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 09:23:06 +0200 Subject: [PATCH 115/259] Major refactor to keep reducing interface --- .../core/src/BinaryData/BinaryData.service.ts | 67 +++++++-------- .../core/src/BinaryData/FileSystem.manager.ts | 82 +++++++++++-------- .../src/BinaryData/ObjectStore.manager.ee.ts | 26 +++--- packages/core/src/BinaryData/types.ts | 36 ++++---- packages/core/src/BinaryData/utils.ts | 4 +- packages/core/src/NodeExecuteFunctions.ts | 4 +- packages/workflow/src/Interfaces.ts | 12 ++- 7 files changed, 124 insertions(+), 107 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 629022ac36fd8..b0ae78f5c5994 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -5,7 +5,7 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; -import { InvalidBinaryDataManagerError, InvalidBinaryDataModeError, areValidModes } from './utils'; +import { InvalidBinaryDataManager, InvalidBinaryDataMode, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -20,7 +20,7 @@ export class BinaryDataService { private managers: Record = {}; async init(config: BinaryData.Config) { - if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataModeError(); + if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataMode(); this.availableModes = config.availableModes; this.mode = config.mode; @@ -37,25 +37,21 @@ export class BinaryDataService { if (!manager) { const { size } = await stat(path); - binaryData.fileSize = prettyBytes(size); binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); + binaryData.fileSize = prettyBytes(size); return binaryData; } - const identifier = await manager.copyByPath(path, executionId); - binaryData.id = this.createIdentifier(identifier); - binaryData.data = this.mode; // clear binary data from memory - - const fileSize = await manager.getSize(identifier); - binaryData.fileSize = prettyBytes(fileSize); - - await manager.storeMetadata(identifier, { + const { fileId, fileSize } = await manager.copyByFilePath(path, executionId, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, - fileSize, }); + binaryData.id = this.createBinaryDataId(fileId); + binaryData.fileSize = prettyBytes(fileSize); + binaryData.data = this.mode; // clear binary data from memory + return binaryData; } @@ -70,19 +66,15 @@ export class BinaryDataService { return binaryData; } - const identifier = await manager.store(input, executionId); - binaryData.id = this.createIdentifier(identifier); - binaryData.data = this.mode; // clear binary data from memory - - const fileSize = await manager.getSize(identifier); - binaryData.fileSize = prettyBytes(fileSize); - - await manager.storeMetadata(identifier, { + const { fileId, fileSize } = await manager.store(input, executionId, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, - fileSize, }); + binaryData.id = this.createBinaryDataId(fileId); + binaryData.fileSize = prettyBytes(fileSize); + binaryData.data = this.mode; // clear binary data from memory + return binaryData; } @@ -94,7 +86,7 @@ export class BinaryDataService { } getAsStream(identifier: string, chunkSize?: number) { - const { mode, id } = this.splitBinaryModeFileId(identifier); + const { mode, id } = this.splitModeFileId(identifier); return this.getManager(mode).getStream(id, chunkSize); } @@ -106,19 +98,19 @@ export class BinaryDataService { } async retrieveBinaryDataByIdentifier(identifier: string) { - const { mode, id } = this.splitBinaryModeFileId(identifier); + const { mode, id } = this.splitModeFileId(identifier); return this.getManager(mode).getBuffer(id); } getPath(identifier: string) { - const { mode, id } = this.splitBinaryModeFileId(identifier); + const { mode, id } = this.splitModeFileId(identifier); return this.getManager(mode).getPath(id); } async getMetadata(identifier: string) { - const { mode, id } = this.splitBinaryModeFileId(identifier); + const { mode, id } = this.splitModeFileId(identifier); return this.getManager(mode).getMetadata(id); } @@ -157,12 +149,15 @@ export class BinaryDataService { // private methods // ---------------------------------- - private createIdentifier(filename: string) { - return `${this.mode}:${filename}`; + /** + * Create an identifier `${mode}:{fileId}` for `IBinaryData['id']`. + */ + private createBinaryDataId(fileId: string) { + return `${this.mode}:${fileId}`; } - private splitBinaryModeFileId(fileId: string) { - const [mode, id] = fileId.split(':'); + private splitModeFileId(identifier: string) { + const [mode, id] = identifier.split(':'); return { mode, id }; } @@ -185,12 +180,12 @@ export class BinaryDataService { return { key, newId: undefined }; } - return manager - ?.copyByIdentifier(this.splitBinaryModeFileId(binaryDataId).id, executionId) - .then((filename) => ({ - newId: this.createIdentifier(filename), - key, - })); + const fileId = this.splitModeFileId(binaryDataId).id; + + return manager?.copyByFileId(fileId, executionId).then((newFileId) => ({ + newId: this.createBinaryDataId(newFileId), + key, + })); }); return Promise.all(bdPromises).then((b) => { @@ -212,6 +207,6 @@ export class BinaryDataService { if (manager) return manager; - throw new InvalidBinaryDataManagerError(); + throw new InvalidBinaryDataManager(); } } diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 1e00774382fd0..357eeac9f2315 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -7,7 +7,6 @@ import { jsonParse } from 'n8n-workflow'; import { FileNotFoundError } from '../errors'; import type { Readable } from 'stream'; -import type { BinaryMetadata } from 'n8n-workflow'; import type { BinaryData } from './types'; const EXECUTION_ID_EXTRACTOR = @@ -20,25 +19,25 @@ export class FileSystemManager implements BinaryData.Manager { await this.ensureDirExists(this.storagePath); } - getPath(identifier: string) { - return this.resolvePath(identifier); + getPath(fileId: string) { + return this.resolvePath(fileId); } - async getSize(identifier: string) { - const filePath = this.getPath(identifier); + async getSize(fileId: string) { + const filePath = this.getPath(fileId); const stats = await fs.stat(filePath); return stats.size; } - getStream(identifier: string, chunkSize?: number) { - const filePath = this.getPath(identifier); + getStream(fileId: string, chunkSize?: number) { + const filePath = this.getPath(fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); } - async getBuffer(identifier: string) { - const filePath = this.getPath(identifier); + async getBuffer(fileId: string) { + const filePath = this.getPath(fileId); try { return await fs.readFile(filePath); @@ -47,29 +46,31 @@ export class FileSystemManager implements BinaryData.Manager { } } - async storeMetadata(identifier: string, metadata: BinaryMetadata) { - const filePath = this.resolvePath(`${identifier}.metadata`); - - await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' }); - } - - async getMetadata(identifier: string): Promise { - const filePath = this.resolvePath(`${identifier}.metadata`); + async getMetadata(fileId: string): Promise { + const filePath = this.resolvePath(`${fileId}.metadata`); return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } - async store(binaryData: Buffer | Readable, executionId: string) { - const identifier = this.createIdentifier(executionId); - const filePath = this.getPath(identifier); + async store( + binaryData: Buffer | Readable, + executionId: string, + { mimeType, fileName }: { mimeType: string; fileName?: string }, + ) { + const fileId = this.createFileId(executionId); + const filePath = this.getPath(fileId); await fs.writeFile(filePath, binaryData); - return identifier; + const fileSize = await this.getSize(fileId); + + await this.storeMetadata(fileId, { mimeType, fileName, fileSize }); + + return { fileId, fileSize }; } - async deleteOne(identifier: string) { - const filePath = this.getPath(identifier); + async deleteOne(fileId: string) { + const filePath = this.getPath(fileId); return fs.rm(filePath); } @@ -94,20 +95,28 @@ export class FileSystemManager implements BinaryData.Manager { return deletedIds; } - async copyByPath(filePath: string, executionId: string) { - const identifier = this.createIdentifier(executionId); + async copyByFilePath( + filePath: string, + executionId: string, + { mimeType, fileName }: { mimeType: string; fileName?: string }, + ) { + const newFileId = this.createFileId(executionId); - await fs.cp(filePath, this.getPath(identifier)); + await fs.cp(filePath, this.getPath(newFileId)); - return identifier; + const fileSize = await this.getSize(newFileId); + + await this.storeMetadata(newFileId, { mimeType, fileName, fileSize }); + + return { fileId: newFileId, fileSize }; } - async copyByIdentifier(identifier: string, executionId: string) { - const newIdentifier = this.createIdentifier(executionId); + async copyByFileId(fileId: string, executionId: string) { + const newFileId = this.createFileId(executionId); - await fs.copyFile(this.resolvePath(identifier), this.resolvePath(newIdentifier)); + await fs.copyFile(this.resolvePath(fileId), this.resolvePath(newFileId)); - return newIdentifier; + return newFileId; } // ---------------------------------- @@ -122,7 +131,10 @@ export class FileSystemManager implements BinaryData.Manager { } } - private createIdentifier(executionId: string) { + /** + * Create an identifier `${executionId}{uuid}` for a binary data file. + */ + private createFileId(executionId: string) { return [executionId, uuid()].join(''); } @@ -135,4 +147,10 @@ export class FileSystemManager implements BinaryData.Manager { return returnPath; } + + private async storeMetadata(fileId: string, metadata: BinaryData.Metadata) { + const filePath = this.resolvePath(`${fileId}.metadata`); + + await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' }); + } } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index 0d7a42c7388bb..04abfd6a462cb 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import type { BinaryMetadata } from 'n8n-workflow'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -11,7 +10,11 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async store(binaryData: Buffer | Readable, executionId: string): Promise { + async store( + binaryData: Buffer | Readable, + executionId: string, + metadata: { mimeType: string; fileName?: string }, + ): Promise<{ fileId: string; fileSize: number }> { throw new Error('TODO'); } @@ -19,10 +22,6 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async getSize(identifier: string): Promise { - throw new Error('TODO'); - } - async getBuffer(identifier: string): Promise { throw new Error('TODO'); } @@ -31,23 +30,22 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async storeMetadata(identifier: string, metadata: BinaryMetadata): Promise { - throw new Error('TODO'); - } - - async getMetadata(identifier: string): Promise { + async getMetadata(identifier: string): Promise { throw new Error('TODO'); } - async copyByPath(path: string, executionId: string): Promise { + async copyByFileId(fileId: string, executionId: string): Promise { throw new Error('TODO'); } - async copyByIdentifier(identifier: string, executionId: string): Promise { + async copyByFilePath( + path: string, + executionId: string, + ): Promise<{ fileId: string; fileSize: number }> { throw new Error('TODO'); } - async deleteOne(identifier: string): Promise { + async deleteOne(fileId: string): Promise { throw new Error('TODO'); } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 73222254149c1..b644704f9a91b 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -1,10 +1,15 @@ import type { Readable } from 'stream'; -import type { BinaryMetadata } from 'n8n-workflow'; import type { BINARY_DATA_MODES } from './utils'; export namespace BinaryData { export type Mode = (typeof BINARY_DATA_MODES)[number]; + export type Metadata = { + fileName?: string; + mimeType?: string; + fileSize: number; + }; + type ConfigBase = { mode: Mode; availableModes: string[]; @@ -14,30 +19,33 @@ export namespace BinaryData { export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; localStoragePath: string }; - export type Config = InMemoryConfig | FileSystemConfig; + export type ObjectStoreConfig = ConfigBase & { mode: 'objectStore'; localStoragePath: string }; + + export type Config = InMemoryConfig | FileSystemConfig | ObjectStoreConfig; export interface Manager { init(): Promise; - store(binaryData: Buffer | Readable, executionId: string): Promise; - getPath(identifier: string): string; - - getSize(identifier: string): Promise; + store( + binaryData: Buffer | Readable, + executionId: string, + metadata: { mimeType: string; fileName?: string }, + ): Promise<{ fileId: string; fileSize: number }>; + getPath(identifier: string): string; getBuffer(identifier: string): Promise; getStream(identifier: string, chunkSize?: number): Readable; + getMetadata(identifier: string): Promise; - // @TODO: Refactor out - not needed for object storage - storeMetadata(identifier: string, metadata: BinaryMetadata): Promise; - - // @TODO: Refactor out - not needed for object storage - getMetadata(identifier: string): Promise; + copyByFileId(identifier: string, prefix: string): Promise; // @TODO: Refactor to also use `workflowId` to support full path-like identifier: // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` - copyByPath(path: string, executionId: string): Promise; - - copyByIdentifier(identifier: string, prefix: string): Promise; + copyByFilePath( + path: string, + executionId: string, + metadata: { mimeType: string; fileName?: string }, + ): Promise<{ fileId: string; fileSize: number }>; deleteOne(identifier: string): Promise; diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 05dc84dc6308c..82f41f3a0a0f9 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -12,13 +12,13 @@ export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } -export class InvalidBinaryDataModeError extends Error { +export class InvalidBinaryDataMode extends Error { constructor() { super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); } } -export class InvalidBinaryDataManagerError extends Error { +export class InvalidBinaryDataManager extends Error { constructor() { super('No binary data manager found'); } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index fa7dede9ec37b..13ba70f7adb0e 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -58,7 +58,6 @@ import type { IPollFunctions, ITriggerFunctions, IWebhookFunctions, - BinaryMetadata, FileSystemHelperFunctions, INodeType, } from 'n8n-workflow'; @@ -138,6 +137,7 @@ import { import { getSecretsProxy } from './Secrets'; import { getUserN8nFolderPath } from './UserSettings'; import Container from 'typedi'; +import type { BinaryData } from './BinaryData/types'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -880,7 +880,7 @@ export function getBinaryPath(binaryDataId: string): string { /** * Returns binary file metadata */ -export async function getBinaryMetadata(binaryDataId: string): Promise { +export async function getBinaryMetadata(binaryDataId: string): Promise { return Container.get(BinaryDataService).getMetadata(binaryDataId); } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index b59448aae95e0..7e200ac7d3767 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -48,12 +48,6 @@ export interface IBinaryData { id?: string; } -export interface BinaryMetadata { - fileName?: string; - mimeType?: string; - fileSize: number; -} - // All properties in this interface except for // "includeCredentialsOnRefreshOnBody" will get // removed once we add the OAuth2 hooks to the @@ -691,7 +685,11 @@ export interface BinaryHelperFunctions { binaryToBuffer(body: Buffer | Readable): Promise; getBinaryPath(binaryDataId: string): string; getBinaryStream(binaryDataId: string, chunkSize?: number): Readable; - getBinaryMetadata(binaryDataId: string): Promise; + getBinaryMetadata(binaryDataId: string): Promise<{ + fileName?: string; + mimeType?: string; + fileSize: number; + }>; } export interface NodeHelperFunctions { From 5810a1b8dc5db1b4e0f1c0b79cb3e003219078e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 14:52:33 +0200 Subject: [PATCH 116/259] refactor(core): Include workflow ID in binary data operations --- packages/cli/src/Server.ts | 7 +- packages/cli/src/WebhookHelpers.ts | 7 +- packages/cli/src/commands/BaseCommand.ts | 24 +++--- packages/cli/src/requests.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 49 +++++++---- .../core/src/BinaryData/FileSystem.manager.ts | 41 ++++++---- .../src/BinaryData/ObjectStore.manager.ee.ts | 19 +++-- packages/core/src/BinaryData/types.ts | 30 ++++--- packages/core/src/NodeExecuteFunctions.ts | 81 ++++++++++++------- .../editor-ui/src/stores/workflows.store.ts | 3 +- packages/workflow/src/Workflow.ts | 4 +- 11 files changed, 157 insertions(+), 110 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9e0bbec7b9cf0..cb504adf74bd1 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1423,16 +1423,17 @@ export class Server extends AbstractServer { // Download binary this.app.get( - `/${this.restEndpoint}/data/:path`, + `/${this.restEndpoint}/data/:workflowId/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; + const workflowId = req.params.workflowId; try { - const binaryPath = this.binaryDataService.getPath(identifier); + const binaryPath = this.binaryDataService.getPath(workflowId, identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getMetadata(identifier); + const metadata = await this.binaryDataService.getMetadata(workflowId, identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 703e4b63b800d..ce0e8c66a06f7 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream(workflow.id, binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -734,7 +734,10 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream( + workflow.id, + binaryData.id, + ); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 87b85ded00d32..ac15f7fc30224 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -6,7 +6,7 @@ import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings, ObjectStoreService } from 'n8n-core'; +import { BinaryDataService, UserSettings } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -111,23 +111,23 @@ export abstract class BaseCommand extends Command { * @TODO: Only for dev, remove later */ - const objectStoreService = new ObjectStoreService( - { - name: config.getEnv('externalStorage.s3.bucket.name'), - region: config.getEnv('externalStorage.s3.bucket.region'), - }, - { - accountId: config.getEnv('externalStorage.s3.credentials.accountId'), - secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), - }, - ); + // const objectStoreService = new ObjectStoreService( + // { + // name: config.getEnv('externalStorage.s3.bucket.name'), + // region: config.getEnv('externalStorage.s3.bucket.region'), + // }, + // { + // accountId: config.getEnv('externalStorage.s3.credentials.accountId'), + // secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), + // }, + // ); // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; // const buffer = Buffer.from(await readFile(filePath)); // const res = await objectStoreService.put('object-store-service-dog.jpg', buffer); // console.log('upload result', res.status); - await objectStoreService.checkConnection(); + // await objectStoreService.checkConnection(); // const res = await objectStoreService.deleteMany('uploaded'); // console.log('res', res); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 82f2c6a7a1860..8e933788c8809 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -490,7 +490,7 @@ export declare namespace LicenseRequest { } export type BinaryDataRequest = AuthenticatedRequest< - { path: string }, + { workflowId: string; path: string }, {}, {}, { diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index b0ae78f5c5994..ca14733888273 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -32,7 +32,12 @@ export class BinaryDataService { } } - async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { + async copyBinaryFile( + workflowId: string, + binaryData: IBinaryData, + path: string, + executionId: string, + ) { const manager = this.managers[this.mode]; if (!manager) { @@ -43,7 +48,7 @@ export class BinaryDataService { return binaryData; } - const { fileId, fileSize } = await manager.copyByFilePath(path, executionId, { + const { fileId, fileSize } = await manager.copyByFilePath(workflowId, executionId, path, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, }); @@ -55,7 +60,12 @@ export class BinaryDataService { return binaryData; } - async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { + async store( + binaryData: IBinaryData, + input: Buffer | Readable, + workflowId: string, + executionId: string, + ) { const manager = this.managers[this.mode]; if (!manager) { @@ -66,7 +76,7 @@ export class BinaryDataService { return binaryData; } - const { fileId, fileSize } = await manager.store(input, executionId, { + const { fileId, fileSize } = await manager.store(workflowId, executionId, input, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, }); @@ -85,41 +95,45 @@ export class BinaryDataService { }); } - getAsStream(identifier: string, chunkSize?: number) { + getAsStream(workflowId: string, identifier: string, chunkSize?: number) { const { mode, id } = this.splitModeFileId(identifier); - return this.getManager(mode).getStream(id, chunkSize); + return this.getManager(mode).getStream(workflowId, id, chunkSize); } - async getBinaryDataBuffer(binaryData: IBinaryData) { - if (binaryData.id) return this.retrieveBinaryDataByIdentifier(binaryData.id); + async getBinaryDataBuffer(workflowId: string, binaryData: IBinaryData) { + if (binaryData.id) return this.retrieveBinaryDataByIdentifier(workflowId, binaryData.id); return Buffer.from(binaryData.data, BINARY_ENCODING); } - async retrieveBinaryDataByIdentifier(identifier: string) { + async retrieveBinaryDataByIdentifier(workflowId: string, identifier: string) { const { mode, id } = this.splitModeFileId(identifier); - return this.getManager(mode).getBuffer(id); + return this.getManager(mode).getBuffer(workflowId, id); } - getPath(identifier: string) { + getPath(workflowId: string, identifier: string) { const { mode, id } = this.splitModeFileId(identifier); - return this.getManager(mode).getPath(id); + return this.getManager(mode).getPath(workflowId, id); } - async getMetadata(identifier: string) { + async getMetadata(workflowId: string, identifier: string) { const { mode, id } = this.splitModeFileId(identifier); - return this.getManager(mode).getMetadata(id); + return this.getManager(mode).getMetadata(workflowId, id); } async deleteManyByExecutionIds(executionIds: string[]) { await this.getManager(this.mode).deleteManyByExecutionIds(executionIds); } - async duplicateBinaryData(inputData: Array, executionId: string) { + async duplicateBinaryData( + workflowId: string, + inputData: Array, + executionId: string, + ) { if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( async (executionDataArray) => { @@ -127,7 +141,7 @@ export class BinaryDataService { return Promise.all( executionDataArray.map(async (executionData) => { if (executionData.binary) { - return this.duplicateBinaryDataInExecData(executionData, executionId); + return this.duplicateBinaryDataInExecData(workflowId, executionData, executionId); } return executionData; @@ -163,6 +177,7 @@ export class BinaryDataService { } private async duplicateBinaryDataInExecData( + workflowId: string, executionData: INodeExecutionData, executionId: string, ) { @@ -182,7 +197,7 @@ export class BinaryDataService { const fileId = this.splitModeFileId(binaryDataId).id; - return manager?.copyByFileId(fileId, executionId).then((newFileId) => ({ + return manager?.copyByFileId(workflowId, fileId, executionId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 357eeac9f2315..41501d6906e4c 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -9,6 +9,11 @@ import { FileNotFoundError } from '../errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; +/** + * `_workflowId` references are for compatibility with the + * `BinaryData.Manager` interface, but currently unused in filesystem mode. + */ + const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; @@ -19,25 +24,25 @@ export class FileSystemManager implements BinaryData.Manager { await this.ensureDirExists(this.storagePath); } - getPath(fileId: string) { + getPath(_workflowId: string, fileId: string) { return this.resolvePath(fileId); } - async getSize(fileId: string) { - const filePath = this.getPath(fileId); + async getSize(workflowId: string, fileId: string) { + const filePath = this.getPath(workflowId, fileId); const stats = await fs.stat(filePath); return stats.size; } - getStream(fileId: string, chunkSize?: number) { - const filePath = this.getPath(fileId); + getStream(workflowId: string, fileId: string, chunkSize?: number) { + const filePath = this.getPath(workflowId, fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); } - async getBuffer(fileId: string) { - const filePath = this.getPath(fileId); + async getBuffer(workflowId: string, fileId: string) { + const filePath = this.getPath(workflowId, fileId); try { return await fs.readFile(filePath); @@ -46,31 +51,32 @@ export class FileSystemManager implements BinaryData.Manager { } } - async getMetadata(fileId: string): Promise { + async getMetadata(_workflowId: string, fileId: string): Promise { const filePath = this.resolvePath(`${fileId}.metadata`); return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } async store( - binaryData: Buffer | Readable, + workflowId: string, executionId: string, + binaryData: Buffer | Readable, { mimeType, fileName }: { mimeType: string; fileName?: string }, ) { const fileId = this.createFileId(executionId); - const filePath = this.getPath(fileId); + const filePath = this.getPath(workflowId, fileId); await fs.writeFile(filePath, binaryData); - const fileSize = await this.getSize(fileId); + const fileSize = await this.getSize(workflowId, fileId); await this.storeMetadata(fileId, { mimeType, fileName, fileSize }); return { fileId, fileSize }; } - async deleteOne(fileId: string) { - const filePath = this.getPath(fileId); + async deleteOne(workflowId: string, fileId: string) { + const filePath = this.getPath(workflowId, fileId); return fs.rm(filePath); } @@ -96,22 +102,23 @@ export class FileSystemManager implements BinaryData.Manager { } async copyByFilePath( - filePath: string, + workflowId: string, executionId: string, + filePath: string, { mimeType, fileName }: { mimeType: string; fileName?: string }, ) { const newFileId = this.createFileId(executionId); - await fs.cp(filePath, this.getPath(newFileId)); + await fs.cp(filePath, this.getPath(workflowId, newFileId)); - const fileSize = await this.getSize(newFileId); + const fileSize = await this.getSize(workflowId, newFileId); await this.storeMetadata(newFileId, { mimeType, fileName, fileSize }); return { fileId: newFileId, fileSize }; } - async copyByFileId(fileId: string, executionId: string) { + async copyByFileId(_workflowId: string, fileId: string, executionId: string) { const newFileId = this.createFileId(executionId); await fs.copyFile(this.resolvePath(fileId), this.resolvePath(newFileId)); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index 04abfd6a462cb..85fdd300c0981 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -11,37 +11,40 @@ export class ObjectStoreManager implements BinaryData.Manager { } async store( - binaryData: Buffer | Readable, + workflowId: string, executionId: string, + binaryData: Buffer | Readable, metadata: { mimeType: string; fileName?: string }, ): Promise<{ fileId: string; fileSize: number }> { throw new Error('TODO'); } - getPath(identifier: string): string { + getPath(workflowId: string, fileId: string): string { throw new Error('TODO'); } - async getBuffer(identifier: string): Promise { + async getBuffer(workflowId: string, fileId: string): Promise { throw new Error('TODO'); } - getStream(identifier: string, chunkSize?: number): Readable { + getStream(workflowId: string, fileId: string, chunkSize?: number): Readable { throw new Error('TODO'); } - async getMetadata(identifier: string): Promise { + async getMetadata(workflowId: string, fileId: string): Promise { throw new Error('TODO'); } - async copyByFileId(fileId: string, executionId: string): Promise { + async copyByFileId(workflowId: string, fileId: string, prefix: string): Promise { throw new Error('TODO'); } async copyByFilePath( - path: string, + workflowId: string, executionId: string, - ): Promise<{ fileId: string; fileSize: number }> { + filePath: string, + metadata: { mimeType: string; fileName?: string }, + ): Promise { throw new Error('TODO'); } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index b644704f9a91b..a1d55d8d7e461 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -23,34 +23,32 @@ export namespace BinaryData { export type Config = InMemoryConfig | FileSystemConfig | ObjectStoreConfig; + export type WriteResult = { fileId: string; fileSize: number }; + export interface Manager { init(): Promise; store( - binaryData: Buffer | Readable, + workflowId: string, executionId: string, + binaryData: Buffer | Readable, metadata: { mimeType: string; fileName?: string }, - ): Promise<{ fileId: string; fileSize: number }>; + ): Promise; - getPath(identifier: string): string; - getBuffer(identifier: string): Promise; - getStream(identifier: string, chunkSize?: number): Readable; - getMetadata(identifier: string): Promise; + getPath(workflowId: string, fileId: string): string; + getBuffer(workflowId: string, fileId: string): Promise; + getStream(workflowId: string, fileId: string, chunkSize?: number): Readable; + getMetadata(workflowId: string, fileId: string): Promise; - copyByFileId(identifier: string, prefix: string): Promise; - - // @TODO: Refactor to also use `workflowId` to support full path-like identifier: - // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` + copyByFileId(workflowId: string, fileId: string, prefix: string): Promise; copyByFilePath( - path: string, + workflowId: string, executionId: string, + filePath: string, metadata: { mimeType: string; fileName?: string }, - ): Promise<{ fileId: string; fileSize: number }>; - - deleteOne(identifier: string): Promise; + ): Promise; - // @TODO: Refactor to also receive `workflowId` to support full path-like identifier: - // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` + deleteOne(workflowId: string, fileId: string): Promise; deleteManyByExecutionIds(executionIds: string[]): Promise; } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 13ba70f7adb0e..dd3483e77ecb3 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -873,22 +873,29 @@ async function httpRequest( return result.data; } -export function getBinaryPath(binaryDataId: string): string { - return Container.get(BinaryDataService).getPath(binaryDataId); +export function getBinaryPath(workflowId: string, binaryDataId: string): string { + return Container.get(BinaryDataService).getPath(workflowId, binaryDataId); } /** * Returns binary file metadata */ -export async function getBinaryMetadata(binaryDataId: string): Promise { - return Container.get(BinaryDataService).getMetadata(binaryDataId); +export async function getBinaryMetadata( + workflowId: string, + binaryDataId: string, +): Promise { + return Container.get(BinaryDataService).getMetadata(workflowId, binaryDataId); } /** * Returns binary file stream for piping */ -export function getBinaryStream(binaryDataId: string, chunkSize?: number): Readable { - return Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); +export function getBinaryStream( + workflowId: string, + binaryDataId: string, + chunkSize?: number, +): Readable { + return Container.get(BinaryDataService).getAsStream(workflowId, binaryDataId, chunkSize); } export function assertBinaryData( @@ -923,9 +930,10 @@ export async function getBinaryDataBuffer( itemIndex: number, propertyName: string, inputIndex: number, + workflowId: string, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return Container.get(BinaryDataService).getBinaryDataBuffer(binaryData); + return Container.get(BinaryDataService).getBinaryDataBuffer(workflowId, binaryData); } /** @@ -939,12 +947,14 @@ export async function getBinaryDataBuffer( export async function setBinaryDataBuffer( data: IBinaryData, binaryData: Buffer | Readable, + workflowId: string, executionId: string, ): Promise { - return Container.get(BinaryDataService).store(data, binaryData, executionId); + return Container.get(BinaryDataService).store(data, binaryData, workflowId, executionId); } export async function copyBinaryFile( + workflowId: string, executionId: string, filePath: string, fileName: string, @@ -994,7 +1004,12 @@ export async function copyBinaryFile( returnData.fileName = path.parse(filePath).base; } - return Container.get(BinaryDataService).copyBinaryFile(returnData, filePath, executionId); + return Container.get(BinaryDataService).copyBinaryFile( + workflowId, + returnData, + filePath, + executionId, + ); } /** @@ -1004,6 +1019,7 @@ export async function copyBinaryFile( async function prepareBinaryData( binaryData: Buffer | Readable, executionId: string, + workflowId: string, filePath?: string, mimeType?: string, ): Promise { @@ -1085,7 +1101,7 @@ async function prepareBinaryData( } } - return setBinaryDataBuffer(returnData, binaryData, executionId); + return setBinaryDataBuffer(returnData, binaryData, workflowId, executionId); } /** @@ -2386,25 +2402,27 @@ const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => }, }); -const getNodeHelperFunctions = ({ - executionId, -}: IWorkflowExecuteAdditionalData): NodeHelperFunctions => ({ +const getNodeHelperFunctions = ( + { executionId }: IWorkflowExecuteAdditionalData, + workflowId: string, +): NodeHelperFunctions => ({ copyBinaryFile: async (filePath, fileName, mimeType) => - copyBinaryFile(executionId!, filePath, fileName, mimeType), + copyBinaryFile(workflowId, executionId!, filePath, fileName, mimeType), }); -const getBinaryHelperFunctions = ({ - executionId, -}: IWorkflowExecuteAdditionalData): BinaryHelperFunctions => ({ - getBinaryPath, - getBinaryStream, - getBinaryMetadata, +const getBinaryHelperFunctions = ( + { executionId }: IWorkflowExecuteAdditionalData, + workflowId: string, +): BinaryHelperFunctions => ({ + getBinaryPath: (binaryDataId) => getBinaryPath(workflowId, binaryDataId), + getBinaryStream: (binaryDataId) => getBinaryStream(workflowId, binaryDataId), + getBinaryMetadata: async (binaryDataId) => getBinaryMetadata(workflowId, binaryDataId), binaryToBuffer: async (body: Buffer | Readable) => Container.get(BinaryDataService).binaryToBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => - prepareBinaryData(binaryData, executionId!, filePath, mimeType), + prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => - setBinaryDataBuffer(data, binaryData, executionId!), + setBinaryDataBuffer(data, binaryData, workflowId, executionId!), copyBinaryFile: async () => { throw new Error('copyBinaryFile has been removed. Please upgrade this node'); }, @@ -2464,7 +2482,7 @@ export function getExecutePollFunctions( helpers: { createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), - ...getBinaryHelperFunctions(additionalData), + ...getBinaryHelperFunctions(additionalData, workflow.id), returnJsonArray, }, }; @@ -2523,7 +2541,7 @@ export function getExecuteTriggerFunctions( helpers: { createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), - ...getBinaryHelperFunctions(additionalData), + ...getBinaryHelperFunctions(additionalData, workflow.id), returnJsonArray, }, }; @@ -2589,6 +2607,7 @@ export function getExecuteFunctions( }) .then(async (result) => Container.get(BinaryDataService).duplicateBinaryData( + workflow.id, result, additionalData.executionId!, ), @@ -2698,17 +2717,17 @@ export function getExecuteFunctions( createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getFileSystemHelperFunctions(node), - ...getBinaryHelperFunctions(additionalData), + ...getBinaryHelperFunctions(additionalData, workflow.id), assertBinaryData: (itemIndex, propertyName) => assertBinaryData(inputData, node, itemIndex, propertyName, 0), getBinaryDataBuffer: async (itemIndex, propertyName) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), + getBinaryDataBuffer(inputData, itemIndex, propertyName, 0, workflow.id), returnJsonArray, normalizeItems, constructExecutionMetaData, }, - nodeHelpers: getNodeHelperFunctions(additionalData), + nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id), }; })(workflow, runExecutionData, connectionInputData, inputData, node) as IExecuteFunctions; } @@ -2840,12 +2859,12 @@ export function getExecuteSingleFunctions( helpers: { createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), - ...getBinaryHelperFunctions(additionalData), + ...getBinaryHelperFunctions(additionalData, workflow.id), assertBinaryData: (propertyName, inputIndex = 0) => assertBinaryData(inputData, node, itemIndex, propertyName, inputIndex), getBinaryDataBuffer: async (propertyName, inputIndex = 0) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex), + getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex, workflow.id), }, }; })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); @@ -3097,10 +3116,10 @@ export function getExecuteWebhookFunctions( helpers: { createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), - ...getBinaryHelperFunctions(additionalData), + ...getBinaryHelperFunctions(additionalData, workflow.id), returnJsonArray, }, - nodeHelpers: getNodeHelperFunctions(additionalData), + nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id), }; })(workflow, node); } diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index ae35bf88c5840..32b6f5848509c 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1379,7 +1379,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { const rootStore = useRootStore(); let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; - const url = new URL(`${restUrl}/data/${dataPath}`); + const workflowId = this.getCurrentWorkflow().id; + const url = new URL(`${restUrl}/data/${workflowId}/${dataPath}`); url.searchParams.append('mode', mode); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 06ff5606b0057..a5f6e663319b3 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -56,7 +56,7 @@ function dedupe(arr: T[]): T[] { } export class Workflow { - id: string | undefined; + id: string; name: string | undefined; @@ -92,7 +92,7 @@ export class Workflow { settings?: IWorkflowSettings; pinData?: IPinData; }) { - this.id = parameters.id; + this.id = parameters.id as string; // @TODO: Why is this optional? this.name = parameters.name; this.nodeTypes = parameters.nodeTypes; this.pinData = parameters.pinData; From 04584b00ac14154defefc309396bc5aa783a5fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 15:11:29 +0200 Subject: [PATCH 117/259] Fix lint --- packages/cli/src/ActiveWebhooks.ts | 2 +- packages/cli/src/ActiveWorkflowRunner.ts | 2 +- packages/cli/src/workflows/workflows.services.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 42e2425634898..28705e2936626 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -157,7 +157,7 @@ export class ActiveWebhooks { * */ async removeWorkflow(workflow: Workflow): Promise { - const workflowId = workflow.id!.toString(); + const workflowId = workflow.id.toString(); if (this.workflowWebhooks[workflowId] === undefined) { // If it did not exist then there is nothing to remove diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index f664d7d42348a..f60b3ffcdb28f 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -417,7 +417,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { } try { - await this.removeWorkflowWebhooks(workflow.id as string); + await this.removeWorkflowWebhooks(workflow.id); } catch (error1) { ErrorReporter.error(error1); Logger.error( diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index e8f566380245f..5a466e65ff57c 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -492,7 +492,7 @@ export class WorkflowsService { // Workflow is saved so update in database try { // eslint-disable-next-line @typescript-eslint/no-use-before-define - await WorkflowsService.saveStaticDataById(workflow.id!, workflow.staticData); + await WorkflowsService.saveStaticDataById(workflow.id, workflow.staticData); workflow.staticData.__dataChanged = false; } catch (error) { ErrorReporter.error(error); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index dd3483e77ecb3..fb623e9882501 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2166,7 +2166,7 @@ export function getNodeWebhookUrl( undefined, false, ) as boolean; - return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path.toString(), isFullPath); + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath); } /** From 80a4fc509adf65cb17b7a5b76bb6d5a7a03649cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:39:31 +0200 Subject: [PATCH 118/259] Fix misresolved conflict --- .../cli/src/databases/repositories/execution.repository.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 23ea9d30ab7e4..051d206b54dd8 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -521,8 +521,7 @@ export class ExecutionRepository extends Repository { }) ).map(({ id }) => id); - const binaryDataManager = BinaryDataManager.getInstance(); - await binaryDataManager.deleteBinaryDataByExecutionIds(executionIds); + await this.binaryDataService.deleteManyByExecutionIds(executionIds); this.logger.debug(`Hard-deleting ${executionIds.length} executions from database`, { executionIds, From 20d5ea78494d75f4283eeba7c8f7d40f5a3465f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:44:09 +0200 Subject: [PATCH 119/259] Remove excess check on init --- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- packages/core/src/BinaryData/types.ts | 11 +++-------- packages/core/test/NodeExecuteFunctions.test.ts | 1 + 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 629022ac36fd8..5540e2c2c4061 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -25,7 +25,7 @@ export class BinaryDataService { this.availableModes = config.availableModes; this.mode = config.mode; - if (this.availableModes.includes('filesystem') && config.mode === 'filesystem') { + if (this.availableModes.includes('filesystem')) { this.managers.filesystem = new FileSystemManager(config.localStoragePath); await this.managers.filesystem.init(); diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index c843f14d30188..e6bc3f6cedc43 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -5,17 +5,12 @@ import type { BINARY_DATA_MODES } from './utils'; export namespace BinaryData { export type Mode = (typeof BINARY_DATA_MODES)[number]; - type ConfigBase = { - mode: Mode; + export type Config = { + mode: 'default' | 'filesystem'; availableModes: string[]; + localStoragePath: string; }; - type InMemoryConfig = ConfigBase & { mode: 'default' }; - - export type FileSystemConfig = ConfigBase & { mode: 'filesystem'; localStoragePath: string }; - - export type Config = InMemoryConfig | FileSystemConfig; - export interface Manager { init(): Promise; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index fa421392327c6..fc15729362807 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -33,6 +33,7 @@ describe('NodeExecuteFunctions', () => { await Container.get(BinaryDataService).init({ mode: 'default', availableModes: ['default'], + localStoragePath: temporaryDir, }); // Set our binary data buffer From f99df11d716e747f671ddfb30cdd89a87e573877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:51:36 +0200 Subject: [PATCH 120/259] Fix misresolved conflicts --- packages/core/src/BinaryData/types.ts | 6 ++++++ packages/core/src/NodeExecuteFunctions.ts | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 1c5855012db58..638b6f9b1421e 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -10,6 +10,12 @@ export namespace BinaryData { localStoragePath: string; }; + export type Metadata = { + fileName?: string; + mimeType?: string; + fileSize: number; + }; + export interface Manager { init(): Promise; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 78a7a1df34de6..48111d3f4b3e0 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -39,7 +39,6 @@ import pick from 'lodash/pick'; import { extension, lookup } from 'mime-types'; import type { BinaryHelperFunctions, - BinaryMetadata, FieldType, FileSystemHelperFunctions, FunctionsBase, From 4475773a5b5891f2b5054b233bdb7ad716ecb469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:53:30 +0200 Subject: [PATCH 121/259] Remove commented out bits --- packages/cli/src/commands/BaseCommand.ts | 44 +------------------ .../src/BinaryData/ObjectStore.manager.ee.ts | 2 - 2 files changed, 1 insertion(+), 45 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 87b85ded00d32..17a95a155f6fe 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,12 +1,9 @@ -// import fs from 'node:fs'; -// import { pipeline } from 'node:stream/promises'; -// import { readFile } from 'node:fs/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings, ObjectStoreService } from 'n8n-core'; +import { BinaryDataService, UserSettings } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -107,45 +104,6 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { - /** - * @TODO: Only for dev, remove later - */ - - const objectStoreService = new ObjectStoreService( - { - name: config.getEnv('externalStorage.s3.bucket.name'), - region: config.getEnv('externalStorage.s3.bucket.region'), - }, - { - accountId: config.getEnv('externalStorage.s3.credentials.accountId'), - secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), - }, - ); - - // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; - // const buffer = Buffer.from(await readFile(filePath)); - // const res = await objectStoreService.put('object-store-service-dog.jpg', buffer); - // console.log('upload result', res.status); - - await objectStoreService.checkConnection(); - - // const res = await objectStoreService.deleteMany('uploaded'); - // console.log('res', res); - - // const res = await objectStoreService.list('uploaded'); - // console.log('res', res); - - // await objectStoreService.delete('object-store-service-dog.jpg'); - - // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); - // try { - // // eslint-disable-next-line @typescript-eslint/no-explicit-any - // await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); - // console.log('✅ Pipeline succeeded'); - // } catch (error) { - // console.log('❌ Pipeline failed', error); - // } - const binaryDataConfig = config.getEnv('binaryDataService'); await Container.get(BinaryDataService).init(binaryDataConfig); } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index 04abfd6a462cb..83ee843818409 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -3,8 +3,6 @@ import type { Readable } from 'stream'; import type { BinaryData } from './types'; -// `/workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` - export class ObjectStoreManager implements BinaryData.Manager { async init() { throw new Error('TODO'); From 4362e23fadc5d0f00f2ad744de248141fcc55134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:54:12 +0200 Subject: [PATCH 122/259] Remove comment --- packages/cli/src/config/schema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index bf55519853156..c4b3685b3b506 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -923,7 +923,6 @@ export const schema = { env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', doc: 'Name of the n8n bucket in S3-compatible external storage', }, - // @TODO: Is region AWS-specific? region: { format: String, default: '', From 0714d4432c31c8c2bc26d33ca06111ebf27fa43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 20 Sep 2023 19:55:57 +0200 Subject: [PATCH 123/259] Consistent naming --- .../core/src/BinaryData/BinaryData.service.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index b9ff8a0182119..64138b33ea911 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -86,9 +86,9 @@ export class BinaryDataService { } getAsStream(identifier: string, chunkSize?: number) { - const { mode, id } = this.splitModeFileId(identifier); + const { mode, fileId } = this.splitModeFileId(identifier); - return this.getManager(mode).getStream(id, chunkSize); + return this.getManager(mode).getStream(fileId, chunkSize); } async getBinaryDataBuffer(binaryData: IBinaryData) { @@ -98,21 +98,21 @@ export class BinaryDataService { } async retrieveBinaryDataByIdentifier(identifier: string) { - const { mode, id } = this.splitModeFileId(identifier); + const { mode, fileId } = this.splitModeFileId(identifier); - return this.getManager(mode).getBuffer(id); + return this.getManager(mode).getBuffer(fileId); } getPath(identifier: string) { - const { mode, id } = this.splitModeFileId(identifier); + const { mode, fileId } = this.splitModeFileId(identifier); - return this.getManager(mode).getPath(id); + return this.getManager(mode).getPath(fileId); } async getMetadata(identifier: string) { - const { mode, id } = this.splitModeFileId(identifier); + const { mode, fileId } = this.splitModeFileId(identifier); - return this.getManager(mode).getMetadata(id); + return this.getManager(mode).getMetadata(fileId); } async deleteManyByExecutionIds(executionIds: string[]) { @@ -157,9 +157,9 @@ export class BinaryDataService { } private splitModeFileId(identifier: string) { - const [mode, id] = identifier.split(':'); + const [mode, fileId] = identifier.split(':'); - return { mode, id }; + return { mode, fileId }; } private async duplicateBinaryDataInExecData( @@ -180,7 +180,7 @@ export class BinaryDataService { return { key, newId: undefined }; } - const fileId = this.splitModeFileId(binaryDataId).id; + const fileId = this.splitModeFileId(binaryDataId).fileId; return manager?.copyByFileId(fileId, executionId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), From fa8e51de04bcc421bba179f26eed4d2a4c6a5631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:00:48 +0200 Subject: [PATCH 124/259] Reorder --- packages/core/src/BinaryData/ObjectStore.manager.ee.ts | 8 ++++---- packages/core/src/BinaryData/types.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts index 83ee843818409..40c3cfa39390e 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts @@ -32,10 +32,6 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async copyByFileId(fileId: string, executionId: string): Promise { - throw new Error('TODO'); - } - async copyByFilePath( path: string, executionId: string, @@ -43,6 +39,10 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } + async copyByFileId(fileId: string, executionId: string): Promise { + throw new Error('TODO'); + } + async deleteOne(fileId: string): Promise { throw new Error('TODO'); } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 638b6f9b1421e..980f6e8412d5c 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -30,8 +30,6 @@ export namespace BinaryData { getStream(identifier: string, chunkSize?: number): Readable; getMetadata(identifier: string): Promise; - copyByFileId(identifier: string, prefix: string): Promise; - // @TODO: Refactor to also use `workflowId` to support full path-like identifier: // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` copyByFilePath( @@ -40,6 +38,8 @@ export namespace BinaryData { metadata: { mimeType: string; fileName?: string }, ): Promise<{ fileId: string; fileSize: number }>; + copyByFileId(identifier: string, prefix: string): Promise; + deleteOne(identifier: string): Promise; // @TODO: Refactor to also receive `workflowId` to support full path-like identifier: From 91f0755482e7f88a4f20a72f6b4c9da0d253ecbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:02:42 +0200 Subject: [PATCH 125/259] Better error message --- packages/core/src/BinaryData/BinaryData.service.ts | 4 ++-- packages/core/src/BinaryData/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 64138b33ea911..6053619c43515 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -5,7 +5,7 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; -import { InvalidBinaryDataManager, InvalidBinaryDataMode, areValidModes } from './utils'; +import { MissingBinaryDataManager, InvalidBinaryDataMode, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -207,6 +207,6 @@ export class BinaryDataService { if (manager) return manager; - throw new InvalidBinaryDataManager(); + throw new MissingBinaryDataManager(); } } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 82f41f3a0a0f9..1687775b98de3 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -18,7 +18,7 @@ export class InvalidBinaryDataMode extends Error { } } -export class InvalidBinaryDataManager extends Error { +export class MissingBinaryDataManager extends Error { constructor() { super('No binary data manager found'); } From 6d0c2715bf3c3b51ea2ecd8e4f762da274a6fd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:04:27 +0200 Subject: [PATCH 126/259] Remove `ObjectStore` from this PR --- .../src/ObjectStore/ObjectStore.service.ee.ts | 228 ------------------ packages/core/src/ObjectStore/types.ts | 21 -- packages/core/src/ObjectStore/utils.ts | 16 -- 3 files changed, 265 deletions(-) delete mode 100644 packages/core/src/ObjectStore/ObjectStore.service.ee.ts delete mode 100644 packages/core/src/ObjectStore/types.ts delete mode 100644 packages/core/src/ObjectStore/utils.ts diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts deleted file mode 100644 index 6f5ab94fed2fd..0000000000000 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ /dev/null @@ -1,228 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - -import axios from 'axios'; -import { Service } from 'typedi'; -import { sign } from 'aws4'; -import { isStream, parseXml } from './utils'; -import { createHash } from 'node:crypto'; -import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; -import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; -import type { ListPage, RawListPage } from './types'; - -// @TODO: Decouple host from AWS - -@Service() -export class ObjectStoreService { - private credentials: Aws4Credentials; - - constructor( - private bucket: { region: string; name: string }, - credentials: { accountId: string; secretKey: string }, - ) { - this.credentials = { - accessKeyId: credentials.accountId, - secretAccessKey: credentials.secretKey, - }; - } - - /** - * Confirm that the configured bucket exists and the caller has permission to access it. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html - */ - async checkConnection() { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - return this.request('HEAD', host); - } - - /** - * Upload an object to the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html - */ - async put(filename: string, buffer: Buffer) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - const headers = { - // derive content-type from filename - 'Content-Length': buffer.length, - 'Content-MD5': createHash('md5').update(buffer).digest('base64'), - }; - - return this.request('PUT', host, `/${filename}`, { headers, body: buffer }); - } - - /** - * Download an object as a stream or buffer from the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html - */ - async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - const { data } = await this.request('GET', host, path, { - responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', - }); - - if (mode === 'stream' && isStream(data)) return data; - - if (mode === 'buffer' && Buffer.isBuffer(data)) return data; - - throw new TypeError(`Expected ${mode} but received ${typeof data}.`); - } - - /** - * Delete an object in the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html - */ - async delete(path: string) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - return this.request('DELETE', host, `/${encodeURIComponent(path)}`); - } - - /** - * Delete objects with a common prefix in the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html - */ - async deleteMany(prefix: string) { - const objects = await this.list(prefix); - - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - const innerXml = objects.map((o) => `${o.key}`).join('\n'); - - const body = ['', innerXml, ''].join('\n'); - - const headers = { - 'Content-Type': 'application/xml', - 'Content-Length': body.length, - 'Content-MD5': createHash('md5').update(body).digest('base64'), - }; - - return this.request('POST', host, '/?delete', { headers, body }); - } - - /** - * List objects with a common prefix in the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html - */ - async list(prefix: string) { - const items = []; - - let isTruncated; - let token; // for next page - - do { - const listPage = await this.getListPage(prefix, token); - - if (listPage.contents?.length > 0) items.push(...listPage.contents); - - isTruncated = listPage.isTruncated; - token = listPage.nextContinuationToken; - } while (isTruncated && token); - - return items; - } - - /** - * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. - */ - private async getListPage(prefix: string, nextPageToken?: string) { - const host = `s3.${this.bucket.region}.amazonaws.com`; - - const qs: Record = { 'list-type': 2, prefix }; - - if (nextPageToken) qs['continuation-token'] = nextPageToken; - - const response = await this.request('GET', host, `/${this.bucket.name}`, { qs }); - - if (typeof response.data !== 'string') { - throw new TypeError('Expected string'); - } - - const { listBucketResult: listPage } = await parseXml(response.data); - - // restore array wrapper removed by `explicitArray: false` on single item array - - if (!Array.isArray(listPage.contents)) { - listPage.contents = [listPage.contents]; - } - - // remove null prototype - https://github.com/Leonidas-from-XIV/node-xml2js/issues/670 - - listPage.contents.forEach((item) => { - Object.setPrototypeOf(item, Object.prototype); - }); - - return listPage as ListPage; - } - - private toPath(rawPath: string, qs?: Record) { - const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; - - if (!qs) return path; - - const qsParams = Object.keys(qs) - .map((key) => `${key}=${qs[key]}`) - .join('&'); - - return path.concat(`?${qsParams}`); - } - - private async request( - method: Method, - host: string, - rawPath = '', - { - qs, - headers, - body, - responseType, - }: { - qs?: Record; - headers?: Record; - body?: string | Buffer; - responseType?: ResponseType; - } = {}, - ) { - const path = this.toPath(rawPath, qs); - - const optionsToSign: Aws4Options = { - method, - service: 's3', - region: this.bucket.region, - host, - path, - }; - - if (headers) optionsToSign.headers = headers; - if (body) optionsToSign.body = body; - - const signedOptions = sign(optionsToSign, this.credentials); - - const config: AxiosRequestConfig = { - method, - url: `https://${host}${path}`, - headers: signedOptions.headers, - }; - - if (body) config.data = body; - if (responseType) config.responseType = responseType; - - // console.log(config); - - try { - return await axios.request(config); - } catch (error) { - // console.log(error); - throw new Error('Request to external object storage failed', { - cause: { error: error as unknown, details: config }, - }); - } - } -} diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts deleted file mode 100644 index 1bd6ba0084cd9..0000000000000 --- a/packages/core/src/ObjectStore/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type RawListPage = { - listBucketResult: { - name: string; - prefix: string; - keyCount: number; - maxKeys: number; - isTruncated: boolean; - nextContinuationToken?: string; // only if isTruncated is true - contents: Item | Item[]; - }; -}; - -type Item = { - key: string; - lastModified: string; - eTag: string; - size: number; - storageClass: string; -}; - -export type ListPage = Omit & { contents: Item[] }; diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts deleted file mode 100644 index 76dcb1f076b1d..0000000000000 --- a/packages/core/src/ObjectStore/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Stream } from 'node:stream'; -import { parseStringPromise } from 'xml2js'; -import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors'; - -export function isStream(maybeStream: unknown): maybeStream is Stream { - return maybeStream instanceof Stream; -} - -export async function parseXml(xml: string): Promise { - return parseStringPromise(xml, { - explicitArray: false, - ignoreAttrs: true, - tagNameProcessors: [firstCharLowerCase], - valueProcessors: [parseNumbers, parseBooleans], - }) as Promise; -} From fa88894ef10556a9c5ab5d192db867d093eefa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:04:59 +0200 Subject: [PATCH 127/259] Remove schema change from this PR --- packages/cli/src/config/schema.ts | 33 ------------------------------- 1 file changed, 33 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index c4b3685b3b506..2529354c15334 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -914,39 +914,6 @@ export const schema = { }, }, - externalStorage: { - s3: { - bucket: { - name: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', - doc: 'Name of the n8n bucket in S3-compatible external storage', - }, - region: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', - doc: 'Region of the n8n bucket in S3-compatible external storage', - }, - }, - credentials: { - accountId: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', - doc: 'Account ID in S3-compatible external storage', - }, - secretKey: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', - doc: 'Secret key in S3-compatible external storage', - }, - }, - }, - }, - deployment: { type: { format: String, From de617687156de3fe1150888de25332334f79723d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:06:04 +0200 Subject: [PATCH 128/259] Update deps --- packages/core/package.json | 5 +---- pnpm-lock.yaml | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 7ece0a28448fa..441627b8b3a6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,7 +34,6 @@ "bin" ], "devDependencies": { - "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", "@types/cron": "~1.7.1", "@types/crypto-js": "^4.0.1", @@ -50,7 +49,6 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", - "aws4": "^1.8.0", "axios": "^0.21.1", "concat-stream": "^2.0.0", "cron": "~1.7.2", @@ -67,7 +65,6 @@ "pretty-bytes": "^5.6.0", "qs": "^6.10.1", "typedi": "^0.10.0", - "uuid": "^8.3.2", - "xml2js": "^0.5.0" + "uuid": "^8.3.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cfebbe6973ba..aa27186350e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,9 +570,6 @@ importers: '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 - aws4: - specifier: ^1.8.0 - version: 1.11.0 axios: specifier: ^0.21.1 version: 0.21.4 @@ -627,13 +624,7 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 - xml2js: - specifier: ^0.5.0 - version: 0.5.0 devDependencies: - '@types/aws4': - specifier: ^1.5.1 - version: 1.11.2 '@types/concat-stream': specifier: ^2.0.0 version: 2.0.0 @@ -9070,7 +9061,7 @@ packages: /axios@0.21.4: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2(debug@3.2.7) + follow-redirects: 1.15.2 transitivePeerDependencies: - debug dev: false @@ -12637,6 +12628,16 @@ packages: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} dev: false + /follow-redirects@1.15.2: + resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /follow-redirects@1.15.2(debug@3.2.7): resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} From eb03437aaf933b87e8afc2c3173d0bbcc3df3193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:06:30 +0200 Subject: [PATCH 129/259] Remove missing dep --- packages/core/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 441627b8b3a6c..bd6d57c0fca25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,8 +41,7 @@ "@types/lodash": "^4.14.195", "@types/mime-types": "^2.1.0", "@types/request-promise-native": "~1.0.15", - "@types/uuid": "^8.3.2", - "@types/xml2js": "^0.4.11" + "@types/uuid": "^8.3.2" }, "peerDependencies": { "n8n-nodes-base": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa27186350e37..09084fcdde168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -649,9 +649,6 @@ importers: '@types/uuid': specifier: ^8.3.2 version: 8.3.4 - '@types/xml2js': - specifier: ^0.4.11 - version: 0.4.11 packages/design-system: dependencies: From c9e49243fde2dd35cdacfa3194aaea1f2ff75666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:08:10 +0200 Subject: [PATCH 130/259] Remove reference --- packages/core/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4de42dbae5ecf..9d6b5bfeebda3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,7 +13,6 @@ export * from './LoadMappingOptions'; export * from './LoadNodeParameterOptions'; export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; -export * from './ObjectStore/ObjectStore.service.ee'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; From c9453fde1f668f5297ed695d06c6d22a41d3b1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:11:47 +0200 Subject: [PATCH 131/259] Cleanup --- packages/core/src/BinaryData/BinaryData.service.ts | 3 ++- packages/core/src/BinaryData/errors.ts | 13 +++++++++++++ packages/core/src/BinaryData/utils.ts | 12 ------------ 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/BinaryData/errors.ts diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 6053619c43515..cae1523611b15 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -5,7 +5,8 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; -import { MissingBinaryDataManager, InvalidBinaryDataMode, areValidModes } from './utils'; +import { areValidModes } from './utils'; +import { MissingBinaryDataManager, InvalidBinaryDataMode } from './errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; diff --git a/packages/core/src/BinaryData/errors.ts b/packages/core/src/BinaryData/errors.ts new file mode 100644 index 0000000000000..70f202d950a36 --- /dev/null +++ b/packages/core/src/BinaryData/errors.ts @@ -0,0 +1,13 @@ +import { BINARY_DATA_MODES } from './utils'; + +export class InvalidBinaryDataMode extends Error { + constructor() { + super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); + } +} + +export class MissingBinaryDataManager extends Error { + constructor() { + super('No binary data manager found'); + } +} diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 1687775b98de3..82b7ef35b080e 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -11,15 +11,3 @@ export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } - -export class InvalidBinaryDataMode extends Error { - constructor() { - super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); - } -} - -export class MissingBinaryDataManager extends Error { - constructor() { - super('No binary data manager found'); - } -} From 3d04b9e2ec2aadba77b7547c37dc66acc100fb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:15:37 +0200 Subject: [PATCH 132/259] Remove `ObjectStore.manager.ts` stub --- .../src/BinaryData/ObjectStore.manager.ee.ts | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 packages/core/src/BinaryData/ObjectStore.manager.ee.ts diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts b/packages/core/src/BinaryData/ObjectStore.manager.ee.ts deleted file mode 100644 index 40c3cfa39390e..0000000000000 --- a/packages/core/src/BinaryData/ObjectStore.manager.ee.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import type { Readable } from 'stream'; -import type { BinaryData } from './types'; - -export class ObjectStoreManager implements BinaryData.Manager { - async init() { - throw new Error('TODO'); - } - - async store( - binaryData: Buffer | Readable, - executionId: string, - metadata: { mimeType: string; fileName?: string }, - ): Promise<{ fileId: string; fileSize: number }> { - throw new Error('TODO'); - } - - getPath(identifier: string): string { - throw new Error('TODO'); - } - - async getBuffer(identifier: string): Promise { - throw new Error('TODO'); - } - - getStream(identifier: string, chunkSize?: number): Readable { - throw new Error('TODO'); - } - - async getMetadata(identifier: string): Promise { - throw new Error('TODO'); - } - - async copyByFilePath( - path: string, - executionId: string, - ): Promise<{ fileId: string; fileSize: number }> { - throw new Error('TODO'); - } - - async copyByFileId(fileId: string, executionId: string): Promise { - throw new Error('TODO'); - } - - async deleteOne(fileId: string): Promise { - throw new Error('TODO'); - } - - async deleteManyByExecutionIds(executionIds: string[]): Promise { - throw new Error('TODO'); - } -} From 0f84eb689a2ab3713534d0ddd016fe0c0ea3c785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:16:41 +0200 Subject: [PATCH 133/259] Remove `ObjectStore.manager.ts` stub --- .../src/BinaryData/ObjectStore.manager.ts | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 packages/core/src/BinaryData/ObjectStore.manager.ts diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts deleted file mode 100644 index 685f7a967a06b..0000000000000 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import type { BinaryMetadata } from 'n8n-workflow'; -import type { Readable } from 'stream'; -import type { BinaryData } from './types'; - -export class ObjectStoreManager implements BinaryData.Manager { - async init() { - throw new Error('TODO'); - } - - async store(binaryData: Buffer | Readable, executionId: string): Promise { - throw new Error('TODO'); - } - - getPath(identifier: string): string { - throw new Error('TODO'); - } - - async getSize(path: string): Promise { - throw new Error('TODO'); - } - - async getBuffer(identifier: string): Promise { - throw new Error('TODO'); - } - - getStream(identifier: string, chunkSize?: number): Readable { - throw new Error('TODO'); - } - - async storeMetadata(identifier: string, metadata: BinaryMetadata): Promise { - throw new Error('TODO'); - } - - async getMetadata(identifier: string): Promise { - throw new Error('TODO'); - } - - async copyByPath(path: string, executionId: string): Promise { - throw new Error('TODO'); - } - - async copyByIdentifier(identifier: string, executionId: string): Promise { - throw new Error('TODO'); - } - - async deleteOne(identifier: string): Promise { - throw new Error('TODO'); - } - - async deleteManyByExecutionIds(executionIds: string[]): Promise { - throw new Error('TODO'); - } -} From 9d3b7e53965efe938cc0d94baf65232ced498b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:23:27 +0200 Subject: [PATCH 134/259] Revert changes to lockfile --- pnpm-lock.yaml | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09084fcdde168..531b37cc1c7a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ importers: dependencies: axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) packages/@n8n_io/eslint-config: devDependencies: @@ -217,7 +217,7 @@ importers: version: 7.28.1 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -572,7 +572,7 @@ importers: version: link:../@n8n/client-oauth2 axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -838,7 +838,7 @@ importers: version: 10.2.0(vue@3.3.4) axios: specifier: ^0.21.1 - version: 0.21.4 + version: 0.21.4(debug@4.3.2) codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -5003,7 +5003,7 @@ packages: dependencies: '@segment/loosely-validate-event': 2.0.0 auto-changelog: 1.16.4 - axios: 0.21.4 + axios: 0.21.4(debug@4.3.2) axios-retry: 3.3.1 bull: 3.29.3 lodash.clonedeep: 4.5.0 @@ -9055,18 +9055,19 @@ packages: is-retry-allowed: 2.2.0 dev: false - /axios@0.21.4: + /axios@0.21.4(debug@4.3.2): resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.2(debug@4.3.2) transitivePeerDependencies: - debug dev: false - /axios@0.21.4(debug@4.3.2): - resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + /axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: follow-redirects: 1.15.2(debug@4.3.2) + form-data: 4.0.0 transitivePeerDependencies: - debug dev: false @@ -9092,7 +9093,7 @@ packages: /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: - follow-redirects: 1.15.2(debug@3.2.7) + follow-redirects: 1.15.2(debug@4.3.2) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -12625,16 +12626,6 @@ packages: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} dev: false - /follow-redirects@1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /follow-redirects@1.15.2(debug@3.2.7): resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} @@ -17999,7 +17990,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2(debug@3.2.7) + axios: 0.27.2 transitivePeerDependencies: - debug dev: false From a4a915a68c010e78d2dfba475c04742aa983fbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:25:51 +0200 Subject: [PATCH 135/259] Cleanup --- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index cae1523611b15..18fa761316aed 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -38,8 +38,8 @@ export class BinaryDataService { if (!manager) { const { size } = await stat(path); - binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); binaryData.fileSize = prettyBytes(size); + binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); return binaryData; } From cd501d764393e8972ff6a93215db522236a4c887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:31:50 +0200 Subject: [PATCH 136/259] Cleanup --- .../core/src/BinaryData/BinaryData.service.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 18fa761316aed..f4b816e6a5203 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -56,18 +56,18 @@ export class BinaryDataService { return binaryData; } - async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { + async store(binaryData: IBinaryData, bufferOrStream: Buffer | Readable, executionId: string) { const manager = this.managers[this.mode]; if (!manager) { - const buffer = await this.binaryToBuffer(input); + const buffer = await this.binaryToBuffer(bufferOrStream); binaryData.data = buffer.toString(BINARY_ENCODING); binaryData.fileSize = prettyBytes(buffer.length); return binaryData; } - const { fileId, fileSize } = await manager.store(input, executionId, { + const { fileId, fileSize } = await manager.store(bufferOrStream, executionId, { fileName: binaryData.fileName, mimeType: binaryData.mimeType, }); @@ -87,9 +87,9 @@ export class BinaryDataService { } getAsStream(identifier: string, chunkSize?: number) { - const { mode, fileId } = this.splitModeFileId(identifier); + const [mode, uuid] = identifier.split(':'); - return this.getManager(mode).getStream(fileId, chunkSize); + return this.getManager(mode).getStream(uuid, chunkSize); } async getBinaryDataBuffer(binaryData: IBinaryData) { @@ -99,21 +99,21 @@ export class BinaryDataService { } async retrieveBinaryDataByIdentifier(identifier: string) { - const { mode, fileId } = this.splitModeFileId(identifier); + const [mode, uuid] = identifier.split(':'); - return this.getManager(mode).getBuffer(fileId); + return this.getManager(mode).getBuffer(uuid); } getPath(identifier: string) { - const { mode, fileId } = this.splitModeFileId(identifier); + const [mode, uuid] = identifier.split(':'); - return this.getManager(mode).getPath(fileId); + return this.getManager(mode).getPath(uuid); } async getMetadata(identifier: string) { - const { mode, fileId } = this.splitModeFileId(identifier); + const [mode, uuid] = identifier.split(':'); - return this.getManager(mode).getMetadata(fileId); + return this.getManager(mode).getMetadata(uuid); } async deleteManyByExecutionIds(executionIds: string[]) { @@ -157,12 +157,6 @@ export class BinaryDataService { return `${this.mode}:${fileId}`; } - private splitModeFileId(identifier: string) { - const [mode, fileId] = identifier.split(':'); - - return { mode, fileId }; - } - private async duplicateBinaryDataInExecData( executionData: INodeExecutionData, executionId: string, @@ -181,9 +175,9 @@ export class BinaryDataService { return { key, newId: undefined }; } - const fileId = this.splitModeFileId(binaryDataId).fileId; + const [_mode, uuid] = binaryDataId.split(':'); - return manager?.copyByFileId(fileId, executionId).then((newFileId) => ({ + return manager?.copyByFileId(uuid, executionId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); From 824fc210c834f6796fb300ad08bf754c3a7564c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:42:33 +0200 Subject: [PATCH 137/259] Remove unneeded comment --- packages/core/src/BinaryData/FileSystem.manager.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 357eeac9f2315..920954c02da8b 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -131,9 +131,6 @@ export class FileSystemManager implements BinaryData.Manager { } } - /** - * Create an identifier `${executionId}{uuid}` for a binary data file. - */ private createFileId(executionId: string) { return [executionId, uuid()].join(''); } From 875188b596ba67104a8cb3cbb8f5ee6b4911e708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:45:20 +0200 Subject: [PATCH 138/259] Better naming --- .../core/src/BinaryData/BinaryData.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index f4b816e6a5203..fbcab28035a97 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -86,8 +86,8 @@ export class BinaryDataService { }); } - getAsStream(identifier: string, chunkSize?: number) { - const [mode, uuid] = identifier.split(':'); + getAsStream(binaryDataId: string, chunkSize?: number) { + const [mode, uuid] = binaryDataId.split(':'); return this.getManager(mode).getStream(uuid, chunkSize); } @@ -98,20 +98,20 @@ export class BinaryDataService { return Buffer.from(binaryData.data, BINARY_ENCODING); } - async retrieveBinaryDataByIdentifier(identifier: string) { - const [mode, uuid] = identifier.split(':'); + async retrieveBinaryDataByIdentifier(binaryDataId: string) { + const [mode, uuid] = binaryDataId.split(':'); return this.getManager(mode).getBuffer(uuid); } - getPath(identifier: string) { - const [mode, uuid] = identifier.split(':'); + getPath(binaryDataId: string) { + const [mode, uuid] = binaryDataId.split(':'); return this.getManager(mode).getPath(uuid); } - async getMetadata(identifier: string) { - const [mode, uuid] = identifier.split(':'); + async getMetadata(binaryDataId: string) { + const [mode, uuid] = binaryDataId.split(':'); return this.getManager(mode).getMetadata(uuid); } From f3a97293c2993556d148179172cec390d504ccf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:47:12 +0200 Subject: [PATCH 139/259] Cleanup --- .../core/src/BinaryData/BinaryData.service.ts | 16 +++++++--------- .../core/src/BinaryData/FileSystem.manager.ts | 4 ++-- packages/core/src/BinaryData/types.ts | 4 ++-- packages/core/src/NodeExecuteFunctions.ts | 2 +- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index fbcab28035a97..b7098a9e42978 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -89,19 +89,17 @@ export class BinaryDataService { getAsStream(binaryDataId: string, chunkSize?: number) { const [mode, uuid] = binaryDataId.split(':'); - return this.getManager(mode).getStream(uuid, chunkSize); + return this.getManager(mode).getAsStream(uuid, chunkSize); } - async getBinaryDataBuffer(binaryData: IBinaryData) { - if (binaryData.id) return this.retrieveBinaryDataByIdentifier(binaryData.id); + async getAsBuffer(binaryData: IBinaryData) { + if (binaryData.id) { + const [mode, uuid] = binaryData.id.split(':'); - return Buffer.from(binaryData.data, BINARY_ENCODING); - } - - async retrieveBinaryDataByIdentifier(binaryDataId: string) { - const [mode, uuid] = binaryDataId.split(':'); + return this.getManager(mode).getAsBuffer(uuid); + } - return this.getManager(mode).getBuffer(uuid); + return Buffer.from(binaryData.data, BINARY_ENCODING); } getPath(binaryDataId: string) { diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 920954c02da8b..e28eb394dd343 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -30,13 +30,13 @@ export class FileSystemManager implements BinaryData.Manager { return stats.size; } - getStream(fileId: string, chunkSize?: number) { + getAsStream(fileId: string, chunkSize?: number) { const filePath = this.getPath(fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); } - async getBuffer(fileId: string) { + async getAsBuffer(fileId: string) { const filePath = this.getPath(fileId); try { diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 980f6e8412d5c..de57309e3b43d 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -26,8 +26,8 @@ export namespace BinaryData { ): Promise<{ fileId: string; fileSize: number }>; getPath(identifier: string): string; - getBuffer(identifier: string): Promise; - getStream(identifier: string, chunkSize?: number): Readable; + getAsBuffer(identifier: string): Promise; + getAsStream(identifier: string, chunkSize?: number): Readable; getMetadata(identifier: string): Promise; // @TODO: Refactor to also use `workflowId` to support full path-like identifier: diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 48111d3f4b3e0..b5eab21853af1 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -992,7 +992,7 @@ export async function getBinaryDataBuffer( inputIndex: number, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return Container.get(BinaryDataService).getBinaryDataBuffer(binaryData); + return Container.get(BinaryDataService).getAsBuffer(binaryData); } /** From f85cf8b80ae84127150cd5e68c9ac58961d4512d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:50:22 +0200 Subject: [PATCH 140/259] Move `ensureDirExists` to utils --- packages/core/src/BinaryData/FileSystem.manager.ts | 11 ++--------- packages/core/src/BinaryData/utils.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index e28eb394dd343..11be3b5a7fea3 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -8,6 +8,7 @@ import { FileNotFoundError } from '../errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; +import { ensureDirExists } from './utils'; const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; @@ -16,7 +17,7 @@ export class FileSystemManager implements BinaryData.Manager { constructor(private storagePath: string) {} async init() { - await this.ensureDirExists(this.storagePath); + await ensureDirExists(this.storagePath); } getPath(fileId: string) { @@ -123,14 +124,6 @@ export class FileSystemManager implements BinaryData.Manager { // private methods // ---------------------------------- - private async ensureDirExists(dir: string) { - try { - await fs.access(dir); - } catch { - await fs.mkdir(dir, { recursive: true }); - } - } - private createFileId(executionId: string) { return [executionId, uuid()].join(''); } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 82b7ef35b080e..fabfedd81c442 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -1,3 +1,4 @@ +import fs from 'fs/promises'; import type { BinaryData } from './types'; /** @@ -11,3 +12,11 @@ export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); } + +export async function ensureDirExists(dir: string) { + try { + await fs.access(dir); + } catch { + await fs.mkdir(dir, { recursive: true }); + } +} From 571fe0a43e6fa3e3197cd760830cd0f6deca255d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 09:54:48 +0200 Subject: [PATCH 141/259] Consolidate errors --- packages/core/src/BinaryData/BinaryData.service.ts | 4 ++-- packages/core/src/BinaryData/FileSystem.manager.ts | 6 +++--- packages/core/src/BinaryData/errors.ts | 12 +++++++++--- packages/core/src/errors.ts | 5 ----- 4 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 packages/core/src/errors.ts diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index b7098a9e42978..c1db77756e39e 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -6,7 +6,7 @@ import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; import { areValidModes } from './utils'; -import { MissingBinaryDataManager, InvalidBinaryDataMode } from './errors'; +import { BinaryDataManagerNotFound, InvalidBinaryDataMode } from './errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -200,6 +200,6 @@ export class BinaryDataService { if (manager) return manager; - throw new MissingBinaryDataManager(); + throw new BinaryDataManagerNotFound(mode); } } diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 11be3b5a7fea3..f8d76a7340467 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -4,11 +4,11 @@ import path from 'path'; import { v4 as uuid } from 'uuid'; import { jsonParse } from 'n8n-workflow'; -import { FileNotFoundError } from '../errors'; +import { FileNotFound } from './errors'; +import { ensureDirExists } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; -import { ensureDirExists } from './utils'; const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; @@ -132,7 +132,7 @@ export class FileSystemManager implements BinaryData.Manager { const returnPath = path.join(this.storagePath, ...args); if (path.relative(this.storagePath, returnPath).startsWith('..')) { - throw new FileNotFoundError('Invalid path detected'); + throw new FileNotFound('Invalid path detected'); } return returnPath; diff --git a/packages/core/src/BinaryData/errors.ts b/packages/core/src/BinaryData/errors.ts index 70f202d950a36..ad0c0e8b86fe3 100644 --- a/packages/core/src/BinaryData/errors.ts +++ b/packages/core/src/BinaryData/errors.ts @@ -6,8 +6,14 @@ export class InvalidBinaryDataMode extends Error { } } -export class MissingBinaryDataManager extends Error { - constructor() { - super('No binary data manager found'); +export class BinaryDataManagerNotFound extends Error { + constructor(mode: string) { + super(`No binary data manager found for: ${mode}`); + } +} + +export class FileNotFound extends Error { + constructor(filePath: string) { + super(`File not found: ${filePath}`); } } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts deleted file mode 100644 index c425675c89371..0000000000000 --- a/packages/core/src/errors.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class FileNotFoundError extends Error { - constructor(readonly filePath: string) { - super(`File not found: ${filePath}`); - } -} From 03b39475746481040d516cef4766647cf68c6555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:01:55 +0200 Subject: [PATCH 142/259] Improve typings --- packages/core/src/BinaryData/types.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index de57309e3b43d..73e15fa8a8724 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -16,31 +16,33 @@ export namespace BinaryData { fileSize: number; }; + type PreStoreMetadata = Omit; + export interface Manager { init(): Promise; store( binaryData: Buffer | Readable, executionId: string, - metadata: { mimeType: string; fileName?: string }, + preStoreMetadata: PreStoreMetadata, ): Promise<{ fileId: string; fileSize: number }>; - getPath(identifier: string): string; - getAsBuffer(identifier: string): Promise; - getAsStream(identifier: string, chunkSize?: number): Readable; - getMetadata(identifier: string): Promise; + getPath(fileId: string): string; + getAsBuffer(fileId: string): Promise; + getAsStream(fileId: string, chunkSize?: number): Readable; + getMetadata(fileId: string): Promise; // @TODO: Refactor to also use `workflowId` to support full path-like identifier: // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` copyByFilePath( path: string, executionId: string, - metadata: { mimeType: string; fileName?: string }, + metadata: PreStoreMetadata, ): Promise<{ fileId: string; fileSize: number }>; - copyByFileId(identifier: string, prefix: string): Promise; + copyByFileId(fileId: string, prefix: string): Promise; - deleteOne(identifier: string): Promise; + deleteOne(fileId: string): Promise; // @TODO: Refactor to also receive `workflowId` to support full path-like identifier: // `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` From 594aab12f66c4ace998f7af4281230d6f4d60ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:15:07 +0200 Subject: [PATCH 143/259] Fix misresolved conflict --- packages/core/src/BinaryData/BinaryData.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index f28bb245d6d65..a1ce9c8405e28 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -108,11 +108,11 @@ export class BinaryDataService { return Buffer.from(binaryData.data, BINARY_ENCODING); } - async getAsBuffer(binaryData: IBinaryData) { + async getAsBuffer(workflowId: string, binaryData: IBinaryData) { if (binaryData.id) { const [mode, uuid] = binaryData.id.split(':'); - return this.getManager(mode).getAsBuffer(uuid); + return this.getManager(mode).getAsBuffer(workflowId, uuid); } return Buffer.from(binaryData.data, BINARY_ENCODING); From e190efd989a78e103d06fc8d212a01c0a3bc41b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:20:15 +0200 Subject: [PATCH 144/259] Restore error --- packages/core/src/BinaryData/FileSystem.manager.ts | 4 ++-- packages/core/src/BinaryData/errors.ts | 6 ------ packages/core/src/errors.ts | 5 +++++ 3 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/errors.ts diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index f8d76a7340467..bad442bec6e6a 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -4,7 +4,7 @@ import path from 'path'; import { v4 as uuid } from 'uuid'; import { jsonParse } from 'n8n-workflow'; -import { FileNotFound } from './errors'; +import { FileNotFoundError } from '../errors'; import { ensureDirExists } from './utils'; import type { Readable } from 'stream'; @@ -132,7 +132,7 @@ export class FileSystemManager implements BinaryData.Manager { const returnPath = path.join(this.storagePath, ...args); if (path.relative(this.storagePath, returnPath).startsWith('..')) { - throw new FileNotFound('Invalid path detected'); + throw new FileNotFoundError('Invalid path detected'); } return returnPath; diff --git a/packages/core/src/BinaryData/errors.ts b/packages/core/src/BinaryData/errors.ts index ad0c0e8b86fe3..29cfab8ab963c 100644 --- a/packages/core/src/BinaryData/errors.ts +++ b/packages/core/src/BinaryData/errors.ts @@ -11,9 +11,3 @@ export class BinaryDataManagerNotFound extends Error { super(`No binary data manager found for: ${mode}`); } } - -export class FileNotFound extends Error { - constructor(filePath: string) { - super(`File not found: ${filePath}`); - } -} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000000000..c425675c89371 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,5 @@ +export class FileNotFoundError extends Error { + constructor(readonly filePath: string) { + super(`File not found: ${filePath}`); + } +} From 605d02b91a47598087061d4caf1fd1658b4ccd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:29:57 +0200 Subject: [PATCH 145/259] Revert rename to `BinaryDataManager` --- packages/cli/src/Server.ts | 2 +- packages/cli/src/WorkflowRunnerProcess.ts | 2 +- packages/cli/src/commands/BaseCommand.ts | 2 +- packages/cli/src/config/schema.ts | 2 +- packages/cli/src/config/types.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9e0bbec7b9cf0..ee3a67d7aaec5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -366,7 +366,7 @@ export class Server extends AbstractServer { LoggerProxy.debug(`Server ID: ${this.uniqueInstanceId}`); const cpus = os.cpus(); - const binaryDataConfig = config.getEnv('binaryDataService'); + const binaryDataConfig = config.getEnv('binaryDataManager'); const diagnosticInfo: IDiagnosticInfo = { databaseType: config.getEnv('database.type'), disableProductionWebhooksOnMainProcess: config.getEnv( diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index ee1b95932d714..4266f0facf6fe 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -123,7 +123,7 @@ class WorkflowRunnerProcess { await Container.get(PostHogClient).init(instanceId); await Container.get(InternalHooks).init(instanceId); - const binaryDataConfig = config.getEnv('binaryDataService'); + const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); const license = Container.get(License); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 17a95a155f6fe..17cc38c1f08c7 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -104,7 +104,7 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { - const binaryDataConfig = config.getEnv('binaryDataService'); + const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 2529354c15334..bf7e7750c179d 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -893,7 +893,7 @@ export const schema = { }, }, - binaryDataService: { + binaryDataManager: { availableModes: { format: 'comma-separated-list', default: 'filesystem', diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 6b87c8016e3fd..b5646ff5ae8fb 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -76,7 +76,7 @@ type ToReturnType = T extends NumericPath type ExceptionPaths = { 'queue.bull.redis': object; - binaryDataService: BinaryData.Config; + binaryDataManager: BinaryData.Config; 'nodes.exclude': string[] | undefined; 'nodes.include': string[] | undefined; 'userManagement.isInstanceOwnerSetUp': boolean; From b49cb7acde183ab895fbe48e2b3394cdf68ddfd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:41:35 +0200 Subject: [PATCH 146/259] Cleanup --- packages/core/src/BinaryData/types.ts | 1 - packages/workflow/src/Workflow.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index f055da4ff6b4e..923de45f9ad14 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -27,7 +27,6 @@ export namespace BinaryData { workflowId: string, executionId: string, binaryData: Buffer | Readable, - metadata: PreStoreMetadata, ): Promise; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index a5f6e663319b3..905d26e331cbd 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -92,7 +92,7 @@ export class Workflow { settings?: IWorkflowSettings; pinData?: IPinData; }) { - this.id = parameters.id as string; // @TODO: Why is this optional? + this.id = parameters.id as string; // @TODO: Why is this arg optional? this.name = parameters.name; this.nodeTypes = parameters.nodeTypes; this.pinData = parameters.pinData; From ba91b0510587aa0fe40822fe3501bec91f679f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:42:25 +0200 Subject: [PATCH 147/259] More accurate naming --- .../core/src/BinaryData/BinaryData.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index c1db77756e39e..901a502e05f75 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -87,31 +87,31 @@ export class BinaryDataService { } getAsStream(binaryDataId: string, chunkSize?: number) { - const [mode, uuid] = binaryDataId.split(':'); + const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getAsStream(uuid, chunkSize); + return this.getManager(mode).getAsStream(fileId, chunkSize); } async getAsBuffer(binaryData: IBinaryData) { if (binaryData.id) { - const [mode, uuid] = binaryData.id.split(':'); + const [mode, fileId] = binaryData.id.split(':'); - return this.getManager(mode).getAsBuffer(uuid); + return this.getManager(mode).getAsBuffer(fileId); } return Buffer.from(binaryData.data, BINARY_ENCODING); } getPath(binaryDataId: string) { - const [mode, uuid] = binaryDataId.split(':'); + const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getPath(uuid); + return this.getManager(mode).getPath(fileId); } async getMetadata(binaryDataId: string) { - const [mode, uuid] = binaryDataId.split(':'); + const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getMetadata(uuid); + return this.getManager(mode).getMetadata(fileId); } async deleteManyByExecutionIds(executionIds: string[]) { @@ -173,9 +173,9 @@ export class BinaryDataService { return { key, newId: undefined }; } - const [_mode, uuid] = binaryDataId.split(':'); + const [_mode, fileId] = binaryDataId.split(':'); - return manager?.copyByFileId(uuid, executionId).then((newFileId) => ({ + return manager?.copyByFileId(fileId, executionId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); From c430f22a09e46566456b8cbfd9b75389730ea689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 10:59:15 +0200 Subject: [PATCH 148/259] Missing renaming in test --- packages/cli/test/integration/shared/utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index c4412119b6416..ee4b1e57509f3 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -77,7 +77,7 @@ export async function initNodeTypes() { export async function initBinaryDataService() { const binaryDataService = new BinaryDataService(); - await binaryDataService.init(config.getEnv('binaryDataService')); + await binaryDataService.init(config.getEnv('binaryDataManager')); Container.set(BinaryDataService, binaryDataService); } From 73b38df2aa9ee898de9810e327402fa31d689c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 11:40:40 +0200 Subject: [PATCH 149/259] Initial setup --- packages/cli/src/commands/BaseCommand.ts | 60 ++++- packages/cli/src/config/schema.ts | 34 +++ packages/core/package.json | 8 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 229 ++++++++++++++++++ packages/core/src/ObjectStore/types.ts | 21 ++ packages/core/src/ObjectStore/utils.ts | 16 ++ packages/core/src/index.ts | 1 + pnpm-lock.yaml | 41 ++-- 8 files changed, 392 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/ObjectStore/ObjectStore.service.ee.ts create mode 100644 packages/core/src/ObjectStore/types.ts create mode 100644 packages/core/src/ObjectStore/utils.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 17cc38c1f08c7..fa22643fb5785 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,9 +1,12 @@ +// import fs from 'node:fs'; +// import { pipeline } from 'node:stream/promises'; +// import { readFile } from 'node:fs/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings } from 'n8n-core'; +import { BinaryDataService, UserSettings, ObjectStoreService } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -104,6 +107,61 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { + /** + * @TODO: Remove - only for testing in dev + */ + + const objectStoreService = new ObjectStoreService( + { + name: config.getEnv('externalStorage.s3.bucket.name'), + region: config.getEnv('externalStorage.s3.bucket.region'), + }, + { + accountId: config.getEnv('externalStorage.s3.credentials.accountId'), + secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), + }, + ); + + await objectStoreService.checkConnection(); + + /** + * Test upload + */ + + // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; + // const buffer = Buffer.from(await readFile(filePath)); + // const res = await objectStoreService.put('object-store-service-dog.jpg', buffer); + // console.log('upload result', res.status); + + /** + * Test deletion + */ + + // const res = await objectStoreService.deleteMany('uploaded'); + // console.log('res', res); + + // await objectStoreService.deleteOne('object-store-service-dog.jpg'); + + /** + * Test listing + */ + + // const res = await objectStoreService.list('happy'); + // console.log('res', res); + + /** + * Test download + */ + + // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); + // try { + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); + // console.log('✅ Pipeline succeeded'); + // } catch (error) { + // console.log('❌ Pipeline failed', error); + // } + const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index bf7e7750c179d..19f89ef74c197 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -914,6 +914,40 @@ export const schema = { }, }, + externalStorage: { + s3: { + // @TODO: service name + bucket: { + name: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', + doc: 'Name of the n8n bucket in S3-compatible external storage', + }, + region: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', + doc: 'Region of the n8n bucket in S3-compatible external storage', + }, + }, + credentials: { + accountId: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', + doc: 'Account ID in S3-compatible external storage', + }, + secretKey: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', + doc: 'Secret key in S3-compatible external storage', + }, + }, + }, + }, + deployment: { type: { format: String, diff --git a/packages/core/package.json b/packages/core/package.json index bd6d57c0fca25..7ece0a28448fa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "bin" ], "devDependencies": { + "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", "@types/cron": "~1.7.1", "@types/crypto-js": "^4.0.1", @@ -41,13 +42,15 @@ "@types/lodash": "^4.14.195", "@types/mime-types": "^2.1.0", "@types/request-promise-native": "~1.0.15", - "@types/uuid": "^8.3.2" + "@types/uuid": "^8.3.2", + "@types/xml2js": "^0.4.11" }, "peerDependencies": { "n8n-nodes-base": "workspace:*" }, "dependencies": { "@n8n/client-oauth2": "workspace:*", + "aws4": "^1.8.0", "axios": "^0.21.1", "concat-stream": "^2.0.0", "cron": "~1.7.2", @@ -64,6 +67,7 @@ "pretty-bytes": "^5.6.0", "qs": "^6.10.1", "typedi": "^0.10.0", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "xml2js": "^0.5.0" } } diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts new file mode 100644 index 0000000000000..5fa26c626066a --- /dev/null +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import axios from 'axios'; +import { Service } from 'typedi'; +import { sign } from 'aws4'; +import { isStream, parseXml } from './utils'; +import { createHash } from 'node:crypto'; +import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; +import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; +import type { ListPage, RawListPage } from './types'; + +// @TODO: Decouple host from AWS + +@Service() +export class ObjectStoreService { + private credentials: Aws4Credentials; + + constructor( + private bucket: { region: string; name: string }, + credentials: { accountId: string; secretKey: string }, + ) { + this.credentials = { + accessKeyId: credentials.accountId, + secretAccessKey: credentials.secretKey, + }; + } + + /** + * Confirm that the configured bucket exists and the caller has permission to access it. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html + */ + async checkConnection() { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + return this.request('HEAD', host); + } + + /** + * Upload an object to the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + */ + async put(filename: string, buffer: Buffer) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const headers = { + 'Content-Length': buffer.length, + 'Content-MD5': createHash('md5').update(buffer).digest('base64'), + }; + + return this.request('PUT', host, `/${filename}`, { headers, body: buffer }); + } + + /** + * Download an object as a stream or buffer from the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + */ + async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const { data } = await this.request('GET', host, path, { + responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', + }); + + if (mode === 'stream' && isStream(data)) return data; + + if (mode === 'buffer' && Buffer.isBuffer(data)) return data; + + throw new TypeError(`Expected ${mode} but received ${typeof data}.`); + } + + /** + * Delete an object in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + */ + async deleteOne(path: string) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + return this.request('DELETE', host, `/${encodeURIComponent(path)}`); + } + + /** + * Delete objects with a common prefix in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html + */ + async deleteMany(prefix: string) { + const objects = await this.list(prefix); + + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + const innerXml = objects.map((o) => `${o.key}`).join('\n'); + + const body = ['', innerXml, ''].join('\n'); + + const headers = { + 'Content-Type': 'application/xml', + 'Content-Length': body.length, + 'Content-MD5': createHash('md5').update(body).digest('base64'), + }; + + return this.request('POST', host, '/?delete', { headers, body }); + } + + /** + * List objects with a common prefix in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + */ + async list(prefix: string) { + const items = []; + + let isTruncated; + let token; // for next page + + do { + const listPage = await this.getListPage(prefix, token); + + if (listPage.contents?.length > 0) items.push(...listPage.contents); + + isTruncated = listPage.isTruncated; + token = listPage.nextContinuationToken; + } while (isTruncated && token); + + return items; + } + + /** + * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. + */ + private async getListPage(prefix: string, nextPageToken?: string) { + const host = `s3.${this.bucket.region}.amazonaws.com`; + + const qs: Record = { 'list-type': 2, prefix }; + + if (nextPageToken) qs['continuation-token'] = nextPageToken; + + const response = await this.request('GET', host, `/${this.bucket.name}`, { qs }); + + if (typeof response.data !== 'string') { + throw new TypeError('Expected string'); + } + + const { listBucketResult: page } = await parseXml(response.data); + + if (!page.contents) return { ...page, contents: [] }; + + // restore array wrapper removed by `explicitArray: false` on single item array + + if (!Array.isArray(page.contents)) { + page.contents = [page.contents]; + } + + // remove null prototype - https://github.com/Leonidas-from-XIV/node-xml2js/issues/670 + + page.contents.forEach((item) => { + Object.setPrototypeOf(item, Object.prototype); + }); + + return page as ListPage; + } + + private toPath(rawPath: string, qs?: Record) { + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + + if (!qs) return path; + + const qsParams = Object.keys(qs) + .map((key) => `${key}=${qs[key]}`) + .join('&'); + + return path.concat(`?${qsParams}`); + } + + private async request( + method: Method, + host: string, + rawPath = '', + { + qs, + headers, + body, + responseType, + }: { + qs?: Record; + headers?: Record; + body?: string | Buffer; + responseType?: ResponseType; + } = {}, + ) { + const path = this.toPath(rawPath, qs); + + const optionsToSign: Aws4Options = { + method, + service: 's3', + region: this.bucket.region, + host, + path, + }; + + if (headers) optionsToSign.headers = headers; + if (body) optionsToSign.body = body; + + const signedOptions = sign(optionsToSign, this.credentials); + + const config: AxiosRequestConfig = { + method, + url: `https://${host}${path}`, + headers: signedOptions.headers, + }; + + if (body) config.data = body; + if (responseType) config.responseType = responseType; + + // console.log(config); + + try { + return await axios.request(config); + } catch (error) { + // console.log(error); + throw new Error('Request to external object storage failed', { + cause: { error: error as unknown, details: config }, + }); + } + } +} diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts new file mode 100644 index 0000000000000..2e9d250c6d301 --- /dev/null +++ b/packages/core/src/ObjectStore/types.ts @@ -0,0 +1,21 @@ +export type RawListPage = { + listBucketResult: { + name: string; + prefix: string; + keyCount: number; + maxKeys: number; + isTruncated: boolean; + nextContinuationToken?: string; // only if isTruncated is true + contents?: Item | Item[]; + }; +}; + +type Item = { + key: string; + lastModified: string; + eTag: string; + size: number; // bytes + storageClass: string; +}; + +export type ListPage = Omit & { contents: Item[] }; diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts new file mode 100644 index 0000000000000..76dcb1f076b1d --- /dev/null +++ b/packages/core/src/ObjectStore/utils.ts @@ -0,0 +1,16 @@ +import { Stream } from 'node:stream'; +import { parseStringPromise } from 'xml2js'; +import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors'; + +export function isStream(maybeStream: unknown): maybeStream is Stream { + return maybeStream instanceof Stream; +} + +export async function parseXml(xml: string): Promise { + return parseStringPromise(xml, { + explicitArray: false, + ignoreAttrs: true, + tagNameProcessors: [firstCharLowerCase], + valueProcessors: [parseNumbers, parseBooleans], + }) as Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d6b5bfeebda3..9fdb093baa574 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,4 @@ export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; +export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 531b37cc1c7a5..35d3cd5356edc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ importers: dependencies: axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 packages/@n8n_io/eslint-config: devDependencies: @@ -217,7 +217,7 @@ importers: version: 7.28.1 axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -570,9 +570,12 @@ importers: '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 + aws4: + specifier: ^1.8.0 + version: 1.11.0 axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 concat-stream: specifier: ^2.0.0 version: 2.0.0 @@ -624,7 +627,13 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 + xml2js: + specifier: ^0.5.0 + version: 0.5.0 devDependencies: + '@types/aws4': + specifier: ^1.5.1 + version: 1.11.2 '@types/concat-stream': specifier: ^2.0.0 version: 2.0.0 @@ -649,6 +658,9 @@ importers: '@types/uuid': specifier: ^8.3.2 version: 8.3.4 + '@types/xml2js': + specifier: ^0.4.11 + version: 0.4.11 packages/design-system: dependencies: @@ -838,7 +850,7 @@ importers: version: 10.2.0(vue@3.3.4) axios: specifier: ^0.21.1 - version: 0.21.4(debug@4.3.2) + version: 0.21.4 codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -5003,7 +5015,7 @@ packages: dependencies: '@segment/loosely-validate-event': 2.0.0 auto-changelog: 1.16.4 - axios: 0.21.4(debug@4.3.2) + axios: 0.21.4 axios-retry: 3.3.1 bull: 3.29.3 lodash.clonedeep: 4.5.0 @@ -6756,7 +6768,7 @@ packages: ts-dedent: 2.2.0 type-fest: 3.13.1 vue: 3.3.4 - vue-component-type-helpers: 1.8.11 + vue-component-type-helpers: 1.8.13 transitivePeerDependencies: - encoding - supports-color @@ -9055,19 +9067,18 @@ packages: is-retry-allowed: 2.2.0 dev: false - /axios@0.21.4(debug@4.3.2): + /axios@0.21.4: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: - follow-redirects: 1.15.2(debug@4.3.2) + follow-redirects: 1.15.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false - /axios@0.27.2: - resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + /axios@0.21.4(debug@4.3.2): + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} dependencies: follow-redirects: 1.15.2(debug@4.3.2) - form-data: 4.0.0 transitivePeerDependencies: - debug dev: false @@ -9093,7 +9104,7 @@ packages: /axios@1.4.0: resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} dependencies: - follow-redirects: 1.15.2(debug@4.3.2) + follow-redirects: 1.15.2(debug@3.2.7) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -17990,7 +18001,7 @@ packages: resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==} engines: {node: '>=14.17.0'} dependencies: - axios: 0.27.2 + axios: 0.27.2(debug@3.2.7) transitivePeerDependencies: - debug dev: false @@ -21721,8 +21732,8 @@ packages: vue: 3.3.4 dev: false - /vue-component-type-helpers@1.8.11: - resolution: {integrity: sha512-CWItFzuEWjkSXDeMGwQEc5cFH4FaueyPQHfi1mBDe+wA2JABqNjFxFUtmZJ9WHkb0HpEwqgBg1umiXrWYXkXHw==} + /vue-component-type-helpers@1.8.13: + resolution: {integrity: sha512-zbCQviVRexZ7NF2kizQq5LicG5QGXPHPALKE3t59f5q2FwaG9GKtdhhIV4rw4LDUm9RkvGAP8TSXlXcBWY8rFQ==} dev: true /vue-component-type-helpers@1.8.4: From 73c0cb38911d0f53cc1e144cc490dad7d7d826ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 11:52:19 +0200 Subject: [PATCH 150/259] Add stub for `ObjectStore.manager.ts` --- .../core/src/BinaryData/FileSystem.manager.ts | 2 +- .../src/BinaryData/ObjectStore.manager.ts | 56 +++++++++++++++++++ packages/core/src/BinaryData/types.ts | 6 +- 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/BinaryData/ObjectStore.manager.ts diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 24d5fdc6cda74..fff631b959f13 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -62,7 +62,7 @@ export class FileSystemManager implements BinaryData.Manager { workflowId: string, executionId: string, binaryData: Buffer | Readable, - { mimeType, fileName }: { mimeType: string; fileName?: string }, + { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const fileId = this.createFileId(executionId); const filePath = this.getPath(workflowId, fileId); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts new file mode 100644 index 0000000000000..b485bb1c08477 --- /dev/null +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import type { Readable } from 'stream'; +import type { BinaryData } from './types'; + +export class ObjectStoreManager implements BinaryData.Manager { + async init() { + throw new Error('TODO'); + } + + async store( + workflowId: string, + executionId: string, + binaryData: Buffer | Readable, + metadata: BinaryData.PreWriteMetadata, + ): Promise { + throw new Error('TODO'); + } + + getPath(workflowId: string, fileId: string): string { + throw new Error('TODO'); + } + + async getAsBuffer(workflowId: string, fileId: string): Promise { + throw new Error('TODO'); + } + + getAsStream(workflowId: string, fileId: string, chunkSize?: number): Readable { + throw new Error('TODO'); + } + + async getMetadata(workflowId: string, fileId: string): Promise { + throw new Error('TODO'); + } + + async copyByFileId(workflowId: string, fileId: string, executionId: string): Promise { + throw new Error('TODO'); + } + + async copyByFilePath( + workflowId: string, + executionId: string, + path: string, + metadata: BinaryData.PreWriteMetadata, + ): Promise { + throw new Error('TODO'); + } + + async deleteOne(workflowId: string, fileId: string): Promise { + throw new Error('TODO'); + } + + async deleteManyByExecutionIds(executionIds: string[]): Promise { + throw new Error('TODO'); + } +} diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 923de45f9ad14..0cbc6a35be21d 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -18,7 +18,7 @@ export namespace BinaryData { export type WriteResult = { fileId: string; fileSize: number }; - type PreStoreMetadata = Omit; + export type PreWriteMetadata = Omit; export interface Manager { init(): Promise; @@ -27,7 +27,7 @@ export namespace BinaryData { workflowId: string, executionId: string, binaryData: Buffer | Readable, - metadata: PreStoreMetadata, + metadata: PreWriteMetadata, ): Promise; getPath(workflowId: string, fileId: string): string; @@ -41,7 +41,7 @@ export namespace BinaryData { workflowId: string, executionId: string, filePath: string, - metadata: { mimeType: string; fileName?: string }, + metadata: PreWriteMetadata, ): Promise; deleteOne(workflowId: string, fileId: string): Promise; From ed9d514577b7a98bae0135f68a457dc6c348c19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 11:54:55 +0200 Subject: [PATCH 151/259] Tighten typings --- packages/core/src/BinaryData/FileSystem.manager.ts | 4 ++-- packages/core/src/BinaryData/types.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index bad442bec6e6a..d6e9ae49cf5d0 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -56,7 +56,7 @@ export class FileSystemManager implements BinaryData.Manager { async store( binaryData: Buffer | Readable, executionId: string, - { mimeType, fileName }: { mimeType: string; fileName?: string }, + { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const fileId = this.createFileId(executionId); const filePath = this.getPath(fileId); @@ -99,7 +99,7 @@ export class FileSystemManager implements BinaryData.Manager { async copyByFilePath( filePath: string, executionId: string, - { mimeType, fileName }: { mimeType: string; fileName?: string }, + { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const newFileId = this.createFileId(executionId); diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 73e15fa8a8724..368e3717d40cc 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -16,7 +16,7 @@ export namespace BinaryData { fileSize: number; }; - type PreStoreMetadata = Omit; + export type PreWriteMetadata = Omit; export interface Manager { init(): Promise; @@ -24,7 +24,7 @@ export namespace BinaryData { store( binaryData: Buffer | Readable, executionId: string, - preStoreMetadata: PreStoreMetadata, + preStoreMetadata: PreWriteMetadata, ): Promise<{ fileId: string; fileSize: number }>; getPath(fileId: string): string; @@ -37,7 +37,7 @@ export namespace BinaryData { copyByFilePath( path: string, executionId: string, - metadata: PreStoreMetadata, + metadata: PreWriteMetadata, ): Promise<{ fileId: string; fileSize: number }>; copyByFileId(fileId: string, prefix: string): Promise; From 0fe0aeba38436c6a0188de1d4e681eeeaa1ac721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:21:35 +0200 Subject: [PATCH 152/259] Mark spots to update --- packages/cli/src/Server.ts | 1 + packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts | 1 + .../nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts | 1 + .../nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 41fa051d9a88d..0711e6e366e23 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1429,6 +1429,7 @@ export class Server extends AbstractServer { const identifier = req.params.path; const workflowId = req.params.workflowId; try { + // @TODO: Decouple from FS const binaryPath = this.binaryDataService.getPath(workflowId, identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { diff --git a/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts b/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts index b638862970662..c5490cc79034e 100644 --- a/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts +++ b/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts @@ -93,6 +93,7 @@ export class ReadPDF implements INodeType { } if (binaryData.id) { + // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); params.url = new URL(`file://${binaryPath}`); } else { diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts index 17f57f3014c33..4ca3020d4853f 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts @@ -76,6 +76,7 @@ export class SpreadsheetFileV1 implements INodeType { if (options.readAsString) xlsxOptions.type = 'string'; if (binaryData.id) { + // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); workbook = xlsxReadFile(binaryPath, xlsxOptions); } else { diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts index fd87ebd2f4d5b..de83ecb79deee 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts @@ -103,6 +103,7 @@ export class SpreadsheetFileV2 implements INodeType { if (options.readAsString) xlsxOptions.type = 'string'; if (binaryData.id) { + // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); workbook = xlsxReadFile(binaryPath, xlsxOptions); } else { From 06002ad2f6146adc5430fba9ad3663781193f977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:21:48 +0200 Subject: [PATCH 153/259] Cleanup --- .../core/src/BinaryData/FileSystem.manager.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 171085c297ea1..f06831af93a80 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -29,13 +29,6 @@ export class FileSystemManager implements BinaryData.Manager { return this.resolvePath(fileId); } - async getSize(workflowId: string, fileId: string) { - const filePath = this.getPath(workflowId, fileId); - const stats = await fs.stat(filePath); - - return stats.size; - } - getAsStream(workflowId: string, fileId: string, chunkSize?: number) { const filePath = this.getPath(workflowId, fileId); @@ -61,13 +54,13 @@ export class FileSystemManager implements BinaryData.Manager { async store( workflowId: string, executionId: string, - binaryData: Buffer | Readable, + bufferOrStream: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const fileId = this.createFileId(executionId); const filePath = this.getPath(workflowId, fileId); - await fs.writeFile(filePath, binaryData); + await fs.writeFile(filePath, bufferOrStream); const fileSize = await this.getSize(workflowId, fileId); @@ -150,4 +143,11 @@ export class FileSystemManager implements BinaryData.Manager { await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' }); } + + private async getSize(workflowId: string, fileId: string) { + const filePath = this.getPath(workflowId, fileId); + const stats = await fs.stat(filePath); + + return stats.size; + } } From f144cb9c8dc830831ece0a3cc9f3302645378000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:41:17 +0200 Subject: [PATCH 154/259] Simplify --- packages/cli/src/Server.ts | 7 ++- packages/cli/src/WebhookHelpers.ts | 7 +-- packages/cli/src/requests.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 28 ++++------- .../core/src/BinaryData/FileSystem.manager.ts | 46 +++++++++---------- packages/core/src/BinaryData/types.ts | 11 ++--- packages/core/src/NodeExecuteFunctions.ts | 27 ++++------- 7 files changed, 52 insertions(+), 76 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 41fa051d9a88d..ee3a67d7aaec5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1423,17 +1423,16 @@ export class Server extends AbstractServer { // Download binary this.app.get( - `/${this.restEndpoint}/data/:workflowId/:path`, + `/${this.restEndpoint}/data/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { // TODO UM: check if this needs permission check for UM const identifier = req.params.path; - const workflowId = req.params.workflowId; try { - const binaryPath = this.binaryDataService.getPath(workflowId, identifier); + const binaryPath = this.binaryDataService.getPath(identifier); let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getMetadata(workflowId, identifier); + const metadata = await this.binaryDataService.getMetadata(identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index ce0e8c66a06f7..703e4b63b800d 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = Container.get(BinaryDataService).getAsStream(workflow.id, binaryData.id); + const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -734,10 +734,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = Container.get(BinaryDataService).getAsStream( - workflow.id, - binaryData.id, - ); + const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 8e933788c8809..82f2c6a7a1860 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -490,7 +490,7 @@ export declare namespace LicenseRequest { } export type BinaryDataRequest = AuthenticatedRequest< - { workflowId: string; path: string }, + { path: string }, {}, {}, { diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 03173525f9dcc..17f8e51b58851 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -96,44 +96,32 @@ export class BinaryDataService { }); } - getAsStream(workflowId: string, binaryDataId: string, chunkSize?: number) { + getAsStream(binaryDataId: string, chunkSize?: number) { const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getAsStream(workflowId, fileId, chunkSize); + return this.getManager(mode).getAsStream(fileId, chunkSize); } - async getBinaryDataBuffer(workflowId: string, binaryData: IBinaryData) { - if (binaryData.id) return this.retrieveBinaryDataByIdentifier(workflowId, binaryData.id); - - return Buffer.from(binaryData.data, BINARY_ENCODING); - } - - async getAsBuffer(workflowId: string, binaryData: IBinaryData) { + async getAsBuffer(binaryData: IBinaryData) { if (binaryData.id) { const [mode, fileId] = binaryData.id.split(':'); - return this.getManager(mode).getAsBuffer(workflowId, fileId); + return this.getManager(mode).getAsBuffer(fileId); } return Buffer.from(binaryData.data, BINARY_ENCODING); } - async retrieveBinaryDataByIdentifier(workflowId: string, binaryDataId: string) { - const [mode, fileId] = binaryDataId.split(':'); - - return this.getManager(mode).getAsBuffer(workflowId, fileId); - } - - getPath(workflowId: string, binaryDataId: string) { + getPath(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getPath(workflowId, fileId); + return this.getManager(mode).getPath(fileId); } - async getMetadata(workflowId: string, binaryDataId: string) { + async getMetadata(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); - return this.getManager(mode).getMetadata(workflowId, fileId); + return this.getManager(mode).getMetadata(fileId); } async deleteManyByExecutionIds(executionIds: string[]) { diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 171085c297ea1..c1baa57d997ee 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -11,8 +11,8 @@ import type { Readable } from 'stream'; import type { BinaryData } from './types'; /** - * `_workflowId` references are for compatibility with the - * `BinaryData.Manager` interface, but currently unused in filesystem mode. + * `_workflowId` arguments on write are for compatibility with the + * `BinaryData.Manager` interface. Unused in filesystem mode. */ const EXECUTION_ID_EXTRACTOR = @@ -25,25 +25,18 @@ export class FileSystemManager implements BinaryData.Manager { await ensureDirExists(this.storagePath); } - getPath(_workflowId: string, fileId: string) { + getPath(fileId: string) { return this.resolvePath(fileId); } - async getSize(workflowId: string, fileId: string) { - const filePath = this.getPath(workflowId, fileId); - const stats = await fs.stat(filePath); - - return stats.size; - } - - getAsStream(workflowId: string, fileId: string, chunkSize?: number) { - const filePath = this.getPath(workflowId, fileId); + getAsStream(fileId: string, chunkSize?: number) { + const filePath = this.getPath(fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); } - async getAsBuffer(workflowId: string, fileId: string) { - const filePath = this.getPath(workflowId, fileId); + async getAsBuffer(fileId: string) { + const filePath = this.getPath(fileId); try { return await fs.readFile(filePath); @@ -52,32 +45,32 @@ export class FileSystemManager implements BinaryData.Manager { } } - async getMetadata(_workflowId: string, fileId: string): Promise { + async getMetadata(fileId: string): Promise { const filePath = this.resolvePath(`${fileId}.metadata`); return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } async store( - workflowId: string, + _workflowId: string, executionId: string, binaryData: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const fileId = this.createFileId(executionId); - const filePath = this.getPath(workflowId, fileId); + const filePath = this.getPath(fileId); await fs.writeFile(filePath, binaryData); - const fileSize = await this.getSize(workflowId, fileId); + const fileSize = await this.getSize(fileId); await this.storeMetadata(fileId, { mimeType, fileName, fileSize }); return { fileId, fileSize }; } - async deleteOne(workflowId: string, fileId: string) { - const filePath = this.getPath(workflowId, fileId); + async deleteOne(fileId: string) { + const filePath = this.getPath(fileId); return fs.rm(filePath); } @@ -103,16 +96,16 @@ export class FileSystemManager implements BinaryData.Manager { } async copyByFilePath( - workflowId: string, + _workflowId: string, executionId: string, filePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const newFileId = this.createFileId(executionId); - await fs.cp(filePath, this.getPath(workflowId, newFileId)); + await fs.cp(filePath, this.getPath(newFileId)); - const fileSize = await this.getSize(workflowId, newFileId); + const fileSize = await this.getSize(newFileId); await this.storeMetadata(newFileId, { mimeType, fileName, fileSize }); @@ -150,4 +143,11 @@ export class FileSystemManager implements BinaryData.Manager { await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' }); } + + private async getSize(fileId: string) { + const filePath = this.getPath(fileId); + const stats = await fs.stat(filePath); + + return stats.size; + } } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 0cbc6a35be21d..2ada99136d0d5 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -30,13 +30,12 @@ export namespace BinaryData { metadata: PreWriteMetadata, ): Promise; - getPath(workflowId: string, fileId: string): string; - getAsBuffer(workflowId: string, fileId: string): Promise; - getAsStream(workflowId: string, fileId: string, chunkSize?: number): Readable; - getMetadata(workflowId: string, fileId: string): Promise; + getPath(fileId: string): string; + getAsBuffer(fileId: string): Promise; + getAsStream(fileId: string, chunkSize?: number): Readable; + getMetadata(fileId: string): Promise; copyByFileId(workflowId: string, fileId: string, prefix: string): Promise; - copyByFilePath( workflowId: string, executionId: string, @@ -44,7 +43,7 @@ export namespace BinaryData { metadata: PreWriteMetadata, ): Promise; - deleteOne(workflowId: string, fileId: string): Promise; + deleteOne(fileId: string): Promise; deleteManyByExecutionIds(executionIds: string[]): Promise; } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index d3a76c4d5bdc3..cab2a36126bb9 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -940,29 +940,22 @@ async function httpRequest( return result.data; } -export function getBinaryPath(workflowId: string, binaryDataId: string): string { - return Container.get(BinaryDataService).getPath(workflowId, binaryDataId); +export function getBinaryPath(binaryDataId: string): string { + return Container.get(BinaryDataService).getPath(binaryDataId); } /** * Returns binary file metadata */ -export async function getBinaryMetadata( - workflowId: string, - binaryDataId: string, -): Promise { - return Container.get(BinaryDataService).getMetadata(workflowId, binaryDataId); +export async function getBinaryMetadata(binaryDataId: string): Promise { + return Container.get(BinaryDataService).getMetadata(binaryDataId); } /** * Returns binary file stream for piping */ -export function getBinaryStream( - workflowId: string, - binaryDataId: string, - chunkSize?: number, -): Readable { - return Container.get(BinaryDataService).getAsStream(workflowId, binaryDataId, chunkSize); +export function getBinaryStream(binaryDataId: string, chunkSize?: number): Readable { + return Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); } export function assertBinaryData( @@ -1000,7 +993,7 @@ export async function getBinaryDataBuffer( workflowId: string, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; - return Container.get(BinaryDataService).getAsBuffer(workflowId, binaryData); + return Container.get(BinaryDataService).getAsBuffer(binaryData); } /** @@ -2588,9 +2581,9 @@ const getBinaryHelperFunctions = ( { executionId }: IWorkflowExecuteAdditionalData, workflowId: string, ): BinaryHelperFunctions => ({ - getBinaryPath: (binaryDataId) => getBinaryPath(workflowId, binaryDataId), - getBinaryStream: (binaryDataId) => getBinaryStream(workflowId, binaryDataId), - getBinaryMetadata: async (binaryDataId) => getBinaryMetadata(workflowId, binaryDataId), + getBinaryPath, + getBinaryStream, + getBinaryMetadata, binaryToBuffer: async (body: Buffer | Readable) => Container.get(BinaryDataService).binaryToBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => From 94e54498b1b831b40e5f25a9a016db9220a3535c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:48:46 +0200 Subject: [PATCH 155/259] More simplifications --- packages/core/src/NodeExecuteFunctions.ts | 5 ++--- packages/editor-ui/src/stores/workflows.store.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index cab2a36126bb9..9e1dbc9daa3c1 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -990,7 +990,6 @@ export async function getBinaryDataBuffer( itemIndex: number, propertyName: string, inputIndex: number, - workflowId: string, ): Promise { const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!; return Container.get(BinaryDataService).getAsBuffer(binaryData); @@ -2888,7 +2887,7 @@ export function getExecuteFunctions( assertBinaryData: (itemIndex, propertyName) => assertBinaryData(inputData, node, itemIndex, propertyName, 0), getBinaryDataBuffer: async (itemIndex, propertyName) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, 0, workflow.id), + getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), returnJsonArray, normalizeItems, @@ -3031,7 +3030,7 @@ export function getExecuteSingleFunctions( assertBinaryData: (propertyName, inputIndex = 0) => assertBinaryData(inputData, node, itemIndex, propertyName, inputIndex), getBinaryDataBuffer: async (propertyName, inputIndex = 0) => - getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex, workflow.id), + getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex), }, }; })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 32b6f5848509c..ae35bf88c5840 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1379,8 +1379,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { const rootStore = useRootStore(); let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; - const workflowId = this.getCurrentWorkflow().id; - const url = new URL(`${restUrl}/data/${workflowId}/${dataPath}`); + const url = new URL(`${restUrl}/data/${dataPath}`); url.searchParams.append('mode', mode); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); From 47d564687f21224cd7a8e1279bb90f44699f434f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:50:06 +0200 Subject: [PATCH 156/259] Better naming --- packages/core/src/BinaryData/FileSystem.manager.ts | 4 ++-- packages/core/src/BinaryData/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index c1baa57d997ee..184f8ad467d81 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -54,13 +54,13 @@ export class FileSystemManager implements BinaryData.Manager { async store( _workflowId: string, executionId: string, - binaryData: Buffer | Readable, + bufferOrStream: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { const fileId = this.createFileId(executionId); const filePath = this.getPath(fileId); - await fs.writeFile(filePath, binaryData); + await fs.writeFile(filePath, bufferOrStream); const fileSize = await this.getSize(fileId); diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 2ada99136d0d5..d1393d23ebc02 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -26,7 +26,7 @@ export namespace BinaryData { store( workflowId: string, executionId: string, - binaryData: Buffer | Readable, + bufferOrStream: Buffer | Readable, metadata: PreWriteMetadata, ): Promise; From 1f485dadbce21f27ee3dbb97362521953e379c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 12:58:30 +0200 Subject: [PATCH 157/259] Simplify stub --- packages/core/src/BinaryData/ObjectStore.manager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index b485bb1c08477..86f2ceba37e99 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -17,19 +17,19 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - getPath(workflowId: string, fileId: string): string { + getPath(fileId: string): string { throw new Error('TODO'); } - async getAsBuffer(workflowId: string, fileId: string): Promise { + async getAsBuffer(fileId: string): Promise { throw new Error('TODO'); } - getAsStream(workflowId: string, fileId: string, chunkSize?: number): Readable { + getAsStream(fileId: string, chunkSize?: number): Readable { throw new Error('TODO'); } - async getMetadata(workflowId: string, fileId: string): Promise { + async getMetadata(fileId: string): Promise { throw new Error('TODO'); } @@ -46,7 +46,7 @@ export class ObjectStoreManager implements BinaryData.Manager { throw new Error('TODO'); } - async deleteOne(workflowId: string, fileId: string): Promise { + async deleteOne(fileId: string): Promise { throw new Error('TODO'); } From 08ae52345b0f1300b51e421a16efea36fe945832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 15:15:47 +0200 Subject: [PATCH 158/259] Make `getAsStream` async --- .../core/src/BinaryData/BinaryData.service.ts | 2 +- .../core/src/BinaryData/FileSystem.manager.ts | 2 +- .../src/BinaryData/ObjectStore.manager.ts | 43 +++++++++++++++---- packages/core/src/BinaryData/types.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 2 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 3 ++ .../nodes/Aws/S3/V2/AwsS3V2.node.ts | 5 ++- .../nodes-base/nodes/Crypto/Crypto.node.ts | 2 +- packages/nodes-base/nodes/Ftp/Ftp.node.ts | 4 +- .../Google/CloudStorage/ObjectDescription.ts | 2 +- .../Google/Drive/v1/GoogleDriveV1.node.ts | 2 +- .../nodes/Google/Drive/v2/helpers/utils.ts | 2 +- .../nodes/Google/YouTube/YouTube.node.ts | 2 +- .../nodes/HttpRequest/GenericFunctions.ts | 12 ++++-- .../HttpRequest/V3/HttpRequestV3.node.ts | 6 +-- packages/nodes-base/nodes/Jira/Jira.node.ts | 2 +- .../nodes-base/nodes/Slack/V2/SlackV2.node.ts | 2 +- .../v2/SpreadsheetFileV2.node.ts | 2 +- packages/nodes-base/nodes/Ssh/Ssh.node.ts | 2 +- .../nodes/Telegram/Telegram.node.ts | 2 +- .../WriteBinaryFile/WriteBinaryFile.node.ts | 2 +- packages/workflow/src/Interfaces.ts | 2 +- 22 files changed, 72 insertions(+), 33 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 17f8e51b58851..a011153745a64 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -96,7 +96,7 @@ export class BinaryDataService { }); } - getAsStream(binaryDataId: string, chunkSize?: number) { + async getAsStream(binaryDataId: string, chunkSize?: number) { const [mode, fileId] = binaryDataId.split(':'); return this.getManager(mode).getAsStream(fileId, chunkSize); diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 184f8ad467d81..7a06672934435 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -29,7 +29,7 @@ export class FileSystemManager implements BinaryData.Manager { return this.resolvePath(fileId); } - getAsStream(fileId: string, chunkSize?: number) { + async getAsStream(fileId: string, chunkSize?: number) { const filePath = this.getPath(fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 86f2ceba37e99..d541206b53c4a 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,20 +1,34 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { Service } from 'typedi'; +import { v4 as uuid } from 'uuid'; +import type { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; + import type { Readable } from 'stream'; import type { BinaryData } from './types'; +import concatStream from 'concat-stream'; +@Service() export class ObjectStoreManager implements BinaryData.Manager { + constructor(private objectStoreService: ObjectStoreService) {} + async init() { - throw new Error('TODO'); + await this.objectStoreService.checkConnection(); } async store( workflowId: string, executionId: string, - binaryData: Buffer | Readable, - metadata: BinaryData.PreWriteMetadata, - ): Promise { - throw new Error('TODO'); + bufferOrStream: Buffer | Readable, + _metadata: BinaryData.PreWriteMetadata, + ) { + const fileId = `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + const buffer = await this.binaryToBuffer(bufferOrStream); + + // @TODO: Set incoming metadata + await this.objectStoreService.put(fileId, buffer); + + return { fileId, fileSize: buffer.length }; } getPath(fileId: string): string { @@ -22,11 +36,12 @@ export class ObjectStoreManager implements BinaryData.Manager { } async getAsBuffer(fileId: string): Promise { - throw new Error('TODO'); + return this.objectStoreService.get(fileId, { mode: 'buffer' }); } - getAsStream(fileId: string, chunkSize?: number): Readable { - throw new Error('TODO'); + // @TODO: Use chunkSize + async getAsStream(fileId: string, chunkSize?: number): Promise { + return this.objectStoreService.get(fileId, { mode: 'stream' }); } async getMetadata(fileId: string): Promise { @@ -53,4 +68,16 @@ export class ObjectStoreManager implements BinaryData.Manager { async deleteManyByExecutionIds(executionIds: string[]): Promise { throw new Error('TODO'); } + + // ---------------------------------- + // private methods + // ---------------------------------- + + // @TODO: Duplicated from BinaryData service + private async binaryToBuffer(body: Buffer | Readable) { + return new Promise((resolve) => { + if (Buffer.isBuffer(body)) resolve(body); + else body.pipe(concatStream(resolve)); + }); + } } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index d1393d23ebc02..65d462c975bb1 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -32,7 +32,7 @@ export namespace BinaryData { getPath(fileId: string): string; getAsBuffer(fileId: string): Promise; - getAsStream(fileId: string, chunkSize?: number): Readable; + getAsStream(fileId: string, chunkSize?: number): Promise; getMetadata(fileId: string): Promise; copyByFileId(workflowId: string, fileId: string, prefix: string): Promise; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 9e1dbc9daa3c1..8415ece877c4f 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -954,7 +954,7 @@ export async function getBinaryMetadata(binaryDataId: string): Promise { return Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); } diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 5fa26c626066a..72aef51b04820 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto'; import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import type { ListPage, RawListPage } from './types'; +import type { Readable } from 'stream'; // @TODO: Decouple host from AWS @@ -57,6 +58,8 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ + async get(path: string, { mode }: { mode: 'buffer' }): Promise; + async get(path: string, { mode }: { mode: 'stream' }): Promise; async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; diff --git a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts index a068579ac751f..bff63bf3c3de8 100644 --- a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts @@ -870,7 +870,10 @@ export class AwsS3V2 implements INodeType { let uploadData: Buffer | Readable; multipartHeaders['Content-Type'] = binaryPropertyData.mimeType; if (binaryPropertyData.id) { - uploadData = this.helpers.getBinaryStream(binaryPropertyData.id, UPLOAD_CHUNK_SIZE); + uploadData = await this.helpers.getBinaryStream( + binaryPropertyData.id, + UPLOAD_CHUNK_SIZE, + ); const createMultiPartUpload = await awsApiRequestREST.call( this, servicePath, diff --git a/packages/nodes-base/nodes/Crypto/Crypto.node.ts b/packages/nodes-base/nodes/Crypto/Crypto.node.ts index 746cf083f02f3..35412582de17e 100644 --- a/packages/nodes-base/nodes/Crypto/Crypto.node.ts +++ b/packages/nodes-base/nodes/Crypto/Crypto.node.ts @@ -486,7 +486,7 @@ export class Crypto implements INodeType { const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i); const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); if (binaryData.id) { - const binaryStream = this.helpers.getBinaryStream(binaryData.id); + const binaryStream = await this.helpers.getBinaryStream(binaryData.id); hashOrHmac.setEncoding(encoding); await pipeline(binaryStream, hashOrHmac); newValue = hashOrHmac.read(); diff --git a/packages/nodes-base/nodes/Ftp/Ftp.node.ts b/packages/nodes-base/nodes/Ftp/Ftp.node.ts index e807dd833c9e2..1404208f4ed0c 100644 --- a/packages/nodes-base/nodes/Ftp/Ftp.node.ts +++ b/packages/nodes-base/nodes/Ftp/Ftp.node.ts @@ -649,7 +649,7 @@ export class Ftp implements INodeType { let uploadData: Buffer | Readable; if (binaryData.id) { - uploadData = this.helpers.getBinaryStream(binaryData.id); + uploadData = await this.helpers.getBinaryStream(binaryData.id); } else { uploadData = Buffer.from(binaryData.data, BINARY_ENCODING); } @@ -759,7 +759,7 @@ export class Ftp implements INodeType { let uploadData: Buffer | Readable; if (binaryData.id) { - uploadData = this.helpers.getBinaryStream(binaryData.id); + uploadData = await this.helpers.getBinaryStream(binaryData.id); } else { uploadData = Buffer.from(binaryData.data, BINARY_ENCODING); } diff --git a/packages/nodes-base/nodes/Google/CloudStorage/ObjectDescription.ts b/packages/nodes-base/nodes/Google/CloudStorage/ObjectDescription.ts index 1a875b3baf008..9bcb313db0af6 100644 --- a/packages/nodes-base/nodes/Google/CloudStorage/ObjectDescription.ts +++ b/packages/nodes-base/nodes/Google/CloudStorage/ObjectDescription.ts @@ -160,7 +160,7 @@ export const objectOperations: INodeProperties[] = [ const binaryData = this.helpers.assertBinaryData(binaryPropertyName); if (binaryData.id) { - content = this.helpers.getBinaryStream(binaryData.id); + content = await this.helpers.getBinaryStream(binaryData.id); const binaryMetadata = await this.helpers.getBinaryMetadata(binaryData.id); contentType = binaryMetadata.mimeType ?? 'application/octet-stream'; contentLength = binaryMetadata.fileSize; diff --git a/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts b/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts index 12ebdba9f3e23..3f10690e98379 100644 --- a/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts @@ -2442,7 +2442,7 @@ export class GoogleDriveV1 implements INodeType { const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); if (binaryData.id) { // Stream data in 256KB chunks, and upload the via the resumable upload api - fileContent = this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); + fileContent = await this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); const metadata = await this.helpers.getBinaryMetadata(binaryData.id); contentLength = metadata.fileSize; originalFilename = metadata.fileName; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts b/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts index 76df7b01f92d8..097ccefadbce7 100644 --- a/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts @@ -40,7 +40,7 @@ export async function getItemBinaryData( if (binaryData.id) { // Stream data in 256KB chunks, and upload the via the resumable upload api - fileContent = this.helpers.getBinaryStream(binaryData.id, chunkSize); + fileContent = await this.helpers.getBinaryStream(binaryData.id, chunkSize); const metadata = await this.helpers.getBinaryMetadata(binaryData.id); contentLength = metadata.fileSize; originalFilename = metadata.fileName; diff --git a/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts b/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts index 6880aba1f1e6f..f4038c4d5327d 100644 --- a/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts +++ b/packages/nodes-base/nodes/Google/YouTube/YouTube.node.ts @@ -838,7 +838,7 @@ export class YouTube implements INodeType { if (binaryData.id) { // Stream data in 256KB chunks, and upload the via the resumable upload api - fileContent = this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); + fileContent = await this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); const metadata = await this.helpers.getBinaryMetadata(binaryData.id); contentLength = metadata.fileSize; mimeType = metadata.mimeType ?? binaryData.mimeType; diff --git a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts index fffb76b646237..5e90b7c14d2b0 100644 --- a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts @@ -138,9 +138,9 @@ export const binaryContentTypes = [ export type BodyParametersReducer = ( acc: IDataObject, cur: { name: string; value: string }, -) => IDataObject; +) => Promise; -export const prepareRequestBody = ( +export const prepareRequestBody = async ( parameters: BodyParameter[], bodyType: string, version: number, @@ -153,6 +153,12 @@ export const prepareRequestBody = ( return acc; }, {} as IDataObject); } else { - return parameters.reduce(defaultReducer, {}); + return parameters.reduce( + async (acc, entry) => { + const result = await acc; // Wait for the promise to resolve + return defaultReducer(result, entry); + }, + Promise.resolve({} as IDataObject), + ); } }; diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 2ebf0d7b90f3a..e31fa0063770c 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -1161,7 +1161,7 @@ export class HttpRequestV3 implements INodeType { }); } - const parametersToKeyValue = ( + const parametersToKeyValue = async ( accumulator: { [key: string]: any }, cur: { name: string; value: string; parameterType?: string; inputDataFieldName?: string }, ) => { @@ -1171,7 +1171,7 @@ export class HttpRequestV3 implements INodeType { let uploadData: Buffer | Readable; const itemBinaryData = items[itemIndex].binary![cur.inputDataFieldName]; if (itemBinaryData.id) { - uploadData = this.helpers.getBinaryStream(itemBinaryData.id); + uploadData = await this.helpers.getBinaryStream(itemBinaryData.id); } else { uploadData = Buffer.from(itemBinaryData.data, BINARY_ENCODING); } @@ -1243,7 +1243,7 @@ export class HttpRequestV3 implements INodeType { const itemBinaryData = this.helpers.assertBinaryData(itemIndex, inputDataFieldName); if (itemBinaryData.id) { - uploadData = this.helpers.getBinaryStream(itemBinaryData.id); + uploadData = await this.helpers.getBinaryStream(itemBinaryData.id); const metadata = await this.helpers.getBinaryMetadata(itemBinaryData.id); contentLength = metadata.fileSize; } else { diff --git a/packages/nodes-base/nodes/Jira/Jira.node.ts b/packages/nodes-base/nodes/Jira/Jira.node.ts index 30fad18fea552..ba11ae9340ae3 100644 --- a/packages/nodes-base/nodes/Jira/Jira.node.ts +++ b/packages/nodes-base/nodes/Jira/Jira.node.ts @@ -1058,7 +1058,7 @@ export class Jira implements INodeType { let uploadData: Buffer | Readable; if (binaryData.id) { - uploadData = this.helpers.getBinaryStream(binaryData.id); + uploadData = await this.helpers.getBinaryStream(binaryData.id); } else { uploadData = Buffer.from(binaryData.data, BINARY_ENCODING); } diff --git a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts index 0ab45d02818c1..17ece328672cc 100644 --- a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts +++ b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts @@ -1059,7 +1059,7 @@ export class SlackV2 implements INodeType { let uploadData: Buffer | Readable; if (binaryData.id) { - uploadData = this.helpers.getBinaryStream(binaryData.id); + uploadData = await this.helpers.getBinaryStream(binaryData.id); } else { uploadData = Buffer.from(binaryData.data, BINARY_ENCODING); } diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts index de83ecb79deee..5634b320ea8cd 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts @@ -92,7 +92,7 @@ export class SpreadsheetFileV2 implements INodeType { }, }); if (binaryData.id) { - const stream = this.helpers.getBinaryStream(binaryData.id); + const stream = await this.helpers.getBinaryStream(binaryData.id); await pipeline(stream, parser); } else { parser.write(binaryData.data, BINARY_ENCODING); diff --git a/packages/nodes-base/nodes/Ssh/Ssh.node.ts b/packages/nodes-base/nodes/Ssh/Ssh.node.ts index 853eac060fe34..565713351f7fe 100644 --- a/packages/nodes-base/nodes/Ssh/Ssh.node.ts +++ b/packages/nodes-base/nodes/Ssh/Ssh.node.ts @@ -441,7 +441,7 @@ export class Ssh implements INodeType { let uploadData: Buffer | Readable; if (binaryData.id) { - uploadData = this.helpers.getBinaryStream(binaryData.id); + uploadData = await this.helpers.getBinaryStream(binaryData.id); } else { uploadData = Buffer.from(binaryData.data, BINARY_ENCODING); } diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.ts b/packages/nodes-base/nodes/Telegram/Telegram.node.ts index a62e0af2eafbb..fcc4700f1c76f 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.ts +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.ts @@ -2005,7 +2005,7 @@ export class Telegram implements INodeType { let uploadData: Buffer | Readable; if (itemBinaryData.id) { - uploadData = this.helpers.getBinaryStream(itemBinaryData.id); + uploadData = await this.helpers.getBinaryStream(itemBinaryData.id); } else { uploadData = Buffer.from(itemBinaryData.data, BINARY_ENCODING); } diff --git a/packages/nodes-base/nodes/WriteBinaryFile/WriteBinaryFile.node.ts b/packages/nodes-base/nodes/WriteBinaryFile/WriteBinaryFile.node.ts index 5ea05ac42e4aa..fb5ea7b1e37d7 100644 --- a/packages/nodes-base/nodes/WriteBinaryFile/WriteBinaryFile.node.ts +++ b/packages/nodes-base/nodes/WriteBinaryFile/WriteBinaryFile.node.ts @@ -91,7 +91,7 @@ export class WriteBinaryFile implements INodeType { let fileContent: Buffer | Readable; if (binaryData.id) { - fileContent = this.helpers.getBinaryStream(binaryData.id); + fileContent = await this.helpers.getBinaryStream(binaryData.id); } else { fileContent = Buffer.from(binaryData.data, BINARY_ENCODING); } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 5fa48b1918f04..76525c751c1be 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -687,7 +687,7 @@ export interface BinaryHelperFunctions { copyBinaryFile(): Promise; binaryToBuffer(body: Buffer | Readable): Promise; getBinaryPath(binaryDataId: string): string; - getBinaryStream(binaryDataId: string, chunkSize?: number): Readable; + getBinaryStream(binaryDataId: string, chunkSize?: number): Promise; getBinaryMetadata(binaryDataId: string): Promise<{ fileName?: string; mimeType?: string; From 2d0325b6f1396a182c1f21c387ee2f2acf02e0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 15:24:11 +0200 Subject: [PATCH 159/259] Async change also in `WebhookHelpers` --- packages/cli/src/WebhookHelpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 703e4b63b800d..59ae274a53f8d 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -506,7 +506,7 @@ export async function executeWebhook( responsePromise = await createDeferredPromise(); responsePromise .promise() - .then((response: IN8nHttpFullResponse) => { + .then(async (response: IN8nHttpFullResponse) => { if (didSendResponse) { return; } @@ -514,7 +514,7 @@ export async function executeWebhook( const binaryData = (response.body as IDataObject)?.binaryData as IBinaryData; if (binaryData?.id) { res.header(response.headers); - const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); + const stream = await Container.get(BinaryDataService).getAsStream(binaryData.id); void pipeline(stream, res).then(() => responseCallback(null, { noWebhookResponse: true }), ); @@ -734,7 +734,7 @@ export async function executeWebhook( // Send the webhook response manually res.setHeader('Content-Type', binaryData.mimeType); if (binaryData.id) { - const stream = Container.get(BinaryDataService).getAsStream(binaryData.id); + const stream = await Container.get(BinaryDataService).getAsStream(binaryData.id); await pipeline(stream, res); } else { res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); From a1f3d9940a143218a89700ffe56881c0459480e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 15:52:06 +0200 Subject: [PATCH 160/259] Address user-written files --- packages/core/src/BinaryData/BinaryData.service.ts | 9 +++++++++ packages/core/src/BinaryData/ObjectStore.manager.ts | 9 ++++----- packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts | 1 - .../nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts | 1 - .../nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts | 1 - 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index a011153745a64..f035f7c86b2ae 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -112,6 +112,15 @@ export class BinaryDataService { return Buffer.from(binaryData.data, BINARY_ENCODING); } + /** + * Get the path to the binary data file, e.g. `/Users/{user}/.n8n/binaryData/{uuid}` + * or `/workflows/{workflowId}/executions/{executionId}/binary_data/{uuid}`. + * + * Used to: + * + * - support download of execution-written binary files. + * - allow nodes access to user-written binary files. + */ getPath(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index d541206b53c4a..01308ccf2ce4e 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -20,22 +20,21 @@ export class ObjectStoreManager implements BinaryData.Manager { workflowId: string, executionId: string, bufferOrStream: Buffer | Readable, - _metadata: BinaryData.PreWriteMetadata, + _metadata: BinaryData.PreWriteMetadata, // @TODO: Use metadata ) { const fileId = `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; const buffer = await this.binaryToBuffer(bufferOrStream); - // @TODO: Set incoming metadata await this.objectStoreService.put(fileId, buffer); return { fileId, fileSize: buffer.length }; } - getPath(fileId: string): string { - throw new Error('TODO'); + getPath(fileId: string) { + return fileId; } - async getAsBuffer(fileId: string): Promise { + async getAsBuffer(fileId: string) { return this.objectStoreService.get(fileId, { mode: 'buffer' }); } diff --git a/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts b/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts index c5490cc79034e..b638862970662 100644 --- a/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts +++ b/packages/nodes-base/nodes/ReadPdf/ReadPDF.node.ts @@ -93,7 +93,6 @@ export class ReadPDF implements INodeType { } if (binaryData.id) { - // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); params.url = new URL(`file://${binaryPath}`); } else { diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts index 4ca3020d4853f..17f57f3014c33 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v1/SpreadsheetFileV1.node.ts @@ -76,7 +76,6 @@ export class SpreadsheetFileV1 implements INodeType { if (options.readAsString) xlsxOptions.type = 'string'; if (binaryData.id) { - // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); workbook = xlsxReadFile(binaryPath, xlsxOptions); } else { diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts index 5634b320ea8cd..a749e20e5abb7 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v2/SpreadsheetFileV2.node.ts @@ -103,7 +103,6 @@ export class SpreadsheetFileV2 implements INodeType { if (options.readAsString) xlsxOptions.type = 'string'; if (binaryData.id) { - // @TODO: Decouple from FS const binaryPath = this.helpers.getBinaryPath(binaryData.id); workbook = xlsxReadFile(binaryPath, xlsxOptions); } else { From a1b82ae264c3c5b6ce985365b63981d91fae4830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 15:54:24 +0200 Subject: [PATCH 161/259] Cleanup --- packages/core/src/BinaryData/BinaryData.service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index f035f7c86b2ae..6984e22cfb5b1 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -116,10 +116,8 @@ export class BinaryDataService { * Get the path to the binary data file, e.g. `/Users/{user}/.n8n/binaryData/{uuid}` * or `/workflows/{workflowId}/executions/{executionId}/binary_data/{uuid}`. * - * Used to: - * - * - support download of execution-written binary files. - * - allow nodes access to user-written binary files. + * Used to allow nodes access to user-written binary files, e.g. Read PDF node, and + * to support download of execution-written binary files. */ getPath(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); From bba6f762da0c40b9475c6d95db8f1a809ed6c5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 17:48:57 +0200 Subject: [PATCH 162/259] Fix test --- .../nodes/HttpRequest/V3/HttpRequestV3.node.ts | 2 +- .../nodes/HttpRequest/test/utils/utils.test.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index e31fa0063770c..5caae4b5bef31 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -1192,7 +1192,7 @@ export class HttpRequestV3 implements INodeType { // Get parameters defined in the UI if (sendBody && bodyParameters) { if (specifyBody === 'keypair' || bodyContentType === 'multipart-form-data') { - requestOptions.body = prepareRequestBody( + requestOptions.body = await prepareRequestBody( bodyParameters, bodyContentType, nodeVersion, diff --git a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts index db0884412038c..991d0061feaac 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts @@ -2,7 +2,7 @@ import { prepareRequestBody } from '../../GenericFunctions'; import type { BodyParameter, BodyParametersReducer } from '../../GenericFunctions'; describe('HTTP Node Utils, prepareRequestBody', () => { - it('should call default reducer', () => { + it('should call default reducer', async () => { const bodyParameters: BodyParameter[] = [ { name: 'foo.bar', @@ -11,15 +11,13 @@ describe('HTTP Node Utils, prepareRequestBody', () => { ]; const defaultReducer: BodyParametersReducer = jest.fn(); - prepareRequestBody(bodyParameters, 'json', 3, defaultReducer); + await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer); expect(defaultReducer).toBeCalledTimes(1); - expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }, 0, [ - { name: 'foo.bar', value: 'baz' }, - ]); + expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }); }); - it('should call process dot notations', () => { + it('should call process dot notations', async () => { const bodyParameters: BodyParameter[] = [ { name: 'foo.bar.spam', @@ -28,7 +26,7 @@ describe('HTTP Node Utils, prepareRequestBody', () => { ]; const defaultReducer: BodyParametersReducer = jest.fn(); - const result = prepareRequestBody(bodyParameters, 'json', 4, defaultReducer); + const result = await prepareRequestBody(bodyParameters, 'json', 4, defaultReducer); expect(defaultReducer).toBeCalledTimes(0); expect(result).toBeDefined(); From caac8e11e1992f9ff94fc72bcc7c02dc23aebc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 18:05:30 +0200 Subject: [PATCH 163/259] Cleanup --- packages/core/src/BinaryData/ObjectStore.manager.ts | 5 ++--- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 01308ccf2ce4e..4334b2882d479 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -31,15 +31,14 @@ export class ObjectStoreManager implements BinaryData.Manager { } getPath(fileId: string) { - return fileId; + return fileId; // already full path } async getAsBuffer(fileId: string) { return this.objectStoreService.get(fileId, { mode: 'buffer' }); } - // @TODO: Use chunkSize - async getAsStream(fileId: string, chunkSize?: number): Promise { + async getAsStream(fileId: string) { return this.objectStoreService.get(fileId, { mode: 'stream' }); } diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 72aef51b04820..a1ae743ace8f4 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -166,7 +166,7 @@ export class ObjectStoreService { return page as ListPage; } - private toPath(rawPath: string, qs?: Record) { + private toRequestPath(rawPath: string, qs?: Record) { const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; if (!qs) return path; @@ -178,7 +178,7 @@ export class ObjectStoreService { return path.concat(`?${qsParams}`); } - private async request( + private async request( method: Method, host: string, rawPath = '', @@ -194,7 +194,7 @@ export class ObjectStoreService { responseType?: ResponseType; } = {}, ) { - const path = this.toPath(rawPath, qs); + const path = this.toRequestPath(rawPath, qs); const optionsToSign: Aws4Options = { method, @@ -221,7 +221,7 @@ export class ObjectStoreService { // console.log(config); try { - return await axios.request(config); + return await axios.request(config); } catch (error) { // console.log(error); throw new Error('Request to external object storage failed', { From 75eb2f0797003638d318765c9e0fb7be848e6da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:04:49 +0200 Subject: [PATCH 164/259] Fill out copy and deletion methods --- .../repositories/execution.repository.ts | 10 ++- .../core/src/BinaryData/BinaryData.service.ts | 20 ++--- .../core/src/BinaryData/FileSystem.manager.ts | 27 ++++--- .../src/BinaryData/ObjectStore.manager.ts | 73 +++++++++++++------ packages/core/src/BinaryData/types.ts | 6 +- packages/core/src/BinaryData/utils.ts | 11 ++- packages/core/src/NodeExecuteFunctions.ts | 6 +- 7 files changed, 98 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 051d206b54dd8..00d6012e2579f 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -505,9 +505,9 @@ export class ExecutionRepository extends Repository { const date = new Date(); date.setHours(date.getHours() - 1); - const executionIds = ( + const workflowIdsAndExecutionIds = ( await this.find({ - select: ['id'], + select: ['workflowId', 'id'], where: { deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), }, @@ -519,9 +519,11 @@ export class ExecutionRepository extends Repository { */ withDeleted: true, }) - ).map(({ id }) => id); + ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); + + await this.binaryDataService.deleteManyByExecutionIds(workflowIdsAndExecutionIds); - await this.binaryDataService.deleteManyByExecutionIds(executionIds); + const executionIds = workflowIdsAndExecutionIds.map((o) => o.executionId); this.logger.debug(`Hard-deleting ${executionIds.length} executions from database`, { executionIds, diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 6984e22cfb5b1..f75e55528a181 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -1,11 +1,10 @@ import { readFile, stat } from 'fs/promises'; -import concatStream from 'concat-stream'; import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; -import { areValidModes } from './utils'; +import { areValidModes, toBuffer } from './utils'; import { BinaryDataManagerNotFound, InvalidBinaryDataMode } from './errors'; import type { Readable } from 'stream'; @@ -70,7 +69,7 @@ export class BinaryDataService { const manager = this.managers[this.mode]; if (!manager) { - const buffer = await this.binaryToBuffer(bufferOrStream); + const buffer = await this.toBuffer(bufferOrStream); binaryData.data = buffer.toString(BINARY_ENCODING); binaryData.fileSize = prettyBytes(buffer.length); @@ -89,11 +88,8 @@ export class BinaryDataService { return binaryData; } - async binaryToBuffer(body: Buffer | Readable) { - return new Promise((resolve) => { - if (Buffer.isBuffer(body)) resolve(body); - else body.pipe(concatStream(resolve)); - }); + async toBuffer(bufferOrStream: Buffer | Readable) { + return toBuffer(bufferOrStream); } async getAsStream(binaryDataId: string, chunkSize?: number) { @@ -116,8 +112,8 @@ export class BinaryDataService { * Get the path to the binary data file, e.g. `/Users/{user}/.n8n/binaryData/{uuid}` * or `/workflows/{workflowId}/executions/{executionId}/binary_data/{uuid}`. * - * Used to allow nodes access to user-written binary files, e.g. Read PDF node, and - * to support download of execution-written binary files. + * Used to allow nodes to access user-written binary files (e.g. Read PDF node) + * and to support download of execution-written binary files. */ getPath(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); @@ -131,8 +127,8 @@ export class BinaryDataService { return this.getManager(mode).getMetadata(fileId); } - async deleteManyByExecutionIds(executionIds: string[]) { - await this.getManager(this.mode).deleteManyByExecutionIds(executionIds); + async deleteManyByExecutionIds(ids: BinaryData.IdsForDeletion) { + await this.getManager(this.mode).deleteMany(ids); } async duplicateBinaryData( diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 7a06672934435..ede0bc63f1c47 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -11,8 +11,9 @@ import type { Readable } from 'stream'; import type { BinaryData } from './types'; /** - * `_workflowId` arguments on write are for compatibility with the - * `BinaryData.Manager` interface. Unused in filesystem mode. + * @note The `workflowId` arguments on write are for compatibility with the + * `BinaryData.Manager` interface. Unused in filesystem mode until we refactor + * how we store binary data files in the `/binaryData` dir. */ const EXECUTION_ID_EXTRACTOR = @@ -57,7 +58,7 @@ export class FileSystemManager implements BinaryData.Manager { bufferOrStream: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const fileId = this.createFileId(executionId); + const fileId = this.toFileId(executionId); const filePath = this.getPath(fileId); await fs.writeFile(filePath, bufferOrStream); @@ -75,7 +76,9 @@ export class FileSystemManager implements BinaryData.Manager { return fs.rm(filePath); } - async deleteManyByExecutionIds(executionIds: string[]) { + async deleteMany(ids: BinaryData.IdsForDeletion) { + const executionIds = ids.map((o) => o.executionId); // ignore workflow IDs + const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); const deletedIds = []; @@ -91,8 +94,6 @@ export class FileSystemManager implements BinaryData.Manager { deletedIds.push(executionId); } } - - return deletedIds; } async copyByFilePath( @@ -101,7 +102,7 @@ export class FileSystemManager implements BinaryData.Manager { filePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const newFileId = this.createFileId(executionId); + const newFileId = this.toFileId(executionId); await fs.cp(filePath, this.getPath(newFileId)); @@ -112,19 +113,21 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId: newFileId, fileSize }; } - async copyByFileId(_workflowId: string, fileId: string, executionId: string) { - const newFileId = this.createFileId(executionId); + async copyByFileId(_workflowId: string, executionId: string, sourceFileId: string) { + const targetFileId = this.toFileId(executionId); + const sourcePath = this.resolvePath(sourceFileId); + const targetPath = this.resolvePath(targetFileId); - await fs.copyFile(this.resolvePath(fileId), this.resolvePath(newFileId)); + await fs.copyFile(sourcePath, targetPath); - return newFileId; + return targetFileId; } // ---------------------------------- // private methods // ---------------------------------- - private createFileId(executionId: string) { + private toFileId(executionId: string) { return [executionId, uuid()].join(''); } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 4334b2882d479..f132c21dbe25a 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; -import type { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; +import { FileSystemManager } from './FileSystem.manager'; +import { toBuffer } from './utils'; -import type { Readable } from 'stream'; +import type { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; +import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; -import concatStream from 'concat-stream'; @Service() export class ObjectStoreManager implements BinaryData.Manager { @@ -22,8 +23,8 @@ export class ObjectStoreManager implements BinaryData.Manager { bufferOrStream: Buffer | Readable, _metadata: BinaryData.PreWriteMetadata, // @TODO: Use metadata ) { - const fileId = `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; - const buffer = await this.binaryToBuffer(bufferOrStream); + const fileId = this.toFileId(workflowId, executionId); + const buffer = await this.toBuffer(bufferOrStream); await this.objectStoreService.put(fileId, buffer); @@ -42,40 +43,70 @@ export class ObjectStoreManager implements BinaryData.Manager { return this.objectStoreService.get(fileId, { mode: 'stream' }); } + // @TODO async getMetadata(fileId: string): Promise { throw new Error('TODO'); } - async copyByFileId(workflowId: string, fileId: string, executionId: string): Promise { - throw new Error('TODO'); + async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { + const targetFileId = this.toFileId(workflowId, executionId); + + const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' }); + + await this.objectStoreService.put(targetFileId, sourceFile); + + return targetFileId; } + /** + * Create a copy of a file in the filesystem. Used by Webhook, FTP, SSH nodes. + * + * This delegates to FS manager because the object store manager does not support + * storage and access of user-written data, only execution-written data. + */ async copyByFilePath( workflowId: string, executionId: string, - path: string, + filePath: string, metadata: BinaryData.PreWriteMetadata, - ): Promise { - throw new Error('TODO'); + ) { + return Container.get(FileSystemManager).copyByFilePath( + workflowId, + executionId, + filePath, + metadata, + ); } - async deleteOne(fileId: string): Promise { - throw new Error('TODO'); + async deleteOne(fileId: string) { + await this.objectStoreService.deleteOne(fileId); } - async deleteManyByExecutionIds(executionIds: string[]): Promise { - throw new Error('TODO'); + async deleteMany(ids: BinaryData.IdsForDeletion) { + const prefixes = ids.map( + (o) => `/workflows/${o.workflowId}/executions/${o.executionId}/binary_data/`, + ); + + await this.deleteManyByPrefixes(prefixes); + } + + private async deleteManyByPrefixes(prefixes: string[]) { + await Promise.all( + prefixes.map(async (prefix) => { + await this.objectStoreService.deleteMany(prefix); + }), + ); } // ---------------------------------- // private methods // ---------------------------------- - // @TODO: Duplicated from BinaryData service - private async binaryToBuffer(body: Buffer | Readable) { - return new Promise((resolve) => { - if (Buffer.isBuffer(body)) resolve(body); - else body.pipe(concatStream(resolve)); - }); + private toFileId(workflowId: string, executionId: string) { + return `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + } + + private async toBuffer(bufferOrStream: Buffer | Readable) { + return toBuffer(bufferOrStream); } } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 65d462c975bb1..a71f7d5b043a6 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -20,6 +20,8 @@ export namespace BinaryData { export type PreWriteMetadata = Omit; + export type IdsForDeletion = Array<{ workflowId: string; executionId: string }>; + export interface Manager { init(): Promise; @@ -35,7 +37,7 @@ export namespace BinaryData { getAsStream(fileId: string, chunkSize?: number): Promise; getMetadata(fileId: string): Promise; - copyByFileId(workflowId: string, fileId: string, prefix: string): Promise; + copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise; copyByFilePath( workflowId: string, executionId: string, @@ -44,6 +46,6 @@ export namespace BinaryData { ): Promise; deleteOne(fileId: string): Promise; - deleteManyByExecutionIds(executionIds: string[]): Promise; + deleteMany(ids: IdsForDeletion): Promise; } } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index fabfedd81c442..7edb37c6de9a5 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -1,5 +1,7 @@ -import fs from 'fs/promises'; +import fs from 'node:fs/promises'; +import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; +import concatStream from 'concat-stream'; /** * Modes for storing binary data: @@ -20,3 +22,10 @@ export async function ensureDirExists(dir: string) { await fs.mkdir(dir, { recursive: true }); } } + +export async function toBuffer(body: Buffer | Readable) { + return new Promise((resolve) => { + if (Buffer.isBuffer(body)) resolve(body); + else body.pipe(concatStream(resolve)); + }); +} diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 8415ece877c4f..896717513069d 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -775,7 +775,7 @@ export async function proxyRequestToAxios( if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { responseData = await Container.get(BinaryDataService) - .binaryToBuffer(responseData) + .toBuffer(responseData) .then((buffer) => buffer.toString('utf-8')); } @@ -2584,7 +2584,7 @@ const getBinaryHelperFunctions = ( getBinaryStream, getBinaryMetadata, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataService).binaryToBuffer(body), + Container.get(BinaryDataService).toBuffer(body), prepareBinaryData: async (binaryData, filePath, mimeType) => prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => @@ -2846,7 +2846,7 @@ export function getExecuteFunctions( return dataProxy.getDataProxy(); }, binaryToBuffer: async (body: Buffer | Readable) => - Container.get(BinaryDataService).binaryToBuffer(body), + Container.get(BinaryDataService).toBuffer(body), async putExecutionToWait(waitTill: Date): Promise { runExecutionData.waitTill = waitTill; if (additionalData.setExecutionStatus) { From d79fbe3197148a59cb21fc9c6b60dd7aedf4b8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:08:02 +0200 Subject: [PATCH 165/259] Cleanup --- packages/core/src/BinaryData/ObjectStore.manager.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index f132c21dbe25a..53a8380d65e24 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -87,10 +87,6 @@ export class ObjectStoreManager implements BinaryData.Manager { (o) => `/workflows/${o.workflowId}/executions/${o.executionId}/binary_data/`, ); - await this.deleteManyByPrefixes(prefixes); - } - - private async deleteManyByPrefixes(prefixes: string[]) { await Promise.all( prefixes.map(async (prefix) => { await this.objectStoreService.deleteMany(prefix); From 40df3d1316f6259fdb1d8f04ca611e300c88681b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:18:16 +0200 Subject: [PATCH 166/259] Integrate manager into service --- .../core/src/BinaryData/BinaryData.service.ts | 16 ++++++++++------ .../core/src/BinaryData/ObjectStore.manager.ts | 8 ++++++-- packages/core/src/BinaryData/errors.ts | 2 +- packages/core/src/BinaryData/utils.ts | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index f75e55528a181..632ffd18d3b53 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -4,8 +4,9 @@ import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; +import { ObjectStoreManager } from './ObjectStore.manager'; import { areValidModes, toBuffer } from './utils'; -import { BinaryDataManagerNotFound, InvalidBinaryDataMode } from './errors'; +import { UnknownBinaryDataManager, InvalidBinaryDataMode } from './errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -13,8 +14,6 @@ import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; @Service() export class BinaryDataService { - private availableModes: BinaryData.Mode[] = []; - private mode: BinaryData.Mode = 'default'; private managers: Record = {}; @@ -22,14 +21,19 @@ export class BinaryDataService { async init(config: BinaryData.Config) { if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataMode(); - this.availableModes = config.availableModes; this.mode = config.mode; - if (this.availableModes.includes('filesystem')) { + if (config.availableModes.includes('filesystem')) { this.managers.filesystem = new FileSystemManager(config.localStoragePath); await this.managers.filesystem.init(); } + + if (config.availableModes.includes('objectStore')) { + this.managers.objectStore = new ObjectStoreManager(); + + await this.managers.objectStore.init(); + } } async copyBinaryFile( @@ -218,6 +222,6 @@ export class BinaryDataService { if (manager) return manager; - throw new BinaryDataManagerNotFound(mode); + throw new UnknownBinaryDataManager(mode); } } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 53a8380d65e24..e95a9d3c7cd17 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -5,13 +5,17 @@ import { v4 as uuid } from 'uuid'; import { FileSystemManager } from './FileSystem.manager'; import { toBuffer } from './utils'; -import type { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; +import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; @Service() export class ObjectStoreManager implements BinaryData.Manager { - constructor(private objectStoreService: ObjectStoreService) {} + private readonly objectStoreService: ObjectStoreService; + + constructor() { + this.objectStoreService = Container.get(ObjectStoreService); + } async init() { await this.objectStoreService.checkConnection(); diff --git a/packages/core/src/BinaryData/errors.ts b/packages/core/src/BinaryData/errors.ts index 29cfab8ab963c..dc52875b2a768 100644 --- a/packages/core/src/BinaryData/errors.ts +++ b/packages/core/src/BinaryData/errors.ts @@ -6,7 +6,7 @@ export class InvalidBinaryDataMode extends Error { } } -export class BinaryDataManagerNotFound extends Error { +export class UnknownBinaryDataManager extends Error { constructor(mode: string) { super(`No binary data manager found for: ${mode}`); } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 7edb37c6de9a5..494d4e5219eff 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -9,7 +9,7 @@ import concatStream from 'concat-stream'; * - `filesystem` (on disk) * - `object` (S3) */ -export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; +export const BINARY_DATA_MODES = ['default', 'filesystem', 'objectStore'] as const; export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); From ac90df9edc52b02d6a5cb4c6b76169c3f930e1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:24:44 +0200 Subject: [PATCH 167/259] Add TODO --- packages/core/src/BinaryData/ObjectStore.manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index e95a9d3c7cd17..3ca95c4f47b79 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -14,7 +14,7 @@ export class ObjectStoreManager implements BinaryData.Manager { private readonly objectStoreService: ObjectStoreService; constructor() { - this.objectStoreService = Container.get(ObjectStoreService); + this.objectStoreService = Container.get(ObjectStoreService); // @TODO: Inject } async init() { From 06c80c6ad5156568b3e06e33a423c07cbe9d90b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:26:52 +0200 Subject: [PATCH 168/259] Cleanup --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index a1ae743ace8f4..0deb8b8569d64 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -95,7 +95,7 @@ export class ObjectStoreService { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const innerXml = objects.map((o) => `${o.key}`).join('\n'); + const innerXml = objects.map(({ key }) => `${key}`).join('\n'); const body = ['', innerXml, ''].join('\n'); From 414a357d16ac42ce7ef7437cad4de81d0ca23f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 21 Sep 2023 19:30:16 +0200 Subject: [PATCH 169/259] Better typing --- .../src/ObjectStore/ObjectStore.service.ee.ts | 16 +++------------- packages/core/src/ObjectStore/types.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 0deb8b8569d64..58451cb52c961 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -5,9 +5,9 @@ import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream, parseXml } from './utils'; import { createHash } from 'node:crypto'; -import type { AxiosRequestConfig, Method, ResponseType } from 'axios'; +import type { AxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; -import type { ListPage, RawListPage } from './types'; +import type { ListPage, ObjectStore, RawListPage } from './types'; import type { Readable } from 'stream'; // @TODO: Decouple host from AWS @@ -182,17 +182,7 @@ export class ObjectStoreService { method: Method, host: string, rawPath = '', - { - qs, - headers, - body, - responseType, - }: { - qs?: Record; - headers?: Record; - body?: string | Buffer; - responseType?: ResponseType; - } = {}, + { qs, headers, body, responseType }: ObjectStore.RequestOptions = {}, ) { const path = this.toRequestPath(rawPath, qs); diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index 2e9d250c6d301..b5ac6c96d1aa7 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -1,3 +1,5 @@ +import type { ResponseType } from 'axios'; + export type RawListPage = { listBucketResult: { name: string; @@ -19,3 +21,12 @@ type Item = { }; export type ListPage = Omit & { contents: Item[] }; + +export namespace ObjectStore { + export type RequestOptions = { + qs?: Record; + headers?: Record; + body?: string | Buffer; + responseType?: ResponseType; + }; +} From 05034e23f664a9aec80dfa1e3d9c2beea6632f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 09:16:28 +0200 Subject: [PATCH 170/259] Dyamic imports --- packages/core/src/BinaryData/BinaryData.service.ts | 6 ++++-- packages/core/src/BinaryData/ObjectStore.manager.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 632ffd18d3b53..2919b9736c467 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -1,10 +1,10 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import { readFile, stat } from 'fs/promises'; import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING } from 'n8n-workflow'; -import { FileSystemManager } from './FileSystem.manager'; -import { ObjectStoreManager } from './ObjectStore.manager'; import { areValidModes, toBuffer } from './utils'; import { UnknownBinaryDataManager, InvalidBinaryDataMode } from './errors'; @@ -24,12 +24,14 @@ export class BinaryDataService { this.mode = config.mode; if (config.availableModes.includes('filesystem')) { + const { FileSystemManager } = await import('./FileSystem.manager'); this.managers.filesystem = new FileSystemManager(config.localStoragePath); await this.managers.filesystem.init(); } if (config.availableModes.includes('objectStore')) { + const { ObjectStoreManager } = await import('./ObjectStore.manager'); this.managers.objectStore = new ObjectStoreManager(); await this.managers.objectStore.init(); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 3ca95c4f47b79..c593fcb16592b 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import { FileSystemManager } from './FileSystem.manager'; import { toBuffer } from './utils'; -import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; +import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; From dd6d0854046540f32716e6ae00e48ae96603070b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 09:25:52 +0200 Subject: [PATCH 171/259] Revert test changes --- packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts | 2 ++ .../nodes-base/nodes/HttpRequest/test/utils/utils.test.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts index 5e90b7c14d2b0..154f7c3fd3e82 100644 --- a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts @@ -153,6 +153,8 @@ export const prepareRequestBody = async ( return acc; }, {} as IDataObject); } else { + // @TODO: Fix + // return parameters.reduce(defaultReducer, {}); return parameters.reduce( async (acc, entry) => { const result = await acc; // Wait for the promise to resolve diff --git a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts index 991d0061feaac..4f78abf12a134 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts @@ -14,7 +14,9 @@ describe('HTTP Node Utils, prepareRequestBody', () => { await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer); expect(defaultReducer).toBeCalledTimes(1); - expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }); + expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }, 0, [ + { name: 'foo.bar', value: 'baz' }, + ]); }); it('should call process dot notations', async () => { From fe52383c0cc844c433fa00edf75b7ff6a138155b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 10:49:36 +0200 Subject: [PATCH 172/259] Add metadata support --- packages/cli/src/commands/BaseCommand.ts | 6 ++++ .../src/BinaryData/ObjectStore.manager.ts | 18 +++++++++--- .../src/ObjectStore/ObjectStore.service.ee.ts | 29 +++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index fa22643fb5785..3bf227c46249c 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -124,6 +124,12 @@ export abstract class BaseCommand extends Command { await objectStoreService.checkConnection(); + /** + * Test metadata retrieval + */ + // const md = await objectStoreService.getMetadata('happy-dog.jpg'); + // console.log('md', md); + /** * Test upload */ diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index c593fcb16592b..1d87a837d30ca 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -25,12 +25,12 @@ export class ObjectStoreManager implements BinaryData.Manager { workflowId: string, executionId: string, bufferOrStream: Buffer | Readable, - _metadata: BinaryData.PreWriteMetadata, // @TODO: Use metadata + metadata: BinaryData.PreWriteMetadata, ) { const fileId = this.toFileId(workflowId, executionId); const buffer = await this.toBuffer(bufferOrStream); - await this.objectStoreService.put(fileId, buffer); + await this.objectStoreService.put(fileId, buffer, metadata); return { fileId, fileSize: buffer.length }; } @@ -47,9 +47,19 @@ export class ObjectStoreManager implements BinaryData.Manager { return this.objectStoreService.get(fileId, { mode: 'stream' }); } - // @TODO async getMetadata(fileId: string): Promise { - throw new Error('TODO'); + const { + 'content-length': contentLength, + 'content-type': contentType, + 'x-amz-meta-filename': fileName, + } = await this.objectStoreService.getMetadata(fileId); + + const metadata: BinaryData.Metadata = { fileSize: Number(contentLength) }; + + if (contentType) metadata.mimeType = contentType; + if (fileName) metadata.fileName = fileName; + + return metadata; } async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 58451cb52c961..0844c618c3326 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -9,6 +9,7 @@ import type { AxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import type { ListPage, ObjectStore, RawListPage } from './types'; import type { Readable } from 'stream'; +import type { BinaryData } from '..'; // @TODO: Decouple host from AWS @@ -42,14 +43,17 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html */ - async put(filename: string, buffer: Buffer) { + async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) { const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const headers = { + const headers: Record = { 'Content-Length': buffer.length, 'Content-MD5': createHash('md5').update(buffer).digest('base64'), }; + if (metadata.fileName) headers['x-amz-meta-filename'] = metadata.fileName; + if (metadata.mimeType) headers['Content-Type'] = metadata.mimeType; + return this.request('PUT', host, `/${filename}`, { headers, body: buffer }); } @@ -74,6 +78,27 @@ export class ObjectStoreService { throw new TypeError(`Expected ${mode} but received ${typeof data}.`); } + /** + * Retrieve metadata for an object in the configured bucket. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html + */ + async getMetadata(path: string) { + const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + + type Response = { + headers: { + 'content-length': string; + 'content-type'?: string; + 'x-amz-meta-filename'?: string; + } & Record; + }; + + const response: Response = await this.request('HEAD', host, path); + + return response.headers; + } + /** * Delete an object in the configured bucket. * From 99c03712d2e33c926efc6c09936550878b6c1f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 11:16:12 +0200 Subject: [PATCH 173/259] Adjust `/data/:path` --- packages/cli/src/Server.ts | 25 +++++++++++++------ packages/cli/src/requests.ts | 2 +- .../editor-ui/src/stores/workflows.store.ts | 4 +-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ee3a67d7aaec5..a1dfe081727ea 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1425,24 +1425,35 @@ export class Server extends AbstractServer { this.app.get( `/${this.restEndpoint}/data/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { - // TODO UM: check if this needs permission check for UM - const identifier = req.params.path; + const { path: binaryDataId } = req.params; + const [mode] = binaryDataId.split(':') as ['filesystem' | 'object', string]; + let { action, fileName, mimeType } = req.query; + try { - const binaryPath = this.binaryDataService.getPath(identifier); - let { mode, fileName, mimeType } = req.query; + const binaryPath = this.binaryDataService.getPath(binaryDataId); + if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getMetadata(identifier); + const metadata = await this.binaryDataService.getMetadata(binaryDataId); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); } catch {} } + if (mimeType) res.setHeader('Content-Type', mimeType); - if (mode === 'download') { + + if (action === 'download') { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - res.sendFile(binaryPath); + + if (mode === 'object') { + const readStream = await this.binaryDataService.getAsStream(binaryPath); + readStream.pipe(res); + return; + } else { + res.sendFile(binaryPath); + } } catch (error) { if (error instanceof FileNotFoundError) res.writeHead(404).end(); else throw error; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 82f2c6a7a1860..ffc692991ac99 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -494,7 +494,7 @@ export type BinaryDataRequest = AuthenticatedRequest< {}, {}, { - mode: 'view' | 'download'; + action: 'view' | 'download'; fileName?: string; mimeType?: string; } diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index ae35bf88c5840..939b11007e5b0 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1372,7 +1372,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { // Binary data getBinaryUrl( dataPath: string, - mode: 'view' | 'download', + action: 'view' | 'download', fileName: string, mimeType: string, ): string { @@ -1380,7 +1380,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; const url = new URL(`${restUrl}/data/${dataPath}`); - url.searchParams.append('mode', mode); + url.searchParams.append('action', action); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); return url.toString(); From 0cdfb7901f49bcc4cec7ae428885b6fafaef6de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 11:31:19 +0200 Subject: [PATCH 174/259] Rename constant --- packages/core/src/BinaryData/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 05dc84dc6308c..7398683e32e9b 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -4,9 +4,9 @@ import type { BinaryData } from './types'; * Modes for storing binary data: * - `default` (in memory) * - `filesystem` (on disk) - * - `object` (S3) + * - `s3` (S3-compatible storage) */ -export const BINARY_DATA_MODES = ['default', 'filesystem', 'object'] as const; +export const BINARY_DATA_MODES = ['default', 'filesystem', 's3'] as const; export function areValidModes(modes: string[]): modes is BinaryData.Mode[] { return modes.every((m) => BINARY_DATA_MODES.includes(m as BinaryData.Mode)); From 61c2f5a25345cee129102f54da7316dc301b9fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 12:05:58 +0200 Subject: [PATCH 175/259] Restore `LogCatch` `LogCatch` was merged into master at a different file: `/packages/core/binaryData/index.ts`, which is now at `BinaryData.service.ts` --- packages/core/src/BinaryData/BinaryData.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 5540e2c2c4061..f16850c600821 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -2,14 +2,15 @@ import { readFile, stat } from 'fs/promises'; import concatStream from 'concat-stream'; import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; -import { BINARY_ENCODING } from 'n8n-workflow'; +import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; import { InvalidBinaryDataManagerError, InvalidBinaryDataModeError, areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; -import type { IBinaryData, INodeExecutionData } from 'n8n-workflow'; +import type { INodeExecutionData } from 'n8n-workflow'; +import { LogCatch } from '../decorators/LogCatch.decorator'; @Service() export class BinaryDataService { @@ -32,6 +33,7 @@ export class BinaryDataService { } } + @LogCatch((error) => Logger.error('Failed to copy binary data file', { error })) async copyBinaryFile(binaryData: IBinaryData, path: string, executionId: string) { const manager = this.managers[this.mode]; @@ -59,6 +61,7 @@ export class BinaryDataService { return binaryData; } + @LogCatch((error) => Logger.error('Failed to write binary data file', { error })) async store(binaryData: IBinaryData, input: Buffer | Readable, executionId: string) { const manager = this.managers[this.mode]; @@ -127,6 +130,9 @@ export class BinaryDataService { await this.getManager(this.mode).deleteManyByExecutionIds(executionIds); } + @LogCatch((error) => + Logger.error('Failed to copy all binary data files for execution', { error }), + ) async duplicateBinaryData(inputData: Array, executionId: string) { if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( From 74adb24ba7d1f4c0cebb4fcf20c1713b6b23697b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 12:35:44 +0200 Subject: [PATCH 176/259] Add file not found error to `getSize` --- packages/core/src/BinaryData/FileSystem.manager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 1e00774382fd0..84a86716ddbad 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -26,9 +26,13 @@ export class FileSystemManager implements BinaryData.Manager { async getSize(identifier: string) { const filePath = this.getPath(identifier); - const stats = await fs.stat(filePath); - return stats.size; + try { + const stats = await fs.stat(filePath); + return stats.size; + } catch (error) { + throw new Error('Failed to find binary data file in filesystem', { cause: error }); + } } getStream(identifier: string, chunkSize?: number) { From 0bc2457d76f373c9c008a10b0b36e0fd2ef9b8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 13:14:27 +0200 Subject: [PATCH 177/259] Add `mode` to `InvalidBinaryDataManagerError` --- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- packages/core/src/BinaryData/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index f16850c600821..42859ea5964eb 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -218,6 +218,6 @@ export class BinaryDataService { if (manager) return manager; - throw new InvalidBinaryDataManagerError(); + throw new InvalidBinaryDataManagerError(mode); } } diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index 7398683e32e9b..c2bea73850f85 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -19,7 +19,7 @@ export class InvalidBinaryDataModeError extends Error { } export class InvalidBinaryDataManagerError extends Error { - constructor() { - super('No binary data manager found'); + constructor(mode: string) { + super('No binary data manager found for mode: ' + mode); } } From 3fa72d08a38b480e86d68b6ccb00687c29605d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 13:57:47 +0200 Subject: [PATCH 178/259] Update per merge --- packages/cli/src/config/schema.ts | 2 +- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 9e3258169b5b6..cb945409b09e9 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -901,7 +901,7 @@ export const schema = { doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: ['default', 'filesystem'] as const, + format: ['default', 'filesystem', 's3'] as const, default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 665107c2210ab..8627dc6076867 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -31,7 +31,7 @@ export class BinaryDataService { await this.managers.filesystem.init(); } - if (config.availableModes.includes('objectStore')) { + if (config.availableModes.includes('s3')) { const { ObjectStoreManager } = await import('./ObjectStore.manager'); this.managers.objectStore = new ObjectStoreManager(); From 8a28853c364a123c3a2944097d17dd58ccafee62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 14:05:25 +0200 Subject: [PATCH 179/259] Add breaking change --- packages/cli/BREAKING-CHANGES.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 1dc69fb13ac2b..97d299928fbf0 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,26 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 1.9.0 + +### What changed? + +In nodes, `this.helpers.getBinaryStream()` is now async. + +### When is action necessary? + +If your node uses `this.helpers.getBinaryStream()`, add `await` when calling it. + +Example: + +```typescript +// Before 1.9.0: +const binaryStream = this.helpers.getBinaryStream(id); + +// From 1.9.0: +const binaryStream = await this.helpers.getBinaryStream(id); +``` + ## 1.5.0 ### What changed? From c90da6773d62b86c757149a6a80fd04454d1283e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 16:31:36 +0200 Subject: [PATCH 180/259] Refactor `prepareRequestBody` --- .../nodes/HttpRequest/GenericFunctions.ts | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts index 154f7c3fd3e82..519b24d74a0ed 100644 --- a/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HttpRequest/GenericFunctions.ts @@ -147,20 +147,15 @@ export const prepareRequestBody = async ( defaultReducer: BodyParametersReducer, ) => { if (bodyType === 'json' && version >= 4) { - return parameters.reduce((acc, entry) => { - const value = entry.value; - set(acc, entry.name, value); - return acc; - }, {} as IDataObject); + return parameters.reduce>(async (acc, p) => { + const result = await acc; + set(result, p.name, p.value); + return result; + }, Promise.resolve({})); } else { - // @TODO: Fix - // return parameters.reduce(defaultReducer, {}); - return parameters.reduce( - async (acc, entry) => { - const result = await acc; // Wait for the promise to resolve - return defaultReducer(result, entry); - }, - Promise.resolve({} as IDataObject), - ); + return parameters.reduce>(async (acc, p) => { + const result = await acc; + return defaultReducer(result, p); + }, Promise.resolve({})); } }; From fa0f6d0518c5bc39d02bcce22505b91f1469dbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 16:50:17 +0200 Subject: [PATCH 181/259] Remove jest-injected args From what I can tell, `0` is the index of the call. Jest tracks how many times the function `defaultReducer` has been called, and it provides the call index as an argument. And `[{ name: 'foo.bar', value: 'baz' }]` is the list of arguments that `defaultReducer` was called with. Jest records all the arguments passed to the spy function during each call and provides them in an array. It would seem these Jest-injected args are no longer compatible with the async reducer. Since these values are already being checked in the first two args, in the interest of time I decided to remove the Jest-injected arguments. --- .../nodes-base/nodes/HttpRequest/test/utils/utils.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts index 4f78abf12a134..991d0061feaac 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/utils/utils.test.ts @@ -14,9 +14,7 @@ describe('HTTP Node Utils, prepareRequestBody', () => { await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer); expect(defaultReducer).toBeCalledTimes(1); - expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }, 0, [ - { name: 'foo.bar', value: 'baz' }, - ]); + expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' }); }); it('should call process dot notations', async () => { From 092d2128832589098c65618aa175b05c27917013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 17:16:08 +0200 Subject: [PATCH 182/259] Await for QS to build --- .../nodes/HttpRequest/V3/HttpRequestV3.node.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 5caae4b5bef31..c5e92aea5564d 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -1264,7 +1264,15 @@ export class HttpRequestV3 implements INodeType { // Get parameters defined in the UI if (sendQuery && queryParameters) { if (specifyQuery === 'keypair') { - requestOptions.qs = queryParameters.reduce(parametersToKeyValue, {}); + async function toQs() { + return queryParameters.reduce>(async (acc, p) => { + const result = await acc; + + return parametersToKeyValue(result, p); + }, Promise.resolve({})); + } + + requestOptions.qs = await toQs(); } else if (specifyQuery === 'json') { // query is specified using JSON try { From 40c054db33a3498d899e86a811c15047a9621e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 22 Sep 2023 17:21:14 +0200 Subject: [PATCH 183/259] Fix headers in HTTPRN --- .../nodes/HttpRequest/V3/HttpRequestV3.node.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index c5e92aea5564d..48c07f2b02114 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -1295,7 +1295,15 @@ export class HttpRequestV3 implements INodeType { if (sendHeaders && headerParameters) { let additionalHeaders: IDataObject = {}; if (specifyHeaders === 'keypair') { - additionalHeaders = headerParameters.reduce(parametersToKeyValue, {}); + async function toHeaders() { + return headerParameters.reduce>(async (acc, p) => { + const result = await acc; + + return parametersToKeyValue(result, p); + }, Promise.resolve({})); + } + + additionalHeaders = await toHeaders(); } else if (specifyHeaders === 'json') { // body is specified using JSON try { From 00e4a8782407284e65074ecafea98413194db756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 11:49:47 +0200 Subject: [PATCH 184/259] Remove comment --- packages/workflow/src/Workflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 905d26e331cbd..6ce26ce302e79 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -92,7 +92,7 @@ export class Workflow { settings?: IWorkflowSettings; pinData?: IPinData; }) { - this.id = parameters.id as string; // @TODO: Why is this arg optional? + this.id = parameters.id as string; this.name = parameters.name; this.nodeTypes = parameters.nodeTypes; this.pinData = parameters.pinData; From 410c9323c1e062e1340a4d3749e9db0cbaa2bb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 11:53:24 +0200 Subject: [PATCH 185/259] Set order `workflowId -> executionId` in `copyBinaryFile` --- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 89794c29b9910..ae6f4e54ec11f 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -37,9 +37,9 @@ export class BinaryDataService { @LogCatch((error) => Logger.error('Failed to copy binary data file', { error })) async copyBinaryFile( workflowId: string, + executionId: string, binaryData: IBinaryData, path: string, - executionId: string, ) { const manager = this.managers[this.mode]; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 51dbe5774feb2..ce8752cecbd1a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1065,9 +1065,9 @@ export async function copyBinaryFile( return Container.get(BinaryDataService).copyBinaryFile( workflowId, + executionId, returnData, filePath, - executionId, ); } From 86580c8d0f6775837eee87358ba35f7360bca6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 11:58:05 +0200 Subject: [PATCH 186/259] Set order `workflowId -> executionId` in `store` --- .../core/src/BinaryData/BinaryData.service.ts | 15 +++++++++++---- packages/core/src/NodeExecuteFunctions.ts | 15 ++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index ae6f4e54ec11f..86ce67f15e41d 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -65,10 +65,10 @@ export class BinaryDataService { @LogCatch((error) => Logger.error('Failed to write binary data file', { error })) async store( - binaryData: IBinaryData, - bufferOrStream: Buffer | Readable, workflowId: string, executionId: string, + bufferOrStream: Buffer | Readable, + binaryData: IBinaryData, ) { const manager = this.managers[this.mode]; @@ -80,10 +80,17 @@ export class BinaryDataService { return binaryData; } - const { fileId, fileSize } = await manager.store(workflowId, executionId, bufferOrStream, { + const metadata = { fileName: binaryData.fileName, mimeType: binaryData.mimeType, - }); + }; + + const { fileId, fileSize } = await manager.store( + workflowId, + executionId, + bufferOrStream, + metadata, + ); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index ce8752cecbd1a..3c96e36cf03be 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -999,17 +999,22 @@ export async function getBinaryDataBuffer( * Store an incoming IBinaryData & related buffer using the configured binary data manager. * * @export - * @param {IBinaryData} data - * @param {Buffer | Readable} binaryData + * @param {IBinaryData} binaryData + * @param {Buffer | Readable} bufferOrStream * @returns {Promise} */ export async function setBinaryDataBuffer( - data: IBinaryData, - binaryData: Buffer | Readable, + binaryData: IBinaryData, + bufferOrStream: Buffer | Readable, workflowId: string, executionId: string, ): Promise { - return Container.get(BinaryDataService).store(data, binaryData, workflowId, executionId); + return Container.get(BinaryDataService).store( + workflowId, + executionId, + bufferOrStream, + binaryData, + ); } export async function copyBinaryFile( From 55062f3560514f47bbf91b4e77248fb5a62df84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 12:00:57 +0200 Subject: [PATCH 187/259] Set order `workflowId -> executionId` in duplication methods --- packages/core/src/BinaryData/BinaryData.service.ts | 6 +++--- packages/core/src/NodeExecuteFunctions.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 86ce67f15e41d..b4162a27a96b2 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -147,8 +147,8 @@ export class BinaryDataService { ) async duplicateBinaryData( workflowId: string, - inputData: Array, executionId: string, + inputData: Array, ) { if (inputData && this.managers[this.mode]) { const returnInputData = (inputData as INodeExecutionData[][]).map( @@ -157,7 +157,7 @@ export class BinaryDataService { return Promise.all( executionDataArray.map(async (executionData) => { if (executionData.binary) { - return this.duplicateBinaryDataInExecData(workflowId, executionData, executionId); + return this.duplicateBinaryDataInExecData(workflowId, executionId, executionData); } return executionData; @@ -188,8 +188,8 @@ export class BinaryDataService { private async duplicateBinaryDataInExecData( workflowId: string, - executionData: INodeExecutionData, executionId: string, + executionData: INodeExecutionData, ) { const manager = this.managers[this.mode]; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 3c96e36cf03be..4e237c240e71b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2779,8 +2779,8 @@ export function getExecuteFunctions( .then(async (result) => Container.get(BinaryDataService).duplicateBinaryData( workflow.id, - result, additionalData.executionId!, + result, ), ); }, From 91e60e1edacab9aa4e4dbfb24ce2e370e2a0c994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 12:08:47 +0200 Subject: [PATCH 188/259] Cleanup --- .../core/src/BinaryData/BinaryData.service.ts | 21 ++++++++++++------- .../core/src/BinaryData/FileSystem.manager.ts | 11 +++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index b4162a27a96b2..cf494f6fc5f70 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -2,16 +2,16 @@ import { readFile, stat } from 'fs/promises'; import concatStream from 'concat-stream'; import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; -import type { INodeExecutionData } from 'n8n-workflow'; import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; import { FileSystemManager } from './FileSystem.manager'; -import { areValidModes } from './utils'; import { UnknownBinaryDataManager, InvalidBinaryDataMode } from './errors'; import { LogCatch } from '../decorators/LogCatch.decorator'; +import { areValidModes } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; +import type { INodeExecutionData } from 'n8n-workflow'; @Service() export class BinaryDataService { @@ -39,22 +39,29 @@ export class BinaryDataService { workflowId: string, executionId: string, binaryData: IBinaryData, - path: string, + filePath: string, ) { const manager = this.managers[this.mode]; if (!manager) { - const { size } = await stat(path); + const { size } = await stat(filePath); binaryData.fileSize = prettyBytes(size); - binaryData.data = await readFile(path, { encoding: BINARY_ENCODING }); + binaryData.data = await readFile(filePath, { encoding: BINARY_ENCODING }); return binaryData; } - const { fileId, fileSize } = await manager.copyByFilePath(workflowId, executionId, path, { + const metadata = { fileName: binaryData.fileName, mimeType: binaryData.mimeType, - }); + }; + + const { fileId, fileSize } = await manager.copyByFilePath( + workflowId, + executionId, + filePath, + metadata, + ); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index c8a8067095899..9104ae63cff7f 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -1,3 +1,9 @@ +/** + * @tech_debt The `workflowId` arguments on write are for compatibility with the + * `BinaryData.Manager` interface. Unused in filesystem mode until we refactor + * how we store binary data files in the `/binaryData` dir. + */ + import { createReadStream } from 'fs'; import fs from 'fs/promises'; import path from 'path'; @@ -10,11 +16,6 @@ import { ensureDirExists } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; -/** - * `_workflowId` arguments on write are for compatibility with the - * `BinaryData.Manager` interface. Unused in filesystem mode. - */ - const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; From f66dfb97493680335828c3508194b94cdbb03bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 12:15:24 +0200 Subject: [PATCH 189/259] Remove needless casting --- packages/cli/src/ActiveWebhooks.ts | 2 +- packages/cli/test/unit/ActiveWorkflowRunner.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 28705e2936626..b594a1b795070 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -157,7 +157,7 @@ export class ActiveWebhooks { * */ async removeWorkflow(workflow: Workflow): Promise { - const workflowId = workflow.id.toString(); + const workflowId = workflow.id; if (this.workflowWebhooks[workflowId] === undefined) { // If it did not exist then there is nothing to remove diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index 0622dac9a2044..0ba430b820500 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -100,7 +100,7 @@ jest.mock('@/Db', () => { find: jest.fn(async () => generateWorkflows(databaseActiveWorkflowsCount)), findOne: jest.fn(async (searchParams) => { return databaseActiveWorkflowsList.find( - (workflow) => workflow.id.toString() === searchParams.where.id.toString(), + (workflow) => workflow.id === searchParams.where.id.toString(), ); }), update: jest.fn(), From aac2bff69329711c1be8d7ea2fb81dbd17d8b199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 12:24:09 +0200 Subject: [PATCH 190/259] Set order `workflowId -> executionId` in `copyByFileId` --- packages/core/src/BinaryData/BinaryData.service.ts | 2 +- packages/core/src/BinaryData/FileSystem.manager.ts | 2 +- packages/core/src/BinaryData/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index cf494f6fc5f70..379e87be23523 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -214,7 +214,7 @@ export class BinaryDataService { const [_mode, fileId] = binaryDataId.split(':'); - return manager?.copyByFileId(workflowId, fileId, executionId).then((newFileId) => ({ + return manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 9104ae63cff7f..1d588d628f324 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -113,7 +113,7 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId: newFileId, fileSize }; } - async copyByFileId(_workflowId: string, fileId: string, executionId: string) { + async copyByFileId(_workflowId: string, executionId: string, fileId: string) { const newFileId = this.createFileId(executionId); await fs.copyFile(this.resolvePath(fileId), this.resolvePath(newFileId)); diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index d1393d23ebc02..d33cac3362e8e 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -35,7 +35,7 @@ export namespace BinaryData { getAsStream(fileId: string, chunkSize?: number): Readable; getMetadata(fileId: string): Promise; - copyByFileId(workflowId: string, fileId: string, prefix: string): Promise; + copyByFileId(workflowId: string, executionId: string, fileId: string): Promise; copyByFilePath( workflowId: string, executionId: string, From 0d5b4255f69ffe7c138b6afc637be3d59272e0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 12:56:34 +0200 Subject: [PATCH 191/259] Add unit tests --- packages/cli/src/Server.ts | 4 +- .../repositories/execution.repository.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 2 +- .../core/src/BinaryData/FileSystem.manager.ts | 11 +- .../src/BinaryData/ObjectStore.manager.ts | 4 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 5 +- .../core/test/NodeExecuteFunctions.test.ts | 2 + packages/core/test/ObjectStore.test.ts | 301 ++++++++++++++++++ 8 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 packages/core/test/ObjectStore.test.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 7c1792514704d..1157752d05bb2 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1429,7 +1429,7 @@ export class Server extends AbstractServer { `/${this.restEndpoint}/data/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { const { path: binaryDataId } = req.params; - const [mode] = binaryDataId.split(':') as ['filesystem' | 'object', string]; + const [mode] = binaryDataId.split(':') as ['filesystem' | 's3', string]; let { action, fileName, mimeType } = req.query; try { @@ -1450,7 +1450,7 @@ export class Server extends AbstractServer { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - if (mode === 'object') { + if (mode === 's3') { const readStream = await this.binaryDataService.getAsStream(binaryPath); readStream.pipe(res); return; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 00d6012e2579f..9265090de8e6a 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -521,7 +521,7 @@ export class ExecutionRepository extends Repository { }) ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); - await this.binaryDataService.deleteManyByExecutionIds(workflowIdsAndExecutionIds); + await this.binaryDataService.deleteMany(workflowIdsAndExecutionIds); const executionIds = workflowIdsAndExecutionIds.map((o) => o.executionId); diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 8627dc6076867..9e6d1ee20ed3e 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -136,7 +136,7 @@ export class BinaryDataService { return this.getManager(mode).getMetadata(fileId); } - async deleteManyByExecutionIds(ids: BinaryData.IdsForDeletion) { + async deleteMany(ids: BinaryData.IdsForDeletion) { await this.getManager(this.mode).deleteMany(ids); } diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 08edd2c390e3a..d41631b918b13 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -11,9 +11,9 @@ import type { Readable } from 'stream'; import type { BinaryData } from './types'; /** - * @note The `workflowId` arguments on write are for compatibility with the - * `BinaryData.Manager` interface. Unused in filesystem mode until we refactor - * how we store binary data files in the `/binaryData` dir. + * @note The `workflowId` arguments on write and delete are intentionally unused. + * They are for compatibility with `BinaryData.Manager` and will be removed + * when we refactor binary data file storage in the `/binaryData` dir. */ const EXECUTION_ID_EXTRACTOR = @@ -77,11 +77,10 @@ export class FileSystemManager implements BinaryData.Manager { } async deleteMany(ids: BinaryData.IdsForDeletion) { - const executionIds = ids.map((o) => o.executionId); // ignore workflow IDs + const executionIds = ids.map((o) => o.executionId); const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); - const deletedIds = []; for (const fileName of fileNames) { const executionId = fileName.match(EXECUTION_ID_EXTRACTOR)?.[1]; @@ -90,8 +89,6 @@ export class FileSystemManager implements BinaryData.Manager { const filePath = this.resolvePath(fileName); await Promise.all([fs.rm(filePath), fs.rm(`${filePath}.metadata`)]); - - deletedIds.push(executionId); } } } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 1d87a837d30ca..53ac5fdefb79c 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,11 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { FileSystemManager } from './FileSystem.manager'; import { toBuffer } from './utils'; - import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; + import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 0844c618c3326..9fcca670fc7e6 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { createHash } from 'node:crypto'; import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream, parseXml } from './utils'; -import { createHash } from 'node:crypto'; + import type { AxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import type { ListPage, ObjectStore, RawListPage } from './types'; @@ -159,7 +160,7 @@ export class ObjectStoreService { /** * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. */ - private async getListPage(prefix: string, nextPageToken?: string) { + async getListPage(prefix: string, nextPageToken?: string) { const host = `s3.${this.bucket.region}.amazonaws.com`; const qs: Record = { 'list-type': 2, prefix }; diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index f6eefe67dfad6..bba705ab8f324 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -44,6 +44,7 @@ describe('NodeExecuteFunctions', () => { data: 'This should be overwritten by the actual payload in the response', }, inputData, + 'workflowId', 'executionId', ); @@ -95,6 +96,7 @@ describe('NodeExecuteFunctions', () => { data: 'This should be overwritten with the name of the configured data manager', }, inputData, + 'workflowId', 'executionId', ); diff --git a/packages/core/test/ObjectStore.test.ts b/packages/core/test/ObjectStore.test.ts new file mode 100644 index 0000000000000..e6da16680397e --- /dev/null +++ b/packages/core/test/ObjectStore.test.ts @@ -0,0 +1,301 @@ +import axios from 'axios'; +import { ObjectStoreService } from '../src/ObjectStore/ObjectStore.service.ee'; +import { Readable } from 'stream'; + +jest.mock('axios'); + +const mockAxios = axios as jest.Mocked; + +const MOCK_BUCKET = { region: 'us-east-1', name: 'test-bucket' }; +const MOCK_CREDENTIALS = { accountId: 'mock-account-id', secretKey: 'mock-secret-key' }; +const FAILED_REQUEST_ERROR_MESSAGE = 'Request to external object storage failed'; +const EXPECTED_HOST = `${MOCK_BUCKET.name}.s3.${MOCK_BUCKET.region}.amazonaws.com`; +const MOCK_S3_ERROR = new Error('Something went wrong!'); + +const toDeleteManyXml = (filename: string) => ` +example-file.txt +`; + +describe('ObjectStoreService', () => { + let objectStoreService: ObjectStoreService; + + beforeEach(() => { + objectStoreService = new ObjectStoreService(MOCK_BUCKET, MOCK_CREDENTIALS); + jest.restoreAllMocks(); + }); + + describe('checkConnection()', () => { + it('should send a HEAD request to the correct host', async () => { + mockAxios.request.mockResolvedValue({ status: 200 }); + + await objectStoreService.checkConnection(); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'HEAD', + url: `https://${EXPECTED_HOST}/`, + headers: expect.objectContaining({ + 'X-Amz-Content-Sha256': expect.any(String), + 'X-Amz-Date': expect.any(String), + Authorization: expect.any(String), + }), + }), + ); + }); + + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.checkConnection(); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); + + describe('getMetadata()', () => { + it('should send a HEAD request to the correct host and path', async () => { + const path = 'file.txt'; + + mockAxios.request.mockResolvedValue({ status: 200 }); + + await objectStoreService.getMetadata(path); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'HEAD', + url: `https://${EXPECTED_HOST}/${path}`, + headers: expect.objectContaining({ + Host: EXPECTED_HOST, + 'X-Amz-Content-Sha256': expect.any(String), + 'X-Amz-Date': expect.any(String), + Authorization: expect.any(String), + }), + }), + ); + }); + + it('should throw an error on request failure', async () => { + const path = 'file.txt'; + + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.getMetadata(path); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); + + describe('put()', () => { + it('should send a PUT request to upload an object', async () => { + const path = 'test-file.txt'; + const buffer = Buffer.from('Test content'); + const metadata = { fileName: path, mimeType: 'text/plain' }; + + mockAxios.request.mockResolvedValue({ status: 200 }); + + await objectStoreService.put(path, buffer, metadata); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + url: `https://${EXPECTED_HOST}/${path}`, + headers: expect.objectContaining({ + 'Content-Length': buffer.length, + 'Content-MD5': expect.any(String), + 'x-amz-meta-filename': metadata.fileName, + 'Content-Type': metadata.mimeType, + }), + data: buffer, + }), + ); + }); + + it('should throw an error on request failure', async () => { + const path = 'test-file.txt'; + const buffer = Buffer.from('Test content'); + const metadata = { fileName: path, mimeType: 'text/plain' }; + + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.put(path, buffer, metadata); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); + + describe('get()', () => { + it('should send a GET request to download an object as a buffer', async () => { + const path = 'test-file.txt'; + + mockAxios.request.mockResolvedValue({ status: 200, data: Buffer.from('Test content') }); + + const result = await objectStoreService.get(path, { mode: 'buffer' }); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://${EXPECTED_HOST}/${path}`, + responseType: 'arraybuffer', + }), + ); + + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it('should send a GET request to download an object as a stream', async () => { + const path = 'test-file.txt'; + + mockAxios.request.mockResolvedValue({ status: 200, data: new Readable() }); + + const result = await objectStoreService.get(path, { mode: 'stream' }); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://${EXPECTED_HOST}/${path}`, + responseType: 'stream', + }), + ); + + expect(result instanceof Readable).toBe(true); + }); + + it('should throw an error on request failure', async () => { + const path = 'test-file.txt'; + + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.get(path, { mode: 'buffer' }); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); + + describe('deleteOne()', () => { + it('should send a DELETE request to delete an object', async () => { + const path = 'test-file.txt'; + + mockAxios.request.mockResolvedValue({ status: 204 }); + + await objectStoreService.deleteOne(path); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + url: `https://${EXPECTED_HOST}/${path}`, + }), + ); + }); + + it('should throw an error on request failure', async () => { + const path = 'test-file.txt'; + + mockAxios.request.mockRejectedValue(new Error('Test error')); + + await expect(objectStoreService.deleteOne(path)).rejects.toThrowError( + 'Request to external object storage failed', + ); + }); + }); + + describe('deleteMany()', () => { + it('should send a POST request to delete multiple objects', async () => { + const prefix = 'test-dir/'; + const fileName = 'example-file.txt'; + + const mockList = [ + { + key: fileName, + lastModified: '2023-09-24T12:34:56Z', + eTag: 'abc123def456', + size: 456789, + storageClass: 'STANDARD', + }, + ]; + + objectStoreService.list = jest.fn().mockResolvedValue(mockList); + + mockAxios.request.mockResolvedValue({ status: 204 }); + + await objectStoreService.deleteMany(prefix); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: `https://${EXPECTED_HOST}/?delete`, + headers: expect.objectContaining({ + 'Content-Type': 'application/xml', + 'Content-Length': expect.any(Number), + 'Content-MD5': expect.any(String), + }), + data: toDeleteManyXml(fileName), + }), + ); + }); + + it('should throw an error on request failure', async () => { + const prefix = 'test-dir/'; + + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.deleteMany(prefix); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); + + describe('list()', () => { + it('should list objects with a common prefix', async () => { + const prefix = 'test-dir/'; + + const mockListPage = { + contents: [{ key: `${prefix}file1.txt` }, { key: `${prefix}file2.txt` }], + isTruncated: false, + }; + + objectStoreService.getListPage = jest.fn().mockResolvedValue(mockListPage); + + mockAxios.request.mockResolvedValue({ status: 200 }); + + const result = await objectStoreService.list(prefix); + + expect(result).toEqual(mockListPage.contents); + }); + + it('should consolidate pages', async () => { + const prefix = 'test-dir/'; + + const mockFirstListPage = { + contents: [{ key: `${prefix}file1.txt` }], + isTruncated: true, + nextContinuationToken: 'token1', + }; + + const mockSecondListPage = { + contents: [{ key: `${prefix}file2.txt` }], + isTruncated: false, + }; + + objectStoreService.getListPage = jest + .fn() + .mockResolvedValueOnce(mockFirstListPage) + .mockResolvedValueOnce(mockSecondListPage); + + mockAxios.request.mockResolvedValue({ status: 200 }); + + const result = await objectStoreService.list(prefix); + + expect(result).toEqual([...mockFirstListPage.contents, ...mockSecondListPage.contents]); + }); + + it('should throw an error on request failure', async () => { + const prefix = 'test-dir/'; + + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + + const promise = objectStoreService.list(prefix); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); + }); +}); From 209b024635faaeff91ee2059c334b5cf0dc19905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 16:14:16 +0200 Subject: [PATCH 192/259] Add license and schema flags --- packages/cli/src/Server.ts | 4 ++++ packages/cli/src/config/schema.ts | 7 +++++++ packages/cli/src/constants.ts | 1 + packages/cli/src/controllers/e2e.controller.ts | 1 + packages/workflow/src/Interfaces.ts | 1 + 5 files changed, 14 insertions(+) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 1157752d05bb2..d8c74012d682f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -327,6 +327,7 @@ export class Server extends AbstractServer { showNonProdBanner: false, debugInEditor: false, workflowHistory: false, + externalStorage: false, }, mfa: { enabled: false, @@ -470,6 +471,9 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), + externalStorage: + config.getEnv('externalStorage.enabled') && + Container.get(License).isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE), }); if (isLdapEnabled()) { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index cb945409b09e9..a9000c1cd295e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -915,6 +915,13 @@ export const schema = { }, externalStorage: { + enabled: { + format: Boolean, + default: false, + env: 'N8N_EXTERNAL_OBJECT_STORAGE_ENABLED', + doc: 'Whether the external object storage feature is enabled', + }, + s3: { // @TODO: service name bucket: { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index c94af1f5c64b1..c9d0d30452164 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -81,6 +81,7 @@ export const LICENSE_FEATURES = { SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', + EXTERNAL_OBJECT_STORAGE: 'feat:externalStorage', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index f4e7a744994cb..f66ec80063fee 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -68,6 +68,7 @@ export class E2EController { [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, + [LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE]: false, }; constructor( diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 3c4bf7ad0e307..eacc204af1e98 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2194,6 +2194,7 @@ export interface IN8nUISettings { externalSecrets: boolean; showNonProdBanner: boolean; debugInEditor: boolean; + externalStorage: boolean; workflowHistory: boolean; }; hideUsagePage: boolean; From 824ceb454d0f5c23e17c50f2708ab6261c330ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 17:13:22 +0200 Subject: [PATCH 193/259] Fix build --- packages/core/src/BinaryData/ObjectStore.manager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 53ac5fdefb79c..9cd2859887cec 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -106,6 +106,10 @@ export class ObjectStoreManager implements BinaryData.Manager { ); } + async rename() { + throw new Error('TODO'); + } + // ---------------------------------- // private methods // ---------------------------------- From c79ba579ccd0208f944d32c56db28c0bd0c6c50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 17:27:29 +0200 Subject: [PATCH 194/259] Remove integration parts --- packages/cli/src/Server.ts | 26 +--- packages/cli/src/commands/BaseCommand.ts | 61 --------- packages/cli/src/config/schema.ts | 43 +----- packages/cli/src/constants.ts | 1 - .../cli/src/controllers/e2e.controller.ts | 1 - packages/cli/src/requests.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 9 +- .../src/BinaryData/ObjectStore.manager.ts | 124 ------------------ packages/core/src/BinaryData/utils.ts | 12 -- .../src/ObjectStore/ObjectStore.service.ee.ts | 2 - .../editor-ui/src/stores/workflows.store.ts | 4 +- packages/workflow/src/Interfaces.ts | 1 - 12 files changed, 12 insertions(+), 274 deletions(-) delete mode 100644 packages/core/src/BinaryData/ObjectStore.manager.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d8c74012d682f..ff65cb16b3b03 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -327,7 +327,6 @@ export class Server extends AbstractServer { showNonProdBanner: false, debugInEditor: false, workflowHistory: false, - externalStorage: false, }, mfa: { enabled: false, @@ -471,9 +470,6 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), - externalStorage: - config.getEnv('externalStorage.enabled') && - Container.get(License).isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE), }); if (isLdapEnabled()) { @@ -1432,16 +1428,14 @@ export class Server extends AbstractServer { this.app.get( `/${this.restEndpoint}/data/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { - const { path: binaryDataId } = req.params; - const [mode] = binaryDataId.split(':') as ['filesystem' | 's3', string]; - let { action, fileName, mimeType } = req.query; - + // TODO UM: check if this needs permission check for UM + const identifier = req.params.path; try { - const binaryPath = this.binaryDataService.getPath(binaryDataId); - + const binaryPath = this.binaryDataService.getPath(identifier); + let { mode, fileName, mimeType } = req.query; if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getMetadata(binaryDataId); + const metadata = await this.binaryDataService.getMetadata(identifier); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); @@ -1450,17 +1444,11 @@ export class Server extends AbstractServer { if (mimeType) res.setHeader('Content-Type', mimeType); - if (action === 'download') { + if (mode === 'download') { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - if (mode === 's3') { - const readStream = await this.binaryDataService.getAsStream(binaryPath); - readStream.pipe(res); - return; - } else { - res.sendFile(binaryPath); - } + res.sendFile(binaryPath); } catch (error) { if (error instanceof FileNotFoundError) res.writeHead(404).end(); else throw error; diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 8b660cbef72e4..a75456de227b7 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -109,67 +109,6 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { - /** - * @TODO: Remove - only for testing in dev - */ - - const objectStoreService = new ObjectStoreService( - { - name: config.getEnv('externalStorage.s3.bucket.name'), - region: config.getEnv('externalStorage.s3.bucket.region'), - }, - { - accountId: config.getEnv('externalStorage.s3.credentials.accountId'), - secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), - }, - ); - - await objectStoreService.checkConnection(); - - /** - * Test metadata retrieval - */ - // const md = await objectStoreService.getMetadata('happy-dog.jpg'); - // console.log('md', md); - - /** - * Test upload - */ - - // const filePath = '/Users/ivov/Downloads/happy-dog.jpg'; - // const buffer = Buffer.from(await readFile(filePath)); - // const res = await objectStoreService.put('object-store-service-dog.jpg', buffer); - // console.log('upload result', res.status); - - /** - * Test deletion - */ - - // const res = await objectStoreService.deleteMany('uploaded'); - // console.log('res', res); - - // await objectStoreService.deleteOne('object-store-service-dog.jpg'); - - /** - * Test listing - */ - - // const res = await objectStoreService.list('happy'); - // console.log('res', res); - - /** - * Test download - */ - - // const stream = await objectStoreService.get('happy-dog.jpg', { mode: 'stream' }); - // try { - // // eslint-disable-next-line @typescript-eslint/no-explicit-any - // await pipeline(stream as any, fs.createWriteStream('happy-dog.jpg')); - // console.log('✅ Pipeline succeeded'); - // } catch (error) { - // console.log('❌ Pipeline failed', error); - // } - const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a9000c1cd295e..65c17fd3c7634 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -901,7 +901,7 @@ export const schema = { doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: ['default', 'filesystem', 's3'] as const, + format: ['default', 'filesystem'] as const, default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', @@ -914,47 +914,6 @@ export const schema = { }, }, - externalStorage: { - enabled: { - format: Boolean, - default: false, - env: 'N8N_EXTERNAL_OBJECT_STORAGE_ENABLED', - doc: 'Whether the external object storage feature is enabled', - }, - - s3: { - // @TODO: service name - bucket: { - name: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', - doc: 'Name of the n8n bucket in S3-compatible external storage', - }, - region: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', - doc: 'Region of the n8n bucket in S3-compatible external storage', - }, - }, - credentials: { - accountId: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', - doc: 'Account ID in S3-compatible external storage', - }, - secretKey: { - format: String, - default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', - doc: 'Secret key in S3-compatible external storage', - }, - }, - }, - }, - deployment: { type: { format: String, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index c9d0d30452164..c94af1f5c64b1 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -81,7 +81,6 @@ export const LICENSE_FEATURES = { SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', - EXTERNAL_OBJECT_STORAGE: 'feat:externalStorage', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index f66ec80063fee..f4e7a744994cb 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -68,7 +68,6 @@ export class E2EController { [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, - [LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE]: false, }; constructor( diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 41cae469d4f35..4ce69bc81c94f 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -495,7 +495,7 @@ export type BinaryDataRequest = AuthenticatedRequest< {}, {}, { - action: 'view' | 'download'; + mode: 'view' | 'download'; fileName?: string; mimeType?: string; } diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 8a0b80a423e6b..cf12561ea50fe 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -5,9 +5,9 @@ import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; -import { areValidModes, toBuffer } from './utils'; import { UnknownBinaryDataManager, InvalidBinaryDataMode } from './errors'; import { LogCatch } from '../decorators/LogCatch.decorator'; +import { areValidModes, toBuffer } from './utils'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -30,13 +30,6 @@ export class BinaryDataService { await this.managers.filesystem.init(); } - - if (config.availableModes.includes('s3')) { - const { ObjectStoreManager } = await import('./ObjectStore.manager'); - this.managers.objectStore = new ObjectStoreManager(); - - await this.managers.objectStore.init(); - } } @LogCatch((error) => Logger.error('Failed to copy binary data file', { error })) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts deleted file mode 100644 index 9cd2859887cec..0000000000000 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ /dev/null @@ -1,124 +0,0 @@ -import Container, { Service } from 'typedi'; -import { v4 as uuid } from 'uuid'; -import { FileSystemManager } from './FileSystem.manager'; -import { toBuffer } from './utils'; -import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; - -import type { Readable } from 'node:stream'; -import type { BinaryData } from './types'; - -@Service() -export class ObjectStoreManager implements BinaryData.Manager { - private readonly objectStoreService: ObjectStoreService; - - constructor() { - this.objectStoreService = Container.get(ObjectStoreService); // @TODO: Inject - } - - async init() { - await this.objectStoreService.checkConnection(); - } - - async store( - workflowId: string, - executionId: string, - bufferOrStream: Buffer | Readable, - metadata: BinaryData.PreWriteMetadata, - ) { - const fileId = this.toFileId(workflowId, executionId); - const buffer = await this.toBuffer(bufferOrStream); - - await this.objectStoreService.put(fileId, buffer, metadata); - - return { fileId, fileSize: buffer.length }; - } - - getPath(fileId: string) { - return fileId; // already full path - } - - async getAsBuffer(fileId: string) { - return this.objectStoreService.get(fileId, { mode: 'buffer' }); - } - - async getAsStream(fileId: string) { - return this.objectStoreService.get(fileId, { mode: 'stream' }); - } - - async getMetadata(fileId: string): Promise { - const { - 'content-length': contentLength, - 'content-type': contentType, - 'x-amz-meta-filename': fileName, - } = await this.objectStoreService.getMetadata(fileId); - - const metadata: BinaryData.Metadata = { fileSize: Number(contentLength) }; - - if (contentType) metadata.mimeType = contentType; - if (fileName) metadata.fileName = fileName; - - return metadata; - } - - async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(workflowId, executionId); - - const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' }); - - await this.objectStoreService.put(targetFileId, sourceFile); - - return targetFileId; - } - - /** - * Create a copy of a file in the filesystem. Used by Webhook, FTP, SSH nodes. - * - * This delegates to FS manager because the object store manager does not support - * storage and access of user-written data, only execution-written data. - */ - async copyByFilePath( - workflowId: string, - executionId: string, - filePath: string, - metadata: BinaryData.PreWriteMetadata, - ) { - return Container.get(FileSystemManager).copyByFilePath( - workflowId, - executionId, - filePath, - metadata, - ); - } - - async deleteOne(fileId: string) { - await this.objectStoreService.deleteOne(fileId); - } - - async deleteMany(ids: BinaryData.IdsForDeletion) { - const prefixes = ids.map( - (o) => `/workflows/${o.workflowId}/executions/${o.executionId}/binary_data/`, - ); - - await Promise.all( - prefixes.map(async (prefix) => { - await this.objectStoreService.deleteMany(prefix); - }), - ); - } - - async rename() { - throw new Error('TODO'); - } - - // ---------------------------------- - // private methods - // ---------------------------------- - - private toFileId(workflowId: string, executionId: string) { - return `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; - } - - private async toBuffer(bufferOrStream: Buffer | Readable) { - return toBuffer(bufferOrStream); - } -} diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/BinaryData/utils.ts index eda8adfc1b612..715456a2e5339 100644 --- a/packages/core/src/BinaryData/utils.ts +++ b/packages/core/src/BinaryData/utils.ts @@ -29,15 +29,3 @@ export async function toBuffer(body: Buffer | Readable) { else body.pipe(concatStream(resolve)); }); } - -export class InvalidBinaryDataModeError extends Error { - constructor() { - super(`Invalid binary data mode selected. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); - } -} - -export class UnknownBinaryDataManager extends Error { - constructor(mode: string) { - super('No binary data manager found for unknown mode: ' + mode); - } -} diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 9fcca670fc7e6..4f25265288e30 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -12,8 +12,6 @@ import type { ListPage, ObjectStore, RawListPage } from './types'; import type { Readable } from 'stream'; import type { BinaryData } from '..'; -// @TODO: Decouple host from AWS - @Service() export class ObjectStoreService { private credentials: Aws4Credentials; diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 80721b95c3217..7892cd10c5bea 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1375,7 +1375,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { // Binary data getBinaryUrl( dataPath: string, - action: 'view' | 'download', + mode: 'view' | 'download', fileName: string, mimeType: string, ): string { @@ -1383,7 +1383,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; const url = new URL(`${restUrl}/data/${dataPath}`); - url.searchParams.append('action', action); + url.searchParams.append('mode', mode); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); return url.toString(); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 936edc1f4b69a..ea22e7bdf4c25 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2199,7 +2199,6 @@ export interface IN8nUISettings { externalSecrets: boolean; showNonProdBanner: boolean; debugInEditor: boolean; - externalStorage: boolean; workflowHistory: boolean; }; hideUsagePage: boolean; From 84596ab6b799cec508eb2e3dbf4ef78be8cee50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 17:31:57 +0200 Subject: [PATCH 195/259] Cleanup --- packages/cli/src/Server.ts | 3 --- packages/cli/src/commands/BaseCommand.ts | 5 +---- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 3 --- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ff65cb16b3b03..70bc4b70108dd 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1441,13 +1441,10 @@ export class Server extends AbstractServer { res.setHeader('Content-Length', metadata.fileSize); } catch {} } - if (mimeType) res.setHeader('Content-Type', mimeType); - if (mode === 'download') { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - res.sendFile(binaryPath); } catch (error) { if (error instanceof FileNotFoundError) res.writeHead(404).end(); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index a75456de227b7..922634b3f68af 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,12 +1,9 @@ -// import fs from 'node:fs'; -// import { pipeline } from 'node:stream/promises'; -// import { readFile } from 'node:fs/promises'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings, ObjectStoreService } from 'n8n-core'; +import { BinaryDataService, UserSettings } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 4f25265288e30..4a72ce2402427 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -232,12 +232,9 @@ export class ObjectStoreService { if (body) config.data = body; if (responseType) config.responseType = responseType; - // console.log(config); - try { return await axios.request(config); } catch (error) { - // console.log(error); throw new Error('Request to external object storage failed', { cause: { error: error as unknown, details: config }, }); From f556cee12b7122f5e9c639f66153c3c4f26ed613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 17:40:56 +0200 Subject: [PATCH 196/259] Remove comment --- packages/core/src/BinaryData/BinaryData.service.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index cf12561ea50fe..e480907e12881 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -124,13 +124,6 @@ export class BinaryDataService { return Buffer.from(binaryData.data, BINARY_ENCODING); } - /** - * Get the path to the binary data file, e.g. `/Users/{user}/.n8n/binaryData/{uuid}` - * or `/workflows/{workflowId}/executions/{executionId}/binary_data/{uuid}`. - * - * Used to allow nodes to access user-written binary files (e.g. Read PDF node) - * and to support download of execution-written binary files. - */ getPath(binaryDataId: string) { const [mode, fileId] = binaryDataId.split(':'); From c27b79848c64634e3f9f5a3099ac4bf47dd7772b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 17:27:29 +0200 Subject: [PATCH 197/259] Initial setup --- packages/cli/src/Server.ts | 29 +++- packages/cli/src/config/schema.ts | 43 +++++- packages/cli/src/constants.ts | 1 + .../cli/src/controllers/e2e.controller.ts | 1 + packages/cli/src/requests.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 7 + .../src/BinaryData/ObjectStore.manager.ts | 124 ++++++++++++++++++ .../src/ObjectStore/ObjectStore.service.ee.ts | 2 + .../editor-ui/src/stores/workflows.store.ts | 4 +- packages/workflow/src/Interfaces.ts | 1 + 10 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/BinaryData/ObjectStore.manager.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 70bc4b70108dd..d8c74012d682f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -327,6 +327,7 @@ export class Server extends AbstractServer { showNonProdBanner: false, debugInEditor: false, workflowHistory: false, + externalStorage: false, }, mfa: { enabled: false, @@ -470,6 +471,9 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), + externalStorage: + config.getEnv('externalStorage.enabled') && + Container.get(License).isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE), }); if (isLdapEnabled()) { @@ -1428,24 +1432,35 @@ export class Server extends AbstractServer { this.app.get( `/${this.restEndpoint}/data/:path`, async (req: BinaryDataRequest, res: express.Response): Promise => { - // TODO UM: check if this needs permission check for UM - const identifier = req.params.path; + const { path: binaryDataId } = req.params; + const [mode] = binaryDataId.split(':') as ['filesystem' | 's3', string]; + let { action, fileName, mimeType } = req.query; + try { - const binaryPath = this.binaryDataService.getPath(identifier); - let { mode, fileName, mimeType } = req.query; + const binaryPath = this.binaryDataService.getPath(binaryDataId); + if (!fileName || !mimeType) { try { - const metadata = await this.binaryDataService.getMetadata(identifier); + const metadata = await this.binaryDataService.getMetadata(binaryDataId); fileName = metadata.fileName; mimeType = metadata.mimeType; res.setHeader('Content-Length', metadata.fileSize); } catch {} } + if (mimeType) res.setHeader('Content-Type', mimeType); - if (mode === 'download') { + + if (action === 'download') { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - res.sendFile(binaryPath); + + if (mode === 's3') { + const readStream = await this.binaryDataService.getAsStream(binaryPath); + readStream.pipe(res); + return; + } else { + res.sendFile(binaryPath); + } } catch (error) { if (error instanceof FileNotFoundError) res.writeHead(404).end(); else throw error; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 65c17fd3c7634..a9000c1cd295e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -901,7 +901,7 @@ export const schema = { doc: 'Available modes of binary data storage, as comma separated strings', }, mode: { - format: ['default', 'filesystem'] as const, + format: ['default', 'filesystem', 's3'] as const, default: 'default', env: 'N8N_DEFAULT_BINARY_DATA_MODE', doc: 'Storage mode for binary data', @@ -914,6 +914,47 @@ export const schema = { }, }, + externalStorage: { + enabled: { + format: Boolean, + default: false, + env: 'N8N_EXTERNAL_OBJECT_STORAGE_ENABLED', + doc: 'Whether the external object storage feature is enabled', + }, + + s3: { + // @TODO: service name + bucket: { + name: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', + doc: 'Name of the n8n bucket in S3-compatible external storage', + }, + region: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', + doc: 'Region of the n8n bucket in S3-compatible external storage', + }, + }, + credentials: { + accountId: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', + doc: 'Account ID in S3-compatible external storage', + }, + secretKey: { + format: String, + default: '', + env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', + doc: 'Secret key in S3-compatible external storage', + }, + }, + }, + }, + deployment: { type: { format: String, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index c94af1f5c64b1..c9d0d30452164 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -81,6 +81,7 @@ export const LICENSE_FEATURES = { SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', + EXTERNAL_OBJECT_STORAGE: 'feat:externalStorage', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index f4e7a744994cb..f66ec80063fee 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -68,6 +68,7 @@ export class E2EController { [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, + [LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE]: false, }; constructor( diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 4ce69bc81c94f..41cae469d4f35 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -495,7 +495,7 @@ export type BinaryDataRequest = AuthenticatedRequest< {}, {}, { - mode: 'view' | 'download'; + action: 'view' | 'download'; fileName?: string; mimeType?: string; } diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index e480907e12881..366cd956e7cce 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -30,6 +30,13 @@ export class BinaryDataService { await this.managers.filesystem.init(); } + + if (config.availableModes.includes('s3')) { + const { ObjectStoreManager } = await import('./ObjectStore.manager'); + this.managers.objectStore = new ObjectStoreManager(); + + await this.managers.objectStore.init(); + } } @LogCatch((error) => Logger.error('Failed to copy binary data file', { error })) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts new file mode 100644 index 0000000000000..9cd2859887cec --- /dev/null +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -0,0 +1,124 @@ +import Container, { Service } from 'typedi'; +import { v4 as uuid } from 'uuid'; +import { FileSystemManager } from './FileSystem.manager'; +import { toBuffer } from './utils'; +import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; + +import type { Readable } from 'node:stream'; +import type { BinaryData } from './types'; + +@Service() +export class ObjectStoreManager implements BinaryData.Manager { + private readonly objectStoreService: ObjectStoreService; + + constructor() { + this.objectStoreService = Container.get(ObjectStoreService); // @TODO: Inject + } + + async init() { + await this.objectStoreService.checkConnection(); + } + + async store( + workflowId: string, + executionId: string, + bufferOrStream: Buffer | Readable, + metadata: BinaryData.PreWriteMetadata, + ) { + const fileId = this.toFileId(workflowId, executionId); + const buffer = await this.toBuffer(bufferOrStream); + + await this.objectStoreService.put(fileId, buffer, metadata); + + return { fileId, fileSize: buffer.length }; + } + + getPath(fileId: string) { + return fileId; // already full path + } + + async getAsBuffer(fileId: string) { + return this.objectStoreService.get(fileId, { mode: 'buffer' }); + } + + async getAsStream(fileId: string) { + return this.objectStoreService.get(fileId, { mode: 'stream' }); + } + + async getMetadata(fileId: string): Promise { + const { + 'content-length': contentLength, + 'content-type': contentType, + 'x-amz-meta-filename': fileName, + } = await this.objectStoreService.getMetadata(fileId); + + const metadata: BinaryData.Metadata = { fileSize: Number(contentLength) }; + + if (contentType) metadata.mimeType = contentType; + if (fileName) metadata.fileName = fileName; + + return metadata; + } + + async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { + const targetFileId = this.toFileId(workflowId, executionId); + + const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' }); + + await this.objectStoreService.put(targetFileId, sourceFile); + + return targetFileId; + } + + /** + * Create a copy of a file in the filesystem. Used by Webhook, FTP, SSH nodes. + * + * This delegates to FS manager because the object store manager does not support + * storage and access of user-written data, only execution-written data. + */ + async copyByFilePath( + workflowId: string, + executionId: string, + filePath: string, + metadata: BinaryData.PreWriteMetadata, + ) { + return Container.get(FileSystemManager).copyByFilePath( + workflowId, + executionId, + filePath, + metadata, + ); + } + + async deleteOne(fileId: string) { + await this.objectStoreService.deleteOne(fileId); + } + + async deleteMany(ids: BinaryData.IdsForDeletion) { + const prefixes = ids.map( + (o) => `/workflows/${o.workflowId}/executions/${o.executionId}/binary_data/`, + ); + + await Promise.all( + prefixes.map(async (prefix) => { + await this.objectStoreService.deleteMany(prefix); + }), + ); + } + + async rename() { + throw new Error('TODO'); + } + + // ---------------------------------- + // private methods + // ---------------------------------- + + private toFileId(workflowId: string, executionId: string) { + return `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + } + + private async toBuffer(bufferOrStream: Buffer | Readable) { + return toBuffer(bufferOrStream); + } +} diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 4a72ce2402427..718a3470dd954 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -12,6 +12,8 @@ import type { ListPage, ObjectStore, RawListPage } from './types'; import type { Readable } from 'stream'; import type { BinaryData } from '..'; +// @TODO: Decouple host from AWS + @Service() export class ObjectStoreService { private credentials: Aws4Credentials; diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 7892cd10c5bea..80721b95c3217 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1375,7 +1375,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { // Binary data getBinaryUrl( dataPath: string, - mode: 'view' | 'download', + action: 'view' | 'download', fileName: string, mimeType: string, ): string { @@ -1383,7 +1383,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; const url = new URL(`${restUrl}/data/${dataPath}`); - url.searchParams.append('mode', mode); + url.searchParams.append('action', action); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); return url.toString(); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ea22e7bdf4c25..936edc1f4b69a 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2199,6 +2199,7 @@ export interface IN8nUISettings { externalSecrets: boolean; showNonProdBanner: boolean; debugInEditor: boolean; + externalStorage: boolean; workflowHistory: boolean; }; hideUsagePage: boolean; From 923499f9853b5e0082c9cd8ecd632cda2012f3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 25 Sep 2023 22:47:49 +0200 Subject: [PATCH 198/259] Cleanup --- packages/core/test/ObjectStore.test.ts | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/core/test/ObjectStore.test.ts b/packages/core/test/ObjectStore.test.ts index e6da16680397e..9b13b253f41e8 100644 --- a/packages/core/test/ObjectStore.test.ts +++ b/packages/core/test/ObjectStore.test.ts @@ -12,8 +12,8 @@ const FAILED_REQUEST_ERROR_MESSAGE = 'Request to external object storage failed' const EXPECTED_HOST = `${MOCK_BUCKET.name}.s3.${MOCK_BUCKET.region}.amazonaws.com`; const MOCK_S3_ERROR = new Error('Something went wrong!'); -const toDeleteManyXml = (filename: string) => ` -example-file.txt +const toMultipleDeletionXml = (filename: string) => ` +${filename} `; describe('ObjectStoreService', () => { @@ -87,7 +87,7 @@ describe('ObjectStoreService', () => { describe('put()', () => { it('should send a PUT request to upload an object', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; const buffer = Buffer.from('Test content'); const metadata = { fileName: path, mimeType: 'text/plain' }; @@ -111,7 +111,7 @@ describe('ObjectStoreService', () => { }); it('should throw an error on request failure', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; const buffer = Buffer.from('Test content'); const metadata = { fileName: path, mimeType: 'text/plain' }; @@ -125,7 +125,7 @@ describe('ObjectStoreService', () => { describe('get()', () => { it('should send a GET request to download an object as a buffer', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 200, data: Buffer.from('Test content') }); @@ -143,7 +143,7 @@ describe('ObjectStoreService', () => { }); it('should send a GET request to download an object as a stream', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 200, data: new Readable() }); @@ -161,7 +161,7 @@ describe('ObjectStoreService', () => { }); it('should throw an error on request failure', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); @@ -173,7 +173,7 @@ describe('ObjectStoreService', () => { describe('deleteOne()', () => { it('should send a DELETE request to delete an object', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 204 }); @@ -188,20 +188,20 @@ describe('ObjectStoreService', () => { }); it('should throw an error on request failure', async () => { - const path = 'test-file.txt'; + const path = 'file.txt'; - mockAxios.request.mockRejectedValue(new Error('Test error')); + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); - await expect(objectStoreService.deleteOne(path)).rejects.toThrowError( - 'Request to external object storage failed', - ); + const promise = objectStoreService.deleteOne(path); + + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); }); describe('deleteMany()', () => { it('should send a POST request to delete multiple objects', async () => { const prefix = 'test-dir/'; - const fileName = 'example-file.txt'; + const fileName = 'file.txt'; const mockList = [ { @@ -228,7 +228,7 @@ describe('ObjectStoreService', () => { 'Content-Length': expect.any(Number), 'Content-MD5': expect.any(String), }), - data: toDeleteManyXml(fileName), + data: toMultipleDeletionXml(fileName), }), ); }); From 65778510095e948d50f5b068877f088743d07176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 09:01:22 +0200 Subject: [PATCH 199/259] Minor improvements --- .../src/ObjectStore/ObjectStore.service.ee.ts | 65 ++++++++----------- packages/core/src/ObjectStore/errors.ts | 8 +++ 2 files changed, 35 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/ObjectStore/errors.ts diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 4a72ce2402427..98d6f67f191ce 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream, parseXml } from './utils'; +import { ExternalStorageRequestFailed } from './errors'; import type { AxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; @@ -26,15 +27,17 @@ export class ObjectStoreService { }; } + get host() { + return `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; + } + /** * Confirm that the configured bucket exists and the caller has permission to access it. * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html */ async checkConnection() { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - return this.request('HEAD', host); + return this.request('HEAD', this.host); } /** @@ -43,8 +46,6 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html */ async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const headers: Record = { 'Content-Length': buffer.length, 'Content-MD5': createHash('md5').update(buffer).digest('base64'), @@ -53,7 +54,7 @@ export class ObjectStoreService { if (metadata.fileName) headers['x-amz-meta-filename'] = metadata.fileName; if (metadata.mimeType) headers['Content-Type'] = metadata.mimeType; - return this.request('PUT', host, `/${filename}`, { headers, body: buffer }); + return this.request('PUT', this.host, `/${filename}`, { headers, body: buffer }); } /** @@ -64,9 +65,7 @@ export class ObjectStoreService { async get(path: string, { mode }: { mode: 'buffer' }): Promise; async get(path: string, { mode }: { mode: 'stream' }): Promise; async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - const { data } = await this.request('GET', host, path, { + const { data } = await this.request('GET', this.host, path, { responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', }); @@ -83,8 +82,6 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html */ async getMetadata(path: string) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - type Response = { headers: { 'content-length': string; @@ -93,7 +90,7 @@ export class ObjectStoreService { } & Record; }; - const response: Response = await this.request('HEAD', host, path); + const response: Response = await this.request('HEAD', this.host, path); return response.headers; } @@ -104,9 +101,7 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ async deleteOne(path: string) { - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - - return this.request('DELETE', host, `/${encodeURIComponent(path)}`); + return this.request('DELETE', this.host, `/${encodeURIComponent(path)}`); } /** @@ -117,8 +112,6 @@ export class ObjectStoreService { async deleteMany(prefix: string) { const objects = await this.list(prefix); - const host = `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - const innerXml = objects.map(({ key }) => `${key}`).join('\n'); const body = ['', innerXml, ''].join('\n'); @@ -129,7 +122,7 @@ export class ObjectStoreService { 'Content-MD5': createHash('md5').update(body).digest('base64'), }; - return this.request('POST', host, '/?delete', { headers, body }); + return this.request('POST', this.host, '/?delete', { headers, body }); } /** @@ -141,16 +134,16 @@ export class ObjectStoreService { const items = []; let isTruncated; - let token; // for next page + let nextPageToken; do { - const listPage = await this.getListPage(prefix, token); + const listPage = await this.getListPage(prefix, nextPageToken); if (listPage.contents?.length > 0) items.push(...listPage.contents); isTruncated = listPage.isTruncated; - token = listPage.nextContinuationToken; - } while (isTruncated && token); + nextPageToken = listPage.nextContinuationToken; + } while (isTruncated && nextPageToken); return items; } @@ -159,27 +152,25 @@ export class ObjectStoreService { * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. */ async getListPage(prefix: string, nextPageToken?: string) { - const host = `s3.${this.bucket.region}.amazonaws.com`; + const bucketlessHost = this.host.split('.').slice(1).join('.'); const qs: Record = { 'list-type': 2, prefix }; if (nextPageToken) qs['continuation-token'] = nextPageToken; - const response = await this.request('GET', host, `/${this.bucket.name}`, { qs }); + const { data } = await this.request('GET', bucketlessHost, `/${this.bucket.name}`, { qs }); - if (typeof response.data !== 'string') { - throw new TypeError('Expected string'); + if (typeof data !== 'string') { + throw new TypeError(`Expected XML string but received ${typeof data}`); } - const { listBucketResult: page } = await parseXml(response.data); + const { listBucketResult: page } = await parseXml(data); if (!page.contents) return { ...page, contents: [] }; - // restore array wrapper removed by `explicitArray: false` on single item array + // `explicitArray: false` removes array wrapper on single item array, so restore it - if (!Array.isArray(page.contents)) { - page.contents = [page.contents]; - } + if (!Array.isArray(page.contents)) page.contents = [page.contents]; // remove null prototype - https://github.com/Leonidas-from-XIV/node-xml2js/issues/670 @@ -190,13 +181,13 @@ export class ObjectStoreService { return page as ListPage; } - private toRequestPath(rawPath: string, qs?: Record) { + private toPath(rawPath: string, qs?: Record) { const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; if (!qs) return path; - const qsParams = Object.keys(qs) - .map((key) => `${key}=${qs[key]}`) + const qsParams = Object.entries(qs) + .map(([key, value]) => `${key}=${value}`) .join('&'); return path.concat(`?${qsParams}`); @@ -208,7 +199,7 @@ export class ObjectStoreService { rawPath = '', { qs, headers, body, responseType }: ObjectStore.RequestOptions = {}, ) { - const path = this.toRequestPath(rawPath, qs); + const path = this.toPath(rawPath, qs); const optionsToSign: Aws4Options = { method, @@ -235,9 +226,7 @@ export class ObjectStoreService { try { return await axios.request(config); } catch (error) { - throw new Error('Request to external object storage failed', { - cause: { error: error as unknown, details: config }, - }); + throw new ExternalStorageRequestFailed(error, config); } } } diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts new file mode 100644 index 0000000000000..81da737d77ba0 --- /dev/null +++ b/packages/core/src/ObjectStore/errors.ts @@ -0,0 +1,8 @@ +import { AxiosRequestConfig } from 'axios'; + +export class ExternalStorageRequestFailed extends Error { + constructor(error: unknown, details: AxiosRequestConfig) { + super('Request to external object storage failed'); + this.cause = { error, details }; + } +} From 2dbbfa8d5a64ebe8c90db1a1ebc4eae5c806661a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 09:21:06 +0200 Subject: [PATCH 200/259] Remove from FE settings --- packages/cli/src/Server.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d8c74012d682f..d15d5294a0b05 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -471,9 +471,6 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), - externalStorage: - config.getEnv('externalStorage.enabled') && - Container.get(License).isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE), }); if (isLdapEnabled()) { From bbddb2a2793570e1980e0d78cb396b8d8eff062c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 09:22:38 +0200 Subject: [PATCH 201/259] Remmove from `IN8nUISettings` --- packages/workflow/src/Interfaces.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 936edc1f4b69a..ea22e7bdf4c25 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2199,7 +2199,6 @@ export interface IN8nUISettings { externalSecrets: boolean; showNonProdBanner: boolean; debugInEditor: boolean; - externalStorage: boolean; workflowHistory: boolean; }; hideUsagePage: boolean; From 73b09b383abd5ffce57bdac2dad38e0e10faacc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 09:39:29 +0200 Subject: [PATCH 202/259] Missing spot --- packages/cli/src/Server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index d15d5294a0b05..1157752d05bb2 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -327,7 +327,6 @@ export class Server extends AbstractServer { showNonProdBanner: false, debugInEditor: false, workflowHistory: false, - externalStorage: false, }, mfa: { enabled: false, From 16503c5e059ad800934126672bd07ac05f8503d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 10:57:32 +0200 Subject: [PATCH 203/259] Initialize s3 binary manager --- packages/cli/src/commands/BaseCommand.ts | 32 +++++++++++++++++-- packages/cli/src/commands/execute.ts | 1 + packages/cli/src/commands/executeBatch.ts | 1 + packages/cli/src/commands/start.ts | 1 + packages/cli/src/commands/webhook.ts | 1 + packages/cli/src/commands/worker.ts | 1 + packages/cli/src/config/schema.ts | 9 +----- packages/cli/src/errors.ts | 7 ++++ .../core/src/BinaryData/BinaryData.service.ts | 4 +-- .../src/BinaryData/ObjectStore.manager.ts | 7 ++-- .../src/ObjectStore/ObjectStore.service.ee.ts | 12 +++++-- packages/core/src/ObjectStore/types.ts | 2 ++ 12 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/errors.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 922634b3f68af..9aa77c14ca21f 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -3,13 +3,13 @@ import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, UserSettings } from 'n8n-core'; +import { BinaryDataService, ObjectStoreService, UserSettings } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; import * as Db from '@/Db'; import * as CrashJournal from '@/CrashJournal'; -import { inTest } from '@/constants'; +import { LICENSE_FEATURES, inTest } from '@/constants'; import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { initErrorHandling } from '@/ErrorReporting'; @@ -22,6 +22,7 @@ import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvalator'; +import { BinaryDataS3NotLicensedError } from '../errors'; export abstract class BaseCommand extends Command { protected logger = LoggerProxy.init(getLogger()); @@ -105,6 +106,33 @@ export abstract class BaseCommand extends Command { process.exit(1); } + async initObjectStoreService() { + const isS3Enabled = + config.get('binaryDataManager.mode') === 's3' && + config.get('binaryDataManager.availableModes').includes('s3'); + + if (!isS3Enabled) return; + + // @TODO: Re-enable later + if (false || !Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3)) { + throw new BinaryDataS3NotLicensedError(); + } + + const objectStoreService = Container.get(ObjectStoreService); + + const bucket = { + name: config.getEnv('externalStorage.s3.bucket.name'), + region: config.getEnv('externalStorage.s3.bucket.region'), + }; + + const credentials = { + accountId: config.getEnv('externalStorage.s3.credentials.accountId'), + secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), + }; + + await objectStoreService.init(bucket, credentials); + } + async initBinaryDataService() { const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index f50d6c7f53d51..749a4742c91da 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -33,6 +33,7 @@ export class Execute extends BaseCommand { async init() { await super.init(); + await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index a20348c92e5da..d37fa395c9a44 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -180,6 +180,7 @@ export class ExecuteBatch extends BaseCommand { async init() { await super.init(); + await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 235c0c8144a07..36fd93212258a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -201,6 +201,7 @@ export class Start extends BaseCommand { this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner); await this.initLicense('main'); + await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 1a681be8b6b5a..31412f1437c5e 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -78,6 +78,7 @@ export class Webhook extends BaseCommand { await super.init(); await this.initLicense('webhook'); + await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 28501a90cad26..200d7c2135b6e 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -257,6 +257,7 @@ export class Worker extends BaseCommand { this.logger.debug('Starting n8n worker...'); await this.initLicense('worker'); + await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a9000c1cd295e..3514dceec236f 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -896,7 +896,7 @@ export const schema = { binaryDataManager: { availableModes: { format: 'comma-separated-list', - default: 'filesystem', + default: 'filesystem,s3', env: 'N8N_AVAILABLE_BINARY_DATA_MODES', doc: 'Available modes of binary data storage, as comma separated strings', }, @@ -915,13 +915,6 @@ export const schema = { }, externalStorage: { - enabled: { - format: Boolean, - default: false, - env: 'N8N_EXTERNAL_OBJECT_STORAGE_ENABLED', - doc: 'Whether the external object storage feature is enabled', - }, - s3: { // @TODO: service name bucket: { diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000000000..1465906ee2158 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,7 @@ +export class BinaryDataS3NotLicensedError extends Error { + constructor() { + super( + 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.', + ); + } +} diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 366cd956e7cce..b6e7fb0118c4c 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -33,9 +33,9 @@ export class BinaryDataService { if (config.availableModes.includes('s3')) { const { ObjectStoreManager } = await import('./ObjectStore.manager'); - this.managers.objectStore = new ObjectStoreManager(); + this.managers.s3 = new ObjectStoreManager(); - await this.managers.objectStore.init(); + await this.managers.s3.init(); } } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 9cd2859887cec..be5eef78cc7e7 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -96,7 +96,8 @@ export class ObjectStoreManager implements BinaryData.Manager { async deleteMany(ids: BinaryData.IdsForDeletion) { const prefixes = ids.map( - (o) => `/workflows/${o.workflowId}/executions/${o.executionId}/binary_data/`, + ({ workflowId, executionId }) => + `workflows/${workflowId}/executions/${executionId}/binary_data/`, ); await Promise.all( @@ -107,7 +108,7 @@ export class ObjectStoreManager implements BinaryData.Manager { } async rename() { - throw new Error('TODO'); + throw new Error('TODO'); // @TODO } // ---------------------------------- @@ -115,7 +116,7 @@ export class ObjectStoreManager implements BinaryData.Manager { // ---------------------------------- private toFileId(workflowId: string, executionId: string) { - return `/workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; } private async toBuffer(bufferOrStream: Buffer | Readable) { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 12cb67ef17d41..9256f362edc45 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -17,16 +17,22 @@ import type { BinaryData } from '..'; @Service() export class ObjectStoreService { - private credentials: Aws4Credentials; + private credentials: Aws4Credentials = { accessKeyId: '', secretAccessKey: '' }; - constructor( - private bucket: { region: string; name: string }, + private bucket: ObjectStore.Bucket = { region: '', name: '' }; + + async init( + bucket: { region: string; name: string }, credentials: { accountId: string; secretKey: string }, ) { + this.bucket = bucket; + this.credentials = { accessKeyId: credentials.accountId, secretAccessKey: credentials.secretKey, }; + + await this.checkConnection(); } get host() { diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index b5ac6c96d1aa7..4b687ba432d16 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -23,6 +23,8 @@ type Item = { export type ListPage = Omit & { contents: Item[] }; export namespace ObjectStore { + export type Bucket = { region: string; name: string }; + export type RequestOptions = { qs?: Record; headers?: Record; From e3cb832fcdffbb728cbb44a84164b2a4e04259b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:08:16 +0200 Subject: [PATCH 204/259] Add rename --- packages/cli/src/commands/BaseCommand.ts | 9 ++++----- packages/cli/src/constants.ts | 2 +- packages/core/src/BinaryData/ObjectStore.manager.ts | 6 ++++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 9aa77c14ca21f..72a0848556bd9 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -9,7 +9,7 @@ import { getLogger } from '@/Logger'; import config from '@/config'; import * as Db from '@/Db'; import * as CrashJournal from '@/CrashJournal'; -import { LICENSE_FEATURES, inTest } from '@/constants'; +import { inTest } from '@/constants'; import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { initErrorHandling } from '@/ErrorReporting'; @@ -22,7 +22,6 @@ import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvalator'; -import { BinaryDataS3NotLicensedError } from '../errors'; export abstract class BaseCommand extends Command { protected logger = LoggerProxy.init(getLogger()); @@ -114,9 +113,9 @@ export abstract class BaseCommand extends Command { if (!isS3Enabled) return; // @TODO: Re-enable later - if (false || !Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3)) { - throw new BinaryDataS3NotLicensedError(); - } + // if (!Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3)) { + // throw new BinaryDataS3NotLicensedError(); + // } const objectStoreService = Container.get(ObjectStoreService); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index c9d0d30452164..96f491cb2d4e8 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -81,7 +81,7 @@ export const LICENSE_FEATURES = { SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', - EXTERNAL_OBJECT_STORAGE: 'feat:externalStorage', + BINARY_DATA_S3: 'feat:binaryDataS3', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index be5eef78cc7e7..25c2fe91162cb 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -107,8 +107,10 @@ export class ObjectStoreManager implements BinaryData.Manager { ); } - async rename() { - throw new Error('TODO'); // @TODO + async rename(oldFileId: string, newFileId: string) { + const oldFile = await this.objectStoreService.get(oldFileId, { mode: 'buffer' }); + + await this.objectStoreService.put(newFileId, oldFile); } // ---------------------------------- From b2bd541f20226f2fd0842e1867c7b33a86d3a962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:17:51 +0200 Subject: [PATCH 205/259] Fix check --- packages/cli/src/commands/BaseCommand.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 72a0848556bd9..a86e843f70b00 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -106,14 +106,15 @@ export abstract class BaseCommand extends Command { } async initObjectStoreService() { - const isS3Enabled = - config.get('binaryDataManager.mode') === 's3' && - config.get('binaryDataManager.availableModes').includes('s3'); + const isS3Required = config.get('binaryDataManager.availableModes').includes('s3'); - if (!isS3Enabled) return; + if (!isS3Required) return; // @TODO: Re-enable later - // if (!Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3)) { + // if ( + // config.get('binaryDataManager.mode') === 's3' && + // !Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3) + // ) { // throw new BinaryDataS3NotLicensedError(); // } From 09d862e40f6d4ae030ccc175172e24fb16bcda3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:22:57 +0200 Subject: [PATCH 206/259] Inject dependency --- packages/core/src/BinaryData/ObjectStore.manager.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 25c2fe91162cb..e9a1b87d0af42 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -9,11 +9,7 @@ import type { BinaryData } from './types'; @Service() export class ObjectStoreManager implements BinaryData.Manager { - private readonly objectStoreService: ObjectStoreService; - - constructor() { - this.objectStoreService = Container.get(ObjectStoreService); // @TODO: Inject - } + constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} async init() { await this.objectStoreService.checkConnection(); From 2dd42b2a64b3a5769ed674c44583fafd52ea6401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:27:01 +0200 Subject: [PATCH 207/259] Missing spot --- packages/cli/src/controllers/e2e.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index f66ec80063fee..24b499747675c 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -68,7 +68,7 @@ export class E2EController { [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, - [LICENSE_FEATURES.EXTERNAL_OBJECT_STORAGE]: false, + [LICENSE_FEATURES.BINARY_DATA_S3]: false, }; constructor( From 769550bc5a2261bdbea166fc19dcebaf154b8604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:34:32 +0200 Subject: [PATCH 208/259] Fix serving to UI --- packages/cli/src/Server.ts | 8 ++++---- packages/cli/src/requests.ts | 3 ++- packages/editor-ui/src/stores/workflows.store.ts | 5 +++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 1157752d05bb2..7ab8d5f9ce5db 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1426,11 +1426,11 @@ export class Server extends AbstractServer { // Download binary this.app.get( - `/${this.restEndpoint}/data/:path`, + `/${this.restEndpoint}/data`, async (req: BinaryDataRequest, res: express.Response): Promise => { - const { path: binaryDataId } = req.params; + const { id: binaryDataId, action } = req.query; + let { fileName, mimeType } = req.query; const [mode] = binaryDataId.split(':') as ['filesystem' | 's3', string]; - let { action, fileName, mimeType } = req.query; try { const binaryPath = this.binaryDataService.getPath(binaryDataId); @@ -1451,7 +1451,7 @@ export class Server extends AbstractServer { } if (mode === 's3') { - const readStream = await this.binaryDataService.getAsStream(binaryPath); + const readStream = await this.binaryDataService.getAsStream(binaryDataId); readStream.pipe(res); return; } else { diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 41cae469d4f35..25eea3770a18a 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -491,10 +491,11 @@ export declare namespace LicenseRequest { } export type BinaryDataRequest = AuthenticatedRequest< - { path: string }, + {}, {}, {}, { + id: string; action: 'view' | 'download'; fileName?: string; mimeType?: string; diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 80721b95c3217..ec5d2aff75ec1 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1374,7 +1374,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { }, // Binary data getBinaryUrl( - dataPath: string, + binaryDataId: string, action: 'view' | 'download', fileName: string, mimeType: string, @@ -1382,7 +1382,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { const rootStore = useRootStore(); let restUrl = rootStore.getRestUrl; if (restUrl.startsWith('/')) restUrl = window.location.origin + restUrl; - const url = new URL(`${restUrl}/data/${dataPath}`); + const url = new URL(`${restUrl}/data`); + url.searchParams.append('id', binaryDataId); url.searchParams.append('action', action); if (fileName) url.searchParams.append('fileName', fileName); if (mimeType) url.searchParams.append('mimeType', mimeType); From 377245b61f03832fe37eac060017a46231f1a2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:39:28 +0200 Subject: [PATCH 209/259] Fill out comment --- packages/cli/src/Server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 7ab8d5f9ce5db..f0d8e6470f800 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1424,7 +1424,7 @@ export class Server extends AbstractServer { // Binary data // ---------------------------------------- - // Download binary + // View or download binary file this.app.get( `/${this.restEndpoint}/data`, async (req: BinaryDataRequest, res: express.Response): Promise => { From 310fa9a2e4ec803b11a142d9174cb489efec4a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:40:54 +0200 Subject: [PATCH 210/259] Better naming --- packages/cli/src/commands/BaseCommand.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index a86e843f70b00..f5fe03e4d33f6 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -106,9 +106,9 @@ export abstract class BaseCommand extends Command { } async initObjectStoreService() { - const isS3Required = config.get('binaryDataManager.availableModes').includes('s3'); + const isNeeded = config.get('binaryDataManager.availableModes').includes('s3'); - if (!isS3Required) return; + if (!isNeeded) return; // @TODO: Re-enable later // if ( From e3f7bf312b8af8aea5692b038a6e554adeb93015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 11:46:17 +0200 Subject: [PATCH 211/259] Rename env vars --- packages/cli/src/config/schema.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 3514dceec236f..d76d52af62655 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -921,13 +921,13 @@ export const schema = { name: { format: String, default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_NAME', + env: 'N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME', doc: 'Name of the n8n bucket in S3-compatible external storage', }, region: { format: String, default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_BUCKET_REGION', + env: 'N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION', doc: 'Region of the n8n bucket in S3-compatible external storage', }, }, @@ -935,13 +935,13 @@ export const schema = { accountId: { format: String, default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_ACCOUNT_ID', + env: 'N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID', doc: 'Account ID in S3-compatible external storage', }, secretKey: { format: String, default: '', - env: 'N8N_EXTERNAL_OBJECT_STORAGE_SECRET_KEY', + env: 'N8N_EXTERNAL_STORAGE_S3_SECRET_KEY', doc: 'Secret key in S3-compatible external storage', }, }, From 145bce5bda65e1547d58589c91d7672daaf4d740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 12:05:37 +0200 Subject: [PATCH 212/259] Remove `deleteOne` --- packages/core/src/BinaryData/FileSystem.manager.ts | 6 ------ packages/core/src/BinaryData/ObjectStore.manager.ts | 4 ---- packages/core/src/BinaryData/types.ts | 1 - packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 2 +- 4 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 62a47bdc54b38..3ecb62a65e402 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -71,12 +71,6 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId, fileSize }; } - async deleteOne(fileId: string) { - const filePath = this.getPath(fileId); - - return fs.rm(filePath); - } - async deleteMany(ids: BinaryData.IdsForDeletion) { const executionIds = ids.map((o) => o.executionId); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index e9a1b87d0af42..dfc2917a35edd 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -86,10 +86,6 @@ export class ObjectStoreManager implements BinaryData.Manager { ); } - async deleteOne(fileId: string) { - await this.objectStoreService.deleteOne(fileId); - } - async deleteMany(ids: BinaryData.IdsForDeletion) { const prefixes = ids.map( ({ workflowId, executionId }) => diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index bedd024a06eb8..b9e371321a96c 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -45,7 +45,6 @@ export namespace BinaryData { metadata: PreWriteMetadata, ): Promise; - deleteOne(fileId: string): Promise; deleteMany(ids: IdsForDeletion): Promise; rename(oldFileId: string, newFileId: string): Promise; diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 9256f362edc45..3aba783e61b39 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -104,7 +104,7 @@ export class ObjectStoreService { } /** - * Delete an object in the configured bucket. + * Delete a single object in the configured bucket. * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ From 89919a16a3608305f1fa23dbe62432ddb32be21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 12:10:54 +0200 Subject: [PATCH 213/259] Fix tests --- packages/core/test/ObjectStore.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/test/ObjectStore.test.ts b/packages/core/test/ObjectStore.test.ts index 9b13b253f41e8..453ec6c0eebb5 100644 --- a/packages/core/test/ObjectStore.test.ts +++ b/packages/core/test/ObjectStore.test.ts @@ -19,8 +19,10 @@ const toMultipleDeletionXml = (filename: string) => ` describe('ObjectStoreService', () => { let objectStoreService: ObjectStoreService; - beforeEach(() => { - objectStoreService = new ObjectStoreService(MOCK_BUCKET, MOCK_CREDENTIALS); + beforeEach(async () => { + objectStoreService = new ObjectStoreService(); + mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection + await objectStoreService.init(MOCK_BUCKET, MOCK_CREDENTIALS); jest.restoreAllMocks(); }); From 98d0c3a96d805cc81c9f03e9849d25e973341262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 12:57:23 +0200 Subject: [PATCH 214/259] Rename errors --- packages/core/src/BinaryData/BinaryData.service.ts | 6 +++--- packages/core/src/BinaryData/errors.ts | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index b6e7fb0118c4c..9e4e510410394 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -5,7 +5,7 @@ import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; -import { UnknownBinaryDataManager, InvalidBinaryDataMode } from './errors'; +import { UnknownBinaryDataManagerError, InvalidBinaryDataModeError } from './errors'; import { LogCatch } from '../decorators/LogCatch.decorator'; import { areValidModes, toBuffer } from './utils'; @@ -20,7 +20,7 @@ export class BinaryDataService { private managers: Record = {}; async init(config: BinaryData.Config) { - if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataMode(); + if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataModeError(); this.mode = config.mode; @@ -249,6 +249,6 @@ export class BinaryDataService { if (manager) return manager; - throw new UnknownBinaryDataManager(mode); + throw new UnknownBinaryDataManagerError(mode); } } diff --git a/packages/core/src/BinaryData/errors.ts b/packages/core/src/BinaryData/errors.ts index dc52875b2a768..6ff1a637c7666 100644 --- a/packages/core/src/BinaryData/errors.ts +++ b/packages/core/src/BinaryData/errors.ts @@ -1,12 +1,10 @@ import { BINARY_DATA_MODES } from './utils'; -export class InvalidBinaryDataMode extends Error { - constructor() { - super(`Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`); - } +export class InvalidBinaryDataModeError extends Error { + message = `Invalid binary data mode. Valid modes: ${BINARY_DATA_MODES.join(', ')}`; } -export class UnknownBinaryDataManager extends Error { +export class UnknownBinaryDataManagerError extends Error { constructor(mode: string) { super(`No binary data manager found for: ${mode}`); } From 63c03d5e2020749fc7598c0753562addb81648e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 13:00:05 +0200 Subject: [PATCH 215/259] Straighten out license checks --- packages/cli/src/commands/BaseCommand.ts | 30 ++++++++++++++++-------- packages/cli/src/errors.ts | 14 ++++++----- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index f5fe03e4d33f6..7e1c60215553a 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -9,7 +9,7 @@ import { getLogger } from '@/Logger'; import config from '@/config'; import * as Db from '@/Db'; import * as CrashJournal from '@/CrashJournal'; -import { inTest } from '@/constants'; +import { LICENSE_FEATURES, inTest } from '@/constants'; import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { initErrorHandling } from '@/ErrorReporting'; @@ -22,6 +22,7 @@ import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvalator'; +import { ExternalStorageUnavailableError, ExternalStorageUnlicensedError } from '@/errors'; export abstract class BaseCommand extends Command { protected logger = LoggerProxy.init(getLogger()); @@ -106,18 +107,27 @@ export abstract class BaseCommand extends Command { } async initObjectStoreService() { - const isNeeded = config.get('binaryDataManager.availableModes').includes('s3'); + const isSelected = config.get('binaryDataManager.mode') === 's3'; - if (!isNeeded) return; + const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); - // @TODO: Re-enable later - // if ( - // config.get('binaryDataManager.mode') === 's3' && - // !Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3) - // ) { - // throw new BinaryDataS3NotLicensedError(); - // } + if (!isSelected && !isAvailable) return; + if (isSelected && !isAvailable) throw new ExternalStorageUnavailableError(); + + if (!isSelected && isAvailable) { + await this._initObjectStoreService(); + return; + } + + const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); + + if (isSelected && isAvailable && !isLicensed) throw new ExternalStorageUnlicensedError(); + + await this._initObjectStoreService(); + } + + private async _initObjectStoreService() { const objectStoreService = Container.get(ObjectStoreService); const bucket = { diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 1465906ee2158..67d889f328499 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -1,7 +1,9 @@ -export class BinaryDataS3NotLicensedError extends Error { - constructor() { - super( - 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.', - ); - } +export class ExternalStorageUnlicensedError extends Error { + message = + 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.'; +} + +export class ExternalStorageUnavailableError extends Error { + message = + 'External storage selected but unavailable. Include "s3" when setting N8N_AVAILABLE_BINARY_DATA_MODES.'; } From d1ae8fac41021da9b9fd727752c5493c1f0abfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 13:21:46 +0200 Subject: [PATCH 216/259] Fix tests --- packages/cli/src/commands/BaseCommand.ts | 2 ++ packages/cli/test/integration/shared/utils/index.ts | 10 ++++++---- packages/nodes-base/test/nodes/Helpers.ts | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 7e1c60215553a..c6d7d61eb7622 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -107,6 +107,8 @@ export abstract class BaseCommand extends Command { } async initObjectStoreService() { + if (inTest) return; // @TODO: Only for worker.cmd.test.ts + const isSelected = config.get('binaryDataManager.mode') === 's3'; const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index ee4b1e57509f3..9cdd4a8b5c1d2 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -74,11 +74,13 @@ export async function initNodeTypes() { /** * Initialize a BinaryDataService for test runs. */ -export async function initBinaryDataService() { +export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { const binaryDataService = new BinaryDataService(); - - await binaryDataService.init(config.getEnv('binaryDataManager')); - + await binaryDataService.init({ + mode: 'default', + availableModes: [mode], + localStoragePath: '', + }); Container.set(BinaryDataService, binaryDataService); } diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 6735223e3fdb3..905a02ba81568 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -219,7 +219,11 @@ export function createTemporaryDir(prefix = 'n8n') { export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { const binaryDataService = new BinaryDataService(); - await binaryDataService.init({ mode: 'default', availableModes: [mode] }); + await binaryDataService.init({ + mode: 'default', + availableModes: [mode], + localStoragePath: createTemporaryDir(), + }); Container.set(BinaryDataService, binaryDataService); } From 157d2efd1c53a1ea1d828ac74443c0fb50de85a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 17:12:52 +0200 Subject: [PATCH 217/259] Adjust license checks --- packages/cli/src/commands/BaseCommand.ts | 22 ++++++++++++++-------- packages/cli/src/errors.ts | 8 ++++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index c6d7d61eb7622..ed96119b27cb7 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -22,7 +22,7 @@ import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvalator'; -import { ExternalStorageUnavailableError, ExternalStorageUnlicensedError } from '@/errors'; +import { ExternalStorageUnavailableError } from '@/errors'; export abstract class BaseCommand extends Command { protected logger = LoggerProxy.init(getLogger()); @@ -110,23 +110,29 @@ export abstract class BaseCommand extends Command { if (inTest) return; // @TODO: Only for worker.cmd.test.ts const isSelected = config.get('binaryDataManager.mode') === 's3'; - const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); + const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); - if (!isSelected && !isAvailable) return; + if (isSelected && isAvailable && isLicensed) { + await this._initObjectStoreService(); + return; + } - if (isSelected && !isAvailable) throw new ExternalStorageUnavailableError(); + if (isSelected && isAvailable && !isLicensed) { + await this._initObjectStoreService(); // @TODO: readonly mode, block writes + return; + } if (!isSelected && isAvailable) { await this._initObjectStoreService(); return; } - const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); - - if (isSelected && isAvailable && !isLicensed) throw new ExternalStorageUnlicensedError(); + if (!isSelected && !isAvailable) return; - await this._initObjectStoreService(); + if (isSelected && !isAvailable) { + throw new ExternalStorageUnavailableError(); + } } private async _initObjectStoreService() { diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 67d889f328499..545484022f894 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -1,7 +1,7 @@ -export class ExternalStorageUnlicensedError extends Error { - message = - 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.'; -} +// export class ExternalStorageUnlicensedError extends Error { +// message = +// 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.'; +// } export class ExternalStorageUnavailableError extends Error { message = From 7f653037092896df43b7f973da659a99b6774fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 17:55:26 +0200 Subject: [PATCH 218/259] Add explanatory comments --- packages/cli/src/commands/BaseCommand.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index ed96119b27cb7..d5d1c8c98ea4e 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -114,16 +114,19 @@ export abstract class BaseCommand extends Command { const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); if (isSelected && isAvailable && isLicensed) { + // allow reads from anywhere, allow writes to S3 await this._initObjectStoreService(); return; } if (isSelected && isAvailable && !isLicensed) { - await this._initObjectStoreService(); // @TODO: readonly mode, block writes + // allow reads from anywhere, block writes to S3 + await this._initObjectStoreService(); // @TODO return; } if (!isSelected && isAvailable) { + // allow reads from anywhere, writes do not go to S3 await this._initObjectStoreService(); return; } From 24cc474be79188b1ca680ffcc93508232e7795f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 18:27:53 +0200 Subject: [PATCH 219/259] Add readonly mode --- packages/cli/src/commands/BaseCommand.ts | 17 ++++++++-------- packages/cli/src/errors.ts | 9 --------- .../src/ObjectStore/ObjectStore.service.ee.ts | 17 +++++++++++----- packages/core/src/ObjectStore/errors.ts | 20 +++++++++++++++---- packages/core/src/ObjectStore/types.ts | 16 +++++++-------- 5 files changed, 43 insertions(+), 36 deletions(-) delete mode 100644 packages/cli/src/errors.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index bc2a98d155f2e..3d1375a31d178 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -22,7 +22,6 @@ import { PostHogClient } from '@/posthog'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvalator'; -import { ExternalStorageUnavailableError } from '@/errors'; import { generateHostInstanceId } from '../databases/utils/generators'; export abstract class BaseCommand extends Command { @@ -141,25 +140,25 @@ export abstract class BaseCommand extends Command { } if (isSelected && isAvailable && !isLicensed) { - // allow reads from anywhere, block writes to S3 - await this._initObjectStoreService(); // @TODO + // allow reads from anywhere, block writes to s3 + await this._initObjectStoreService({ isReadOnly: true }); return; } if (!isSelected && isAvailable) { - // allow reads from anywhere, writes do not go to S3 + // allow reads from anywhere, no writes to S3 will occur await this._initObjectStoreService(); return; } - if (!isSelected && !isAvailable) return; - if (isSelected && !isAvailable) { - throw new ExternalStorageUnavailableError(); + throw new Error( + 'External storage selected but unavailable. Please make external storage available, e.g. `export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3`', + ); } } - private async _initObjectStoreService() { + private async _initObjectStoreService(options = { isReadOnly: false }) { const objectStoreService = Container.get(ObjectStoreService); const bucket = { @@ -172,7 +171,7 @@ export abstract class BaseCommand extends Command { secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), }; - await objectStoreService.init(bucket, credentials); + await objectStoreService.init(bucket, credentials, options); } async initBinaryDataService() { diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts deleted file mode 100644 index 545484022f894..0000000000000 --- a/packages/cli/src/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -// export class ExternalStorageUnlicensedError extends Error { -// message = -// 'Binary data storage in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than s3.'; -// } - -export class ExternalStorageUnavailableError extends Error { - message = - 'External storage selected but unavailable. Include "s3" when setting N8N_AVAILABLE_BINARY_DATA_MODES.'; -} diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 3aba783e61b39..eeafbb346d37a 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -5,11 +5,11 @@ import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream, parseXml } from './utils'; -import { ExternalStorageRequestFailed } from './errors'; +import { ObjectStore } from './errors'; import type { AxiosRequestConfig, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; -import type { ListPage, ObjectStore, RawListPage } from './types'; +import type { Bucket, ListPage, RawListPage, RequestOptions } from './types'; import type { Readable } from 'stream'; import type { BinaryData } from '..'; @@ -19,11 +19,14 @@ import type { BinaryData } from '..'; export class ObjectStoreService { private credentials: Aws4Credentials = { accessKeyId: '', secretAccessKey: '' }; - private bucket: ObjectStore.Bucket = { region: '', name: '' }; + private bucket: Bucket = { region: '', name: '' }; + + private isReadOnly = false; async init( bucket: { region: string; name: string }, credentials: { accountId: string; secretKey: string }, + options?: { isReadOnly: boolean }, ) { this.bucket = bucket; @@ -32,6 +35,8 @@ export class ObjectStoreService { secretAccessKey: credentials.secretKey, }; + if (options?.isReadOnly) this.isReadOnly = true; + await this.checkConnection(); } @@ -54,6 +59,8 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html */ async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) { + if (this.isReadOnly) throw new ObjectStore.WriteBlockedError(filename); + const headers: Record = { 'Content-Length': buffer.length, 'Content-MD5': createHash('md5').update(buffer).digest('base64'), @@ -205,7 +212,7 @@ export class ObjectStoreService { method: Method, host: string, rawPath = '', - { qs, headers, body, responseType }: ObjectStore.RequestOptions = {}, + { qs, headers, body, responseType }: RequestOptions = {}, ) { const path = this.toPath(rawPath, qs); @@ -234,7 +241,7 @@ export class ObjectStoreService { try { return await axios.request(config); } catch (error) { - throw new ExternalStorageRequestFailed(error, config); + throw new ObjectStore.RequestFailedError(error, config); } } } diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts index 81da737d77ba0..1e1b69b9c3f1d 100644 --- a/packages/core/src/ObjectStore/errors.ts +++ b/packages/core/src/ObjectStore/errors.ts @@ -1,8 +1,20 @@ import { AxiosRequestConfig } from 'axios'; -export class ExternalStorageRequestFailed extends Error { - constructor(error: unknown, details: AxiosRequestConfig) { - super('Request to external object storage failed'); - this.cause = { error, details }; +export namespace ObjectStore { + export class RequestFailedError extends Error { + message = 'Request to external object storage failed'; + + constructor(error: unknown, details: AxiosRequestConfig) { + super(); + this.cause = { error, details }; + } + } + + export class WriteBlockedError extends Error { + constructor(filename: string) { + super( + `Request to write file "${filename}" to object storage was blocked. This is likely because storing binary data in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than "s3".`, + ); + } } } diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index 4b687ba432d16..b0a4ade19bc15 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -22,13 +22,11 @@ type Item = { export type ListPage = Omit & { contents: Item[] }; -export namespace ObjectStore { - export type Bucket = { region: string; name: string }; +export type Bucket = { region: string; name: string }; - export type RequestOptions = { - qs?: Record; - headers?: Record; - body?: string | Buffer; - responseType?: ResponseType; - }; -} +export type RequestOptions = { + qs?: Record; + headers?: Record; + body?: string | Buffer; + responseType?: ResponseType; +}; From 4afcb196f51c9a0de84c0da40720961720607417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 18:57:19 +0200 Subject: [PATCH 220/259] Implement `copyByFilePath` --- .../core/src/BinaryData/FileSystem.manager.ts | 13 +++++----- .../src/BinaryData/ObjectStore.manager.ts | 26 +++++++++---------- packages/core/src/BinaryData/types.ts | 2 +- packages/core/src/NodeExecuteFunctions.ts | 1 + 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 3ecb62a65e402..088b664975c47 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -91,18 +91,19 @@ export class FileSystemManager implements BinaryData.Manager { async copyByFilePath( _workflowId: string, executionId: string, - filePath: string, + sourceFilePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const newFileId = this.toFileId(executionId); + const targetFileId = this.toFileId(executionId); + const targetFilePath = this.getPath(targetFileId); - await fs.cp(filePath, this.getPath(newFileId)); + await fs.cp(sourceFilePath, targetFilePath); - const fileSize = await this.getSize(newFileId); + const fileSize = await this.getSize(targetFileId); - await this.storeMetadata(newFileId, { mimeType, fileName, fileSize }); + await this.storeMetadata(targetFileId, { mimeType, fileName, fileSize }); - return { fileId: newFileId, fileSize }; + return { fileId: targetFileId, fileSize }; } async copyByFileId(_workflowId: string, executionId: string, sourceFileId: string) { diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index dfc2917a35edd..bf078b690bd54 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,9 +1,8 @@ +import fs from 'node:fs/promises'; import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; -import { FileSystemManager } from './FileSystem.manager'; import { toBuffer } from './utils'; import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; - import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; @@ -30,7 +29,7 @@ export class ObjectStoreManager implements BinaryData.Manager { } getPath(fileId: string) { - return fileId; // already full path + return fileId; // already full path, no transform needed } async getAsBuffer(fileId: string) { @@ -67,23 +66,20 @@ export class ObjectStoreManager implements BinaryData.Manager { } /** - * Create a copy of a file in the filesystem. Used by Webhook, FTP, SSH nodes. - * - * This delegates to FS manager because the object store manager does not support - * storage and access of user-written data, only execution-written data. + * Copy to object store the temp file written by nodes like Webhook, FTP, and SSH. */ async copyByFilePath( workflowId: string, executionId: string, - filePath: string, + sourceFilePath: string, metadata: BinaryData.PreWriteMetadata, ) { - return Container.get(FileSystemManager).copyByFilePath( - workflowId, - executionId, - filePath, - metadata, - ); + const targetFileId = this.toFileId(workflowId, executionId); + const sourceFile = await fs.readFile(sourceFilePath); + + await this.objectStoreService.put(targetFileId, sourceFile, metadata); + + return { fileId: targetFileId, fileSize: sourceFile.length }; } async deleteMany(ids: BinaryData.IdsForDeletion) { @@ -110,6 +106,8 @@ export class ObjectStoreManager implements BinaryData.Manager { // ---------------------------------- private toFileId(workflowId: string, executionId: string) { + if (!executionId) executionId = 'temp'; // missing in edge case, see PR #7244 + return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index b9e371321a96c..042f29f93f615 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -41,7 +41,7 @@ export namespace BinaryData { copyByFilePath( workflowId: string, executionId: string, - filePath: string, + sourceFilePath: string, metadata: PreWriteMetadata, ): Promise; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b432edc675347..eb9c40111e313 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1024,6 +1024,7 @@ export async function copyBinaryFile( fileName: string, mimeType?: string, ): Promise { + console.log('[x] filePath', filePath); let fileExtension: string | undefined; if (!mimeType) { // If no mime type is given figure it out From 537bb0c346fb08f5771861759d05a5c76136bd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 19:05:12 +0200 Subject: [PATCH 221/259] Remove logging --- packages/core/src/NodeExecuteFunctions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index eb9c40111e313..b432edc675347 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1024,7 +1024,6 @@ export async function copyBinaryFile( fileName: string, mimeType?: string, ): Promise { - console.log('[x] filePath', filePath); let fileExtension: string | undefined; if (!mimeType) { // If no mime type is given figure it out From 63b2db2d3bc1302ed135ea3b1bfae57826e37ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 19:29:54 +0200 Subject: [PATCH 222/259] Adjust `restoreBinaryDataId` for s3 --- .../cli/src/WorkflowExecuteAdditionalData.ts | 2 +- .../restoreBinaryDataId.ts | 32 ++++++++++++------- .../cli/test/unit/execution.lifecycle.test.ts | 4 ++- .../src/BinaryData/ObjectStore.manager.ts | 4 ++- .../src/ObjectStore/ObjectStore.service.ee.ts | 2 +- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 5c94de7f12438..24e342afc5416 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -447,7 +447,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { workflowId: this.workflowData.id, }); - if (this.mode === 'webhook' && config.getEnv('binaryDataManager.mode') === 'filesystem') { + if (this.mode === 'webhook' && config.getEnv('binaryDataManager.mode') !== 'default') { await restoreBinaryDataId(fullRunData, this.executionId); } diff --git a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts index 95682494d5c09..a1a16f4972e4e 100644 --- a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts +++ b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts @@ -2,12 +2,14 @@ import Container from 'typedi'; import { BinaryDataService } from 'n8n-core'; import type { IRun } from 'n8n-workflow'; -export function isMissingExecutionId(binaryDataId: string) { - const UUID_CHAR_LENGTH = 36; - - return [UUID_CHAR_LENGTH + 'filesystem:'.length, UUID_CHAR_LENGTH + 's3:'.length].some( - (incorrectLength) => binaryDataId.length === incorrectLength, - ); +export function isMissingExecutionId( + fileId: string, + mode: 'filesystem' | 's3', + uuidV4CharLength = 36, +) { + return mode === 'filesystem' + ? uuidV4CharLength === fileId.length + : fileId.includes('/executions/temp/'); } /** @@ -19,6 +21,9 @@ export function isMissingExecutionId(binaryDataId: string) { * ```txt * filesystem:11869055-83c4-4493-876a-9092c4708b9b -> * filesystem:39011869055-83c4-4493-876a-9092c4708b9b + * + * s3:workflows/123/executions/temp/binary_data/69055-83c4-4493-876a-9092c4708b9b -> + * s3:workflows/123/executions/390/binary_data/69055-83c4-4493-876a-9092c4708b9b * ``` */ export async function restoreBinaryDataId(run: IRun, executionId: string) { @@ -27,13 +32,18 @@ export async function restoreBinaryDataId(run: IRun, executionId: string) { const promises = Object.keys(runData).map(async (nodeName) => { const binaryDataId = runData[nodeName]?.[0]?.data?.main?.[0]?.[0].binary?.data.id; - if (!binaryDataId || !isMissingExecutionId(binaryDataId)) return; + if (!binaryDataId) return; - const [mode, incorrectFileId] = binaryDataId.split(':'); - const correctFileId = `${executionId}${incorrectFileId}`; - const correctBinaryDataId = `${mode}:${correctFileId}`; + const [mode, fileId] = binaryDataId.split(':') as ['filesystem' | 's3', string]; + + if (!isMissingExecutionId(fileId, mode)) return; - await Container.get(BinaryDataService).rename(incorrectFileId, correctFileId); + const correctFileId = + mode === 'filesystem' ? `${executionId}${fileId}` : fileId.replace('temp', executionId); // s3 + + await Container.get(BinaryDataService).rename(fileId, correctFileId); + + const correctBinaryDataId = `${mode}:${correctFileId}`; // @ts-expect-error Validated at the top run.data.resultData.runData[nodeName][0].data.main[0][0].binary.data.id = correctBinaryDataId; diff --git a/packages/cli/test/unit/execution.lifecycle.test.ts b/packages/cli/test/unit/execution.lifecycle.test.ts index 093bb3d286bd5..2d3790bd5fd87 100644 --- a/packages/cli/test/unit/execution.lifecycle.test.ts +++ b/packages/cli/test/unit/execution.lifecycle.test.ts @@ -2,6 +2,7 @@ import { restoreBinaryDataId } from '@/executionLifecycleHooks/restoreBinaryData import { BinaryDataService } from 'n8n-core'; import { mockInstance } from '../integration/shared/utils/mocking'; import type { IRun } from 'n8n-workflow'; +import config from '@/config'; function toIRun(item: object) { return { @@ -27,10 +28,11 @@ function getDataId(run: IRun, kind: 'binary' | 'json') { return run.data.resultData.runData.myNode[0].data.main[0][0][kind].data.id; } -describe('restoreBinaryDataId()', () => { +describe('restoreBinaryDataId() [filesystem mode]', () => { const binaryDataService = mockInstance(BinaryDataService); beforeEach(() => { + config.set('binaryDataManager.mode', 'filesystem'); jest.clearAllMocks(); }); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index bf078b690bd54..4d12b6f6f4c74 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -97,8 +97,10 @@ export class ObjectStoreManager implements BinaryData.Manager { async rename(oldFileId: string, newFileId: string) { const oldFile = await this.objectStoreService.get(oldFileId, { mode: 'buffer' }); + const oldFileMetadata = await this.objectStoreService.getMetadata(oldFileId); - await this.objectStoreService.put(newFileId, oldFile); + await this.objectStoreService.put(newFileId, oldFile, oldFileMetadata); + await this.objectStoreService.deleteOne(oldFileId); } // ---------------------------------- diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index eeafbb346d37a..5d9e00a9e0844 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -102,7 +102,7 @@ export class ObjectStoreService { 'content-length': string; 'content-type'?: string; 'x-amz-meta-filename'?: string; - } & Record; + } & BinaryData.PreWriteMetadata; }; const response: Response = await this.request('HEAD', this.host, path); From 8dcc0878d6219775be132570c57aa30289e41258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Sep 2023 21:22:11 +0200 Subject: [PATCH 223/259] Write docs --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/core/src/ObjectStore/AWS-S3-SETUP.md diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md new file mode 100644 index 0000000000000..6148563b3efa6 --- /dev/null +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -0,0 +1,73 @@ +# AWS S3 Setup + +n8n can use AWS S3 as an object store for binary data produced by workflow executions. + +Follow these instructions to set up an AWS S3 bucket as n8n's object store. + +## Create a bucket + +1. With your root user, [sign in](https://signin.aws.amazon.com/signin) to the AWS Management Console. + +2. Go to `S3` > `Create Bucket`. Name the bucket and select a region, ideally one close to your instance. Scroll to the bottom and `Create bucket`. Make a note of the bucket name and region. + +## Create a policy for the bucket + +3. Go to `IAM` > `Policies` > `Create Policy`. Select the `JSON` tab and paste the following policy. Replace `` with the name of the bucket you created in step 2. Click on `Next`. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": ["arn:aws:s3:::", "arn:aws:s3:::/*"] + } + ] +} +``` + +4. Name the policy, scroll to the bottom, and `Create policy`. Make a note of the policy name. + +## Create a user and attach the policy to them + +5. Go to `IAM` > `Users` > `Create user`. + +6. Name the user and enable `Provide user with access to the AWS Management Console`, then `I want to create an IAM user` and `Next`. + +7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password and console sign-in link. + +8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to to the `Access key` section and click on `Create access key`. Select `Local code` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. + +## Configure n8n to use S3 + +9. Set these environment variables using the bucket name and region from step 2. + +```sh +export N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME=... +export N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION=... +``` + +Set these environment variables using the credentials from step 8. + +```sh +export N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID=... +export N8N_EXTERNAL_STORAGE_S3_SECRET_KEY=... +``` + +Configure n8n to store binary data in S3. + +```sh +export N8N_DEFAULT_BINARY_DATA_MODE=s3 +export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 +``` + +10. Activate an [Enterprise license key](https://docs.n8n.io/enterprise-key/) for your instance. + +## Usage notes + +- Binary data is written to your n8n S3 bucket in this format: `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` +- To inspect binary data in the n8n S3 bucket, you can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key, or you can also access the S3 section in the AWS Management Console with the details from step 7. +- If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. +- If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and provided that your access key ID and secret access key for S3 remain valid. From 8575f49643fbefa68feba2f33b8a9913e1d9c1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 09:41:15 +0200 Subject: [PATCH 224/259] Write unit tests --- .../core/test/ObjectStore.manager.test.ts | 132 ++++++++++++++++++ packages/core/test/utils.ts | 21 +++ 2 files changed, 153 insertions(+) create mode 100644 packages/core/test/ObjectStore.manager.test.ts create mode 100644 packages/core/test/utils.ts diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/test/ObjectStore.manager.test.ts new file mode 100644 index 0000000000000..a8d0decc9012d --- /dev/null +++ b/packages/core/test/ObjectStore.manager.test.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs/promises'; +import { ObjectStoreManager } from '../src/BinaryData/ObjectStore.manager'; +import { ObjectStoreService } from '..'; +import { mockInstance, toStream } from './utils'; +import { isStream } from '@/ObjectStore/utils'; + +jest.mock('fs/promises'); + +const objectStoreService = mockInstance(ObjectStoreService); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const objectStoreManager = new ObjectStoreManager(objectStoreService); + +const toFileId = (workflowId: string, executionId: string, fileUuid: string): string => + `workflows/${workflowId}/executions/${executionId}/binary_data/${fileUuid}`; + +const workflowId = 'ObogjVbqpNOQpiyV'; +const executionId = '999'; +const fileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb32'; +const fileId = toFileId(workflowId, executionId, fileUuid); + +const otherWorkflowId = 'FHio8ftV6SrCAfPJ'; +const otherExecutionId = '888'; +const otherFileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb33'; +const otherFileId = toFileId(otherWorkflowId, otherExecutionId, otherFileUuid); + +test('store() should store a buffer', async () => { + const buffer = Buffer.from('Test data'); + const metadata = { mimeType: 'text/plain' }; + + const result = await objectStoreManager.store(workflowId, executionId, buffer, metadata); + + const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; + + expect(result.fileId.startsWith(expectedPrefix)).toBe(true); + expect(result.fileSize).toBe(buffer.length); +}); + +test('getPath() should return a path', async () => { + const path = objectStoreManager.getPath(fileId); + + expect(path).toBe(fileId); +}); + +test('getAsBuffer() should return a buffer', async () => { + const fileId = + 'workflows/ObogjVbqpNOQpiyV/executions/123/binary_data/71f6209b-5d48-41a2-a224-80d529d8bb32'; + + // @ts-expect-error Overload signature seemingly causing Jest to misinfer the return type + objectStoreService.get.mockResolvedValueOnce(Buffer.from('Test data')); + + const result = await objectStoreManager.getAsBuffer(fileId); + + expect(Buffer.isBuffer(result)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); +}); + +test('getAsStream() should return a stream', async () => { + objectStoreService.get.mockResolvedValueOnce(toStream(Buffer.from('Test data'))); + + const stream = await objectStoreManager.getAsStream(fileId); + + expect(isStream(stream)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'stream' }); +}); + +test('getMetadata() should return metadata', async () => { + objectStoreService.getMetadata.mockResolvedValue({ + 'content-length': '1', + 'content-type': 'text/plain', + 'x-amz-meta-filename': 'file.txt', + }); + + const metadata = await objectStoreManager.getMetadata(fileId); + + expect(metadata).toEqual(expect.objectContaining({ fileSize: 1 })); + expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); +}); + +test('copyByFileId() should copy by file ID and return the file ID', async () => { + const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId); + + const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; + + expect(targetFileId.startsWith(expectedPrefix)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); +}); + +test('copyByFilePath() should copy by file path and return the file ID and size', async () => { + const sourceFilePath = 'path/to/file/in/filesystem'; + const metadata = { mimeType: 'text/plain' }; + const buffer = Buffer.from('Test file content'); + + fs.readFile = jest.fn().mockResolvedValueOnce(buffer); + + const result = await objectStoreManager.copyByFilePath( + workflowId, + executionId, + sourceFilePath, + metadata, + ); + + const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; + + expect(result.fileId.startsWith(expectedPrefix)).toBe(true); + expect(fs.readFile).toHaveBeenCalledWith(sourceFilePath); + expect(result.fileSize).toBe(buffer.length); +}); + +test('deleteMany() should delete many files by prefix', async () => { + const ids = [ + { workflowId, executionId }, + { workflowId: otherWorkflowId, executionId: otherExecutionId }, + ]; + + const promise = objectStoreManager.deleteMany(ids); + + await expect(promise).resolves.not.toThrow(); + + expect(objectStoreService.deleteMany).toHaveBeenCalledTimes(2); +}); + +test('rename() should rename a file', async () => { + const promise = objectStoreManager.rename(fileId, otherFileId); + + await expect(promise).resolves.not.toThrow(); + + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); + expect(objectStoreService.deleteOne).toHaveBeenCalledWith(fileId); +}); diff --git a/packages/core/test/utils.ts b/packages/core/test/utils.ts new file mode 100644 index 0000000000000..b5eb94465cc0c --- /dev/null +++ b/packages/core/test/utils.ts @@ -0,0 +1,21 @@ +import { Container } from 'typedi'; +import { mock } from 'jest-mock-extended'; +import { Duplex } from 'stream'; +import type { DeepPartial } from 'ts-essentials'; + +export const mockInstance = ( + constructor: new (...args: unknown[]) => T, + data: DeepPartial | undefined = undefined, +) => { + const instance = mock(data); + Container.set(constructor, instance); + return instance; +}; + +export function toStream(buffer: Buffer) { + const d = new Duplex(); + d.push(buffer); + d.push(null); + + return d; +} From a444552e616467c6a17c42aaa59d1a18f979f482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 11:19:51 +0200 Subject: [PATCH 225/259] Typo --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index 6148563b3efa6..a0aaafe235545 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -38,7 +38,7 @@ Follow these instructions to set up an AWS S3 bucket as n8n's object store. 7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password and console sign-in link. -8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to to the `Access key` section and click on `Create access key`. Select `Local code` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. +8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to the `Access key` section and click on `Create access key`. Select `Local code` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. ## Configure n8n to use S3 From 5af3015a0c4e1636c7eaa54b10b72b5fee253b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 11:20:27 +0200 Subject: [PATCH 226/259] Better naming --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index a0aaafe235545..966b7638fcfe7 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -67,7 +67,7 @@ export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 ## Usage notes -- Binary data is written to your n8n S3 bucket in this format: `workflows/{workflowId}/executions/{executionId}/binary_data/{fileId}` +- Binary data is written to your n8n S3 bucket in this format: `workflows/{workflowId}/executions/{executionId}/binary_data/{binaryFileId}` - To inspect binary data in the n8n S3 bucket, you can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key, or you can also access the S3 section in the AWS Management Console with the details from step 7. - If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. - If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and provided that your access key ID and secret access key for S3 remain valid. From 5aeb5417ebfeca5fe2767d0efaee3087ca569a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 11:22:02 +0200 Subject: [PATCH 227/259] Cleanup --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index 966b7638fcfe7..67dbd58e78726 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -2,6 +2,12 @@ n8n can use AWS S3 as an object store for binary data produced by workflow executions. +Binary data is written to your n8n S3 bucket in this format: + +``` +workflows/{workflowId}/executions/{executionId}/binary_data/{binaryFileId} +``` + Follow these instructions to set up an AWS S3 bucket as n8n's object store. ## Create a bucket @@ -67,7 +73,6 @@ export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 ## Usage notes -- Binary data is written to your n8n S3 bucket in this format: `workflows/{workflowId}/executions/{executionId}/binary_data/{binaryFileId}` -- To inspect binary data in the n8n S3 bucket, you can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key, or you can also access the S3 section in the AWS Management Console with the details from step 7. +- To inspect binary data in the n8n S3 bucket, you can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key. You can also access the S3 section in the AWS Management Console with the details from step 7. - If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. -- If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and provided that your access key ID and secret access key for S3 remain valid. +- If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and as long as your S3 credentials remain valid. From d768250e4fcdd54cd6df779f9ef363061e46aaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 11:34:02 +0200 Subject: [PATCH 228/259] Remove `inTest` --- packages/cli/src/commands/BaseCommand.ts | 4 +--- packages/cli/test/integration/commands/worker.cmd.test.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 3d1375a31d178..006a45610903b 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -127,9 +127,7 @@ export abstract class BaseCommand extends Command { } async initObjectStoreService() { - if (inTest) return; // @TODO: Only for worker.cmd.test.ts - - const isSelected = config.get('binaryDataManager.mode') === 's3'; + const isSelected = config.getEnv('binaryDataManager.mode') === 's3'; const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 41b01619885e2..e171864158c09 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -23,6 +23,7 @@ const oclifConfig: Config.IConfig = new Config.Config({ root: __dirname }); beforeAll(async () => { LoggerProxy.init(getLogger()); config.set('executions.mode', 'queue'); + config.set('binaryDataManager.availableModes', 'filesystem'); mockInstance(Telemetry); mockInstance(PostHogClient); mockInstance(InternalHooks); From 37cbdb99fcdd20c641ce4aa34e9f9a78c966cdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 11:43:05 +0200 Subject: [PATCH 229/259] Add TODO --- packages/core/src/BinaryData/ObjectStore.manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 4d12b6f6f4c74..0d08c1b364c94 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -8,7 +8,7 @@ import type { BinaryData } from './types'; @Service() export class ObjectStoreManager implements BinaryData.Manager { - constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} + constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} // @TODO: Fix async init() { await this.objectStoreService.checkConnection(); From d53a2e26c18fc9561941064e4e04b2f946c56e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 12:28:46 +0200 Subject: [PATCH 230/259] Account for license expiring when blocking writes --- packages/cli/src/License.ts | 5 ++++ .../src/ObjectStore/ObjectStore.service.ee.ts | 21 ++++++++++++++--- packages/core/src/ObjectStore/errors.ts | 8 ------- packages/core/src/ObjectStore/utils.ts | 4 ++++ ...re.test.ts => ObjectStore.service.test.ts} | 23 +++++++++++++++++++ 5 files changed, 50 insertions(+), 11 deletions(-) rename packages/core/test/{ObjectStore.test.ts => ObjectStore.service.test.ts} (92%) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 672a8959e18fd..0ef04d68eb921 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -15,6 +15,7 @@ import Container, { Service } from 'typedi'; import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces'; import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher'; import { RedisService } from './services/redis.service'; +import { ObjectStoreService } from 'n8n-core'; type FeatureReturnType = Partial< { @@ -103,6 +104,10 @@ export class License { command: 'reloadLicense', }); } + + const isReadonly = _features['feat:binaryDataS3'] === false; + + Container.get(ObjectStoreService).setReadonly(isReadonly); } async saveCertStr(value: TLicenseBlock): Promise { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 5d9e00a9e0844..e4a31fbeb48cc 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -4,10 +4,11 @@ import { createHash } from 'node:crypto'; import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; -import { isStream, parseXml } from './utils'; +import { isStream, parseXml, writeBlockedMessage } from './utils'; import { ObjectStore } from './errors'; +import { LoggerProxy as Logger } from 'n8n-workflow'; -import type { AxiosRequestConfig, Method } from 'axios'; +import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; import type { Bucket, ListPage, RawListPage, RequestOptions } from './types'; import type { Readable } from 'stream'; @@ -23,6 +24,8 @@ export class ObjectStoreService { private isReadOnly = false; + private logger = Logger; + async init( bucket: { region: string; name: string }, credentials: { accountId: string; secretKey: string }, @@ -44,6 +47,10 @@ export class ObjectStoreService { return `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; } + setReadonly(newState: boolean) { + this.isReadOnly = newState; + } + /** * Confirm that the configured bucket exists and the caller has permission to access it. * @@ -59,7 +66,7 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html */ async put(filename: string, buffer: Buffer, metadata: BinaryData.PreWriteMetadata = {}) { - if (this.isReadOnly) throw new ObjectStore.WriteBlockedError(filename); + if (this.isReadOnly) return this.blockWrite(filename); const headers: Record = { 'Content-Length': buffer.length, @@ -208,6 +215,14 @@ export class ObjectStoreService { return path.concat(`?${qsParams}`); } + private async blockWrite(filename: string): Promise { + const logMessage = writeBlockedMessage(filename); + + this.logger.warn(logMessage); + + return { status: 403, statusText: 'Forbidden', data: logMessage, headers: {}, config: {} }; + } + private async request( method: Method, host: string, diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts index 1e1b69b9c3f1d..34b28a453480d 100644 --- a/packages/core/src/ObjectStore/errors.ts +++ b/packages/core/src/ObjectStore/errors.ts @@ -9,12 +9,4 @@ export namespace ObjectStore { this.cause = { error, details }; } } - - export class WriteBlockedError extends Error { - constructor(filename: string) { - super( - `Request to write file "${filename}" to object storage was blocked. This is likely because storing binary data in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than "s3".`, - ); - } - } } diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts index 76dcb1f076b1d..34fa080c2135b 100644 --- a/packages/core/src/ObjectStore/utils.ts +++ b/packages/core/src/ObjectStore/utils.ts @@ -14,3 +14,7 @@ export async function parseXml(xml: string): Promise { valueProcessors: [parseNumbers, parseBooleans], }) as Promise; } + +export function writeBlockedMessage(filename: string) { + return `BLOCKED: Request to write file "${filename}" to object storage failed. This request was blocked because your current binary data mode is "s3" but storing binary data in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than "s3".`; +} diff --git a/packages/core/test/ObjectStore.test.ts b/packages/core/test/ObjectStore.service.test.ts similarity index 92% rename from packages/core/test/ObjectStore.test.ts rename to packages/core/test/ObjectStore.service.test.ts index 453ec6c0eebb5..4101727587b80 100644 --- a/packages/core/test/ObjectStore.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -1,6 +1,8 @@ import axios from 'axios'; import { ObjectStoreService } from '../src/ObjectStore/ObjectStore.service.ee'; import { Readable } from 'stream'; +import { writeBlockedMessage } from '@/ObjectStore/utils'; +import { initLogger } from './helpers/utils'; jest.mock('axios'); @@ -112,6 +114,27 @@ describe('ObjectStoreService', () => { ); }); + it('should block without erroring if read-only', async () => { + initLogger(); + objectStoreService.setReadonly(true); + + const path = 'file.txt'; + const buffer = Buffer.from('Test content'); + const metadata = { fileName: path, mimeType: 'text/plain' }; + + const promise = objectStoreService.put(path, buffer, metadata); + + await expect(promise).resolves.not.toThrow(); + + const result = await promise; + + const blockedMessage = writeBlockedMessage(path); + + expect(result.status).toBe(403); + expect(result.statusText).toBe('Forbidden'); + expect(result.data).toBe(blockedMessage); + }); + it('should throw an error on request failure', async () => { const path = 'file.txt'; const buffer = Buffer.from('Test content'); From b6363c8973f696d5191c484c08c4e326e7f689c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 12:53:58 +0200 Subject: [PATCH 231/259] Clarifications --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index 67dbd58e78726..18287e224307c 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -73,6 +73,9 @@ export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 ## Usage notes -- To inspect binary data in the n8n S3 bucket, you can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key. You can also access the S3 section in the AWS Management Console with the details from step 7. +- To inspect binary data in the n8n S3 bucket... + - You can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key. + - You can also access the S3 section in the AWS Management Console with the details from step 7. + - Or you can query the AWS S3 API using n8n's AWS S3 node. - If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. - If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and as long as your S3 credentials remain valid. From da4c1183aa29fa113da1d43b0ab42d3d03018f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 14:54:52 +0200 Subject: [PATCH 232/259] Update setting on doc --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index 18287e224307c..a383eb95cf581 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -44,7 +44,7 @@ Follow these instructions to set up an AWS S3 bucket as n8n's object store. 7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password and console sign-in link. -8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to the `Access key` section and click on `Create access key`. Select `Local code` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. +8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to the `Access key` section and click on `Create access key`. Select `Third-party service` or `Application running outside AWS` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. ## Configure n8n to use S3 From 5873b9354dc6c44a5d5bb38ac4d1d935e649c021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 15:22:30 +0200 Subject: [PATCH 233/259] Parameterize host --- packages/cli/src/commands/BaseCommand.ts | 4 +++- packages/cli/src/config/schema.ts | 7 ++++++- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 1 + .../src/ObjectStore/ObjectStore.service.ee.ts | 17 +++++++++++------ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 006a45610903b..e9150e5f86670 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -169,7 +169,9 @@ export abstract class BaseCommand extends Command { secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), }; - await objectStoreService.init(bucket, credentials, options); + const host = config.getEnv('externalStorage.s3.host'); + + await objectStoreService.init(host, bucket, credentials, options); } async initBinaryDataService() { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 5f9c3afcca29d..b6d5cda24754e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -916,7 +916,12 @@ export const schema = { externalStorage: { s3: { - // @TODO: service name + host: { + format: String, + default: '', + env: 'N8N_EXTERNAL_STORAGE_S3_HOST', + doc: 'Endpoint of the n8n bucket in S3-compatible external storage, e.g. `my-bucket.s3.us-east-1.amazonaws.com`', + }, bucket: { name: { format: String, diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index a383eb95cf581..c380dd780f10c 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -65,6 +65,7 @@ export N8N_EXTERNAL_STORAGE_S3_SECRET_KEY=... Configure n8n to store binary data in S3. ```sh +export N8N_EXTERNAL_STORAGE_S3_HOST=... export N8N_DEFAULT_BINARY_DATA_MODE=s3 export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 ``` diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index e4a31fbeb48cc..e4ff85fcd3183 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -18,20 +18,25 @@ import type { BinaryData } from '..'; @Service() export class ObjectStoreService { - private credentials: Aws4Credentials = { accessKeyId: '', secretAccessKey: '' }; + private host = ''; private bucket: Bucket = { region: '', name: '' }; + private credentials: Aws4Credentials = { accessKeyId: '', secretAccessKey: '' }; + private isReadOnly = false; private logger = Logger; async init( - bucket: { region: string; name: string }, + host: string, + bucket: Bucket, credentials: { accountId: string; secretKey: string }, options?: { isReadOnly: boolean }, ) { - this.bucket = bucket; + this.host = host; + this.bucket.name = bucket.name; + this.bucket.region = bucket.region; this.credentials = { accessKeyId: credentials.accountId, @@ -43,9 +48,9 @@ export class ObjectStoreService { await this.checkConnection(); } - get host() { - return `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; - } + // get host() { + // return `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; // @TODO: Get it from envs + // } setReadonly(newState: boolean) { this.isReadOnly = newState; From 9c48a7b12f848d66b42859479dcdc6c9d09688b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 16:40:14 +0200 Subject: [PATCH 234/259] Switch pattern from bucket before host to bucket in path --- packages/cli/src/config/schema.ts | 2 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 38 +++++++------- packages/core/src/ObjectStore/errors.ts | 8 ++- .../core/test/ObjectStore.service.test.ts | 49 ++++++++++--------- 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index b6d5cda24754e..3b802b2858b7e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -920,7 +920,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_HOST', - doc: 'Endpoint of the n8n bucket in S3-compatible external storage, e.g. `my-bucket.s3.us-east-1.amazonaws.com`', + doc: 'Host of the n8n bucket in S3-compatible external storage, e.g. `s3.us-east-1.amazonaws.com` or `s3.us-east-005.backblazeb2.com`', }, bucket: { name: { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index e4ff85fcd3183..95cb41960cef4 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -14,8 +14,6 @@ import type { Bucket, ListPage, RawListPage, RequestOptions } from './types'; import type { Readable } from 'stream'; import type { BinaryData } from '..'; -// @TODO: Decouple host from AWS - @Service() export class ObjectStoreService { private host = ''; @@ -48,10 +46,6 @@ export class ObjectStoreService { await this.checkConnection(); } - // get host() { - // return `${this.bucket.name}.s3.${this.bucket.region}.amazonaws.com`; // @TODO: Get it from envs - // } - setReadonly(newState: boolean) { this.isReadOnly = newState; } @@ -62,7 +56,7 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html */ async checkConnection() { - return this.request('HEAD', this.host); + return this.request('HEAD', this.host, this.bucket.name); } /** @@ -81,7 +75,9 @@ export class ObjectStoreService { if (metadata.fileName) headers['x-amz-meta-filename'] = metadata.fileName; if (metadata.mimeType) headers['Content-Type'] = metadata.mimeType; - return this.request('PUT', this.host, `/${filename}`, { headers, body: buffer }); + const path = `/${this.bucket.name}/${filename}`; + + return this.request('PUT', this.host, path, { headers, body: buffer }); } /** @@ -89,9 +85,11 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ - async get(path: string, { mode }: { mode: 'buffer' }): Promise; - async get(path: string, { mode }: { mode: 'stream' }): Promise; - async get(path: string, { mode }: { mode: 'stream' | 'buffer' }) { + async get(fileId: string, { mode }: { mode: 'buffer' }): Promise; + async get(fileId: string, { mode }: { mode: 'stream' }): Promise; + async get(fileId: string, { mode }: { mode: 'stream' | 'buffer' }) { + const path = `${this.bucket.name}/${fileId}`; + const { data } = await this.request('GET', this.host, path, { responseType: mode === 'buffer' ? 'arraybuffer' : 'stream', }); @@ -108,7 +106,7 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html */ - async getMetadata(path: string) { + async getMetadata(fileId: string) { type Response = { headers: { 'content-length': string; @@ -117,6 +115,8 @@ export class ObjectStoreService { } & BinaryData.PreWriteMetadata; }; + const path = `${this.bucket.name}/${fileId}`; + const response: Response = await this.request('HEAD', this.host, path); return response.headers; @@ -127,8 +127,10 @@ export class ObjectStoreService { * * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html */ - async deleteOne(path: string) { - return this.request('DELETE', this.host, `/${encodeURIComponent(path)}`); + async deleteOne(fileId: string) { + const path = `${this.bucket.name}/${fileId}`; + + return this.request('DELETE', this.host, path); } /** @@ -149,7 +151,9 @@ export class ObjectStoreService { 'Content-MD5': createHash('md5').update(body).digest('base64'), }; - return this.request('POST', this.host, '/?delete', { headers, body }); + const path = `${this.bucket.name}/?delete`; + + return this.request('POST', this.host, path, { headers, body }); } /** @@ -179,13 +183,11 @@ export class ObjectStoreService { * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. */ async getListPage(prefix: string, nextPageToken?: string) { - const bucketlessHost = this.host.split('.').slice(1).join('.'); - const qs: Record = { 'list-type': 2, prefix }; if (nextPageToken) qs['continuation-token'] = nextPageToken; - const { data } = await this.request('GET', bucketlessHost, `/${this.bucket.name}`, { qs }); + const { data } = await this.request('GET', this.host, this.bucket.name, { qs }); if (typeof data !== 'string') { throw new TypeError(`Expected XML string but received ${typeof data}`); diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts index 34b28a453480d..f77de37a19f09 100644 --- a/packages/core/src/ObjectStore/errors.ts +++ b/packages/core/src/ObjectStore/errors.ts @@ -2,11 +2,9 @@ import { AxiosRequestConfig } from 'axios'; export namespace ObjectStore { export class RequestFailedError extends Error { - message = 'Request to external object storage failed'; - - constructor(error: unknown, details: AxiosRequestConfig) { - super(); - this.cause = { error, details }; + constructor(error: unknown, config: AxiosRequestConfig) { + super(`Request to external object storage failed. Config: ${JSON.stringify(config)}`); + this.cause = { error }; } } } diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index 4101727587b80..4cc7d1988e468 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -9,9 +9,10 @@ jest.mock('axios'); const mockAxios = axios as jest.Mocked; const MOCK_BUCKET = { region: 'us-east-1', name: 'test-bucket' }; +const MOCK_HOST = `s3.${MOCK_BUCKET.region}.amazonaws.com`; const MOCK_CREDENTIALS = { accountId: 'mock-account-id', secretKey: 'mock-secret-key' }; +const MOCK_URL = `https://${MOCK_HOST}/${MOCK_BUCKET.name}`; const FAILED_REQUEST_ERROR_MESSAGE = 'Request to external object storage failed'; -const EXPECTED_HOST = `${MOCK_BUCKET.name}.s3.${MOCK_BUCKET.region}.amazonaws.com`; const MOCK_S3_ERROR = new Error('Something went wrong!'); const toMultipleDeletionXml = (filename: string) => ` @@ -24,7 +25,7 @@ describe('ObjectStoreService', () => { beforeEach(async () => { objectStoreService = new ObjectStoreService(); mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection - await objectStoreService.init(MOCK_BUCKET, MOCK_CREDENTIALS); + await objectStoreService.init(MOCK_HOST, MOCK_BUCKET, MOCK_CREDENTIALS); jest.restoreAllMocks(); }); @@ -37,7 +38,7 @@ describe('ObjectStoreService', () => { expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'HEAD', - url: `https://${EXPECTED_HOST}/`, + url: `https://${MOCK_HOST}/${MOCK_BUCKET.name}`, headers: expect.objectContaining({ 'X-Amz-Content-Sha256': expect.any(String), 'X-Amz-Date': expect.any(String), @@ -58,18 +59,18 @@ describe('ObjectStoreService', () => { describe('getMetadata()', () => { it('should send a HEAD request to the correct host and path', async () => { - const path = 'file.txt'; + const fileId = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 200 }); - await objectStoreService.getMetadata(path); + await objectStoreService.getMetadata(fileId); expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'HEAD', - url: `https://${EXPECTED_HOST}/${path}`, + url: `${MOCK_URL}/${fileId}`, headers: expect.objectContaining({ - Host: EXPECTED_HOST, + Host: MOCK_HOST, 'X-Amz-Content-Sha256': expect.any(String), 'X-Amz-Date': expect.any(String), Authorization: expect.any(String), @@ -79,11 +80,11 @@ describe('ObjectStoreService', () => { }); it('should throw an error on request failure', async () => { - const path = 'file.txt'; + const fileId = 'file.txt'; mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); - const promise = objectStoreService.getMetadata(path); + const promise = objectStoreService.getMetadata(fileId); await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); @@ -91,18 +92,18 @@ describe('ObjectStoreService', () => { describe('put()', () => { it('should send a PUT request to upload an object', async () => { - const path = 'file.txt'; + const fileId = 'file.txt'; const buffer = Buffer.from('Test content'); - const metadata = { fileName: path, mimeType: 'text/plain' }; + const metadata = { fileName: fileId, mimeType: 'text/plain' }; mockAxios.request.mockResolvedValue({ status: 200 }); - await objectStoreService.put(path, buffer, metadata); + await objectStoreService.put(fileId, buffer, metadata); expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'PUT', - url: `https://${EXPECTED_HOST}/${path}`, + url: `${MOCK_URL}/${fileId}`, headers: expect.objectContaining({ 'Content-Length': buffer.length, 'Content-MD5': expect.any(String), @@ -150,16 +151,16 @@ describe('ObjectStoreService', () => { describe('get()', () => { it('should send a GET request to download an object as a buffer', async () => { - const path = 'file.txt'; + const fileId = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 200, data: Buffer.from('Test content') }); - const result = await objectStoreService.get(path, { mode: 'buffer' }); + const result = await objectStoreService.get(fileId, { mode: 'buffer' }); expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', - url: `https://${EXPECTED_HOST}/${path}`, + url: `${MOCK_URL}/${fileId}`, responseType: 'arraybuffer', }), ); @@ -168,16 +169,16 @@ describe('ObjectStoreService', () => { }); it('should send a GET request to download an object as a stream', async () => { - const path = 'file.txt'; + const fileId = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 200, data: new Readable() }); - const result = await objectStoreService.get(path, { mode: 'stream' }); + const result = await objectStoreService.get(fileId, { mode: 'stream' }); expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', - url: `https://${EXPECTED_HOST}/${path}`, + url: `${MOCK_URL}/${fileId}`, responseType: 'stream', }), ); @@ -197,17 +198,17 @@ describe('ObjectStoreService', () => { }); describe('deleteOne()', () => { - it('should send a DELETE request to delete an object', async () => { - const path = 'file.txt'; + it('should send a DELETE request to delete a single object', async () => { + const fileId = 'file.txt'; mockAxios.request.mockResolvedValue({ status: 204 }); - await objectStoreService.deleteOne(path); + await objectStoreService.deleteOne(fileId); expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'DELETE', - url: `https://${EXPECTED_HOST}/${path}`, + url: `${MOCK_URL}/${fileId}`, }), ); }); @@ -247,7 +248,7 @@ describe('ObjectStoreService', () => { expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', - url: `https://${EXPECTED_HOST}/?delete`, + url: `${MOCK_URL}/?delete`, headers: expect.objectContaining({ 'Content-Type': 'application/xml', 'Content-Length': expect.any(Number), From a2f67ddc378fe0495e1ae5c5a76bfc59347ffbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:35:39 +0200 Subject: [PATCH 235/259] Cleanup --- packages/core/src/ObjectStore/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts index f77de37a19f09..46a2db8d76343 100644 --- a/packages/core/src/ObjectStore/errors.ts +++ b/packages/core/src/ObjectStore/errors.ts @@ -3,8 +3,8 @@ import { AxiosRequestConfig } from 'axios'; export namespace ObjectStore { export class RequestFailedError extends Error { constructor(error: unknown, config: AxiosRequestConfig) { - super(`Request to external object storage failed. Config: ${JSON.stringify(config)}`); - this.cause = { error }; + super('Request to external object storage failed.'); + this.cause = { error, details: config }; } } } From 5b27a9b2a6d083c7a3dda54f335530baed8d48cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:38:57 +0200 Subject: [PATCH 236/259] Add example --- packages/cli/src/config/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 3b802b2858b7e..f5b87d8c7bfe9 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -920,7 +920,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_HOST', - doc: 'Host of the n8n bucket in S3-compatible external storage, e.g. `s3.us-east-1.amazonaws.com` or `s3.us-east-005.backblazeb2.com`', + doc: 'Host of the n8n bucket in S3-compatible external storage, e.g. `s3.us-east-1.amazonaws.com` or `s3.us-east-005.backblazeb2.com` or `dde5a538e0ffa1b202a3ddf2f20022b0.r2.cloudflarestorage.com`', }, bucket: { name: { From 9c5d2be3cf4e9269f08106d819e3ce3e3d51e782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:39:18 +0200 Subject: [PATCH 237/259] Avoid checking connection twice --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 95cb41960cef4..70c47b6a318aa 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -22,6 +22,8 @@ export class ObjectStoreService { private credentials: Aws4Credentials = { accessKeyId: '', secretAccessKey: '' }; + private isReady = false; + private isReadOnly = false; private logger = Logger; @@ -44,6 +46,8 @@ export class ObjectStoreService { if (options?.isReadOnly) this.isReadOnly = true; await this.checkConnection(); + + this.isReady = true; } setReadonly(newState: boolean) { @@ -56,6 +60,8 @@ export class ObjectStoreService { * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html */ async checkConnection() { + if (this.isReady) return; + return this.request('HEAD', this.host, this.bucket.name); } @@ -261,6 +267,7 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { + console.log(config); return await axios.request(config); } catch (error) { throw new ObjectStore.RequestFailedError(error, config); From 75d66f9d714e16ce24d865c25c1ff341d87483bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:39:56 +0200 Subject: [PATCH 238/259] Remove logging --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 70c47b6a318aa..bbf3282d05651 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -267,7 +267,6 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { - console.log(config); return await axios.request(config); } catch (error) { throw new ObjectStore.RequestFailedError(error, config); From c1e8fdf502723e41cf3100a0c469605c3e5f307f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:42:04 +0200 Subject: [PATCH 239/259] Fix tests --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 6 +++++- packages/core/test/ObjectStore.service.test.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index bbf3282d05651..3a5157cdc27fc 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -47,13 +47,17 @@ export class ObjectStoreService { await this.checkConnection(); - this.isReady = true; + this.setReady(true); } setReadonly(newState: boolean) { this.isReadOnly = newState; } + setReady(newState: boolean) { + this.isReady = newState; + } + /** * Confirm that the configured bucket exists and the caller has permission to access it. * diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index 4cc7d1988e468..ce4f5362cafec 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -33,6 +33,8 @@ describe('ObjectStoreService', () => { it('should send a HEAD request to the correct host', async () => { mockAxios.request.mockResolvedValue({ status: 200 }); + objectStoreService.setReady(false); + await objectStoreService.checkConnection(); expect(mockAxios.request).toHaveBeenCalledWith( @@ -49,6 +51,8 @@ describe('ObjectStoreService', () => { }); it('should throw an error on request failure', async () => { + objectStoreService.setReady(false); + mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); const promise = objectStoreService.checkConnection(); From 889c8d3a6fefea98b0e27f2144a9c860a0d2b091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 17:46:30 +0200 Subject: [PATCH 240/259] Add logging --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 3a5157cdc27fc..30016b71b5212 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -271,6 +271,7 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { + this.logger.debug(`[S3] Sending ${method} request to ${host}${path}`, { config }); return await axios.request(config); } catch (error) { throw new ObjectStore.RequestFailedError(error, config); From 05cea35a9a44e75fd30e3d123ad35df51d0fe517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 18:10:49 +0200 Subject: [PATCH 241/259] Remove logging --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 30016b71b5212..3a5157cdc27fc 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -271,7 +271,6 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { - this.logger.debug(`[S3] Sending ${method} request to ${host}${path}`, { config }); return await axios.request(config); } catch (error) { throw new ObjectStore.RequestFailedError(error, config); From 9b61f853d50f0bae0468738bdeb7f4c3bf513779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 27 Sep 2023 18:55:39 +0200 Subject: [PATCH 242/259] Fix tests --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 1 + packages/core/test/ObjectStore.service.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 3a5157cdc27fc..84fcdcf50df33 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -271,6 +271,7 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { + this.logger.debug(`[Object Store service] Sending request to ${host}${path}`, { config }); return await axios.request(config); } catch (error) { throw new ObjectStore.RequestFailedError(error, config); diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index ce4f5362cafec..53dc01b3991ff 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -15,12 +15,13 @@ const MOCK_URL = `https://${MOCK_HOST}/${MOCK_BUCKET.name}`; const FAILED_REQUEST_ERROR_MESSAGE = 'Request to external object storage failed'; const MOCK_S3_ERROR = new Error('Something went wrong!'); -const toMultipleDeletionXml = (filename: string) => ` +const toDeletionXml = (filename: string) => ` ${filename} `; describe('ObjectStoreService', () => { let objectStoreService: ObjectStoreService; + initLogger(); beforeEach(async () => { objectStoreService = new ObjectStoreService(); @@ -258,7 +259,7 @@ describe('ObjectStoreService', () => { 'Content-Length': expect.any(Number), 'Content-MD5': expect.any(String), }), - data: toMultipleDeletionXml(fileName), + data: toDeletionXml(fileName), }), ); }); From bc90500b3a52b9d6190dfe95240b7e90fac87ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 09:25:43 +0200 Subject: [PATCH 243/259] Cleanup --- packages/cli/src/commands/BaseCommand.ts | 4 +- packages/cli/src/config/schema.ts | 2 +- .../restoreBinaryDataId.ts | 11 +- packages/cli/src/requests.ts | 1 + .../test/integration/shared/utils/index.ts | 2 +- .../core/src/BinaryData/BinaryData.service.ts | 7 +- .../core/src/BinaryData/FileSystem.manager.ts | 87 ++-- .../src/BinaryData/ObjectStore.manager.ts | 9 +- packages/core/src/BinaryData/types.ts | 10 +- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 14 +- .../src/ObjectStore/ObjectStore.service.ee.ts | 18 +- packages/core/src/ObjectStore/errors.ts | 10 - packages/core/src/ObjectStore/utils.ts | 2 +- packages/core/src/index.ts | 1 + .../core/test/ObjectStore.manager.test.ts | 167 ++++--- .../core/test/ObjectStore.service.test.ts | 463 +++++++++--------- packages/core/test/utils.ts | 9 +- 17 files changed, 408 insertions(+), 409 deletions(-) delete mode 100644 packages/core/src/ObjectStore/errors.ts diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index e9150e5f86670..8e1072cdaa07b 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -159,6 +159,8 @@ export abstract class BaseCommand extends Command { private async _initObjectStoreService(options = { isReadOnly: false }) { const objectStoreService = Container.get(ObjectStoreService); + const host = config.getEnv('externalStorage.s3.host'); + const bucket = { name: config.getEnv('externalStorage.s3.bucket.name'), region: config.getEnv('externalStorage.s3.bucket.region'), @@ -169,8 +171,6 @@ export abstract class BaseCommand extends Command { secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), }; - const host = config.getEnv('externalStorage.s3.host'); - await objectStoreService.init(host, bucket, credentials, options); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index f5b87d8c7bfe9..bd370f1fbda0b 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -920,7 +920,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_HOST', - doc: 'Host of the n8n bucket in S3-compatible external storage, e.g. `s3.us-east-1.amazonaws.com` or `s3.us-east-005.backblazeb2.com` or `dde5a538e0ffa1b202a3ddf2f20022b0.r2.cloudflarestorage.com`', + doc: 'Host of the n8n bucket in S3-compatible external storage', }, bucket: { name: { diff --git a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts index a1a16f4972e4e..16a367407d414 100644 --- a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts +++ b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts @@ -1,15 +1,14 @@ import Container from 'typedi'; import { BinaryDataService } from 'n8n-core'; import type { IRun } from 'n8n-workflow'; +import type { BinaryData } from 'n8n-core'; export function isMissingExecutionId( fileId: string, - mode: 'filesystem' | 's3', + mode: BinaryData.NonDefaultMode, uuidV4CharLength = 36, ) { - return mode === 'filesystem' - ? uuidV4CharLength === fileId.length - : fileId.includes('/executions/temp/'); + return mode === 'filesystem' ? uuidV4CharLength === fileId.length : fileId.includes('/temp/'); } /** @@ -34,12 +33,12 @@ export async function restoreBinaryDataId(run: IRun, executionId: string) { if (!binaryDataId) return; - const [mode, fileId] = binaryDataId.split(':') as ['filesystem' | 's3', string]; + const [mode, fileId] = binaryDataId.split(':') as [BinaryData.NonDefaultMode, string]; if (!isMissingExecutionId(fileId, mode)) return; const correctFileId = - mode === 'filesystem' ? `${executionId}${fileId}` : fileId.replace('temp', executionId); // s3 + mode === 'filesystem' ? `${executionId}${fileId}` : fileId.replace('temp', executionId); await Container.get(BinaryDataService).rename(fileId, correctFileId); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 25eea3770a18a..76495f0dffe00 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,6 +1,7 @@ import type express from 'express'; import type { BannerName, + IBinaryData, IConnections, ICredentialDataDecryptedObject, ICredentialNodeAccess, diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 9cdd4a8b5c1d2..cd5d215f60b84 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -77,7 +77,7 @@ export async function initNodeTypes() { export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { const binaryDataService = new BinaryDataService(); await binaryDataService.init({ - mode: 'default', + mode, availableModes: [mode], localStoragePath: '', }); diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 9e4e510410394..cb55ef14aea5d 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { readFile, stat } from 'fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; import { Service } from 'typedi'; import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; - import { UnknownBinaryDataManagerError, InvalidBinaryDataModeError } from './errors'; import { LogCatch } from '../decorators/LogCatch.decorator'; import { areValidModes, toBuffer } from './utils'; @@ -20,7 +19,9 @@ export class BinaryDataService { private managers: Record = {}; async init(config: BinaryData.Config) { - if (!areValidModes(config.availableModes)) throw new InvalidBinaryDataModeError(); + if (!areValidModes(config.availableModes)) { + throw new InvalidBinaryDataModeError(); + } this.mode = config.mode; diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index 088b664975c47..9fa6688d678fa 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -1,18 +1,10 @@ -/** - * @tech_debt The `workflowId` arguments on write are for compatibility with the - * `BinaryData.Manager` interface. Unused in filesystem mode until we refactor - * how we store binary data files in the `/binaryData` dir. - */ - -import { createReadStream } from 'fs'; -import fs from 'fs/promises'; -import path from 'path'; +import { createReadStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { v4 as uuid } from 'uuid'; import { jsonParse } from 'n8n-workflow'; -import { rename } from 'node:fs/promises'; - -import { FileNotFoundError } from '../errors'; import { ensureDirExists } from './utils'; +import { FileNotFoundError } from '../errors'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -27,18 +19,36 @@ export class FileSystemManager implements BinaryData.Manager { await ensureDirExists(this.storagePath); } + async store( + workflowId: string, + executionId: string, + bufferOrStream: Buffer | Readable, + { mimeType, fileName }: BinaryData.PreWriteMetadata, + ) { + const fileId = this.toFileId(workflowId, executionId); + const filePath = this.resolvePath(fileId); + + await fs.writeFile(filePath, bufferOrStream); + + const fileSize = await this.getSize(fileId); + + await this.storeMetadata(fileId, { mimeType, fileName, fileSize }); + + return { fileId, fileSize }; + } + getPath(fileId: string) { return this.resolvePath(fileId); } async getAsStream(fileId: string, chunkSize?: number) { - const filePath = this.getPath(fileId); + const filePath = this.resolvePath(fileId); return createReadStream(filePath, { highWaterMark: chunkSize }); } async getAsBuffer(fileId: string) { - const filePath = this.getPath(fileId); + const filePath = this.resolvePath(fileId); try { return await fs.readFile(filePath); @@ -53,24 +63,6 @@ export class FileSystemManager implements BinaryData.Manager { return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } - async store( - _workflowId: string, - executionId: string, - bufferOrStream: Buffer | Readable, - { mimeType, fileName }: BinaryData.PreWriteMetadata, - ) { - const fileId = this.toFileId(executionId); - const filePath = this.getPath(fileId); - - await fs.writeFile(filePath, bufferOrStream); - - const fileSize = await this.getSize(fileId); - - await this.storeMetadata(fileId, { mimeType, fileName, fileSize }); - - return { fileId, fileSize }; - } - async deleteMany(ids: BinaryData.IdsForDeletion) { const executionIds = ids.map((o) => o.executionId); @@ -89,15 +81,15 @@ export class FileSystemManager implements BinaryData.Manager { } async copyByFilePath( - _workflowId: string, + workflowId: string, executionId: string, - sourceFilePath: string, + sourcePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const targetFileId = this.toFileId(executionId); - const targetFilePath = this.getPath(targetFileId); + const targetFileId = this.toFileId(workflowId, executionId); + const targetPath = this.resolvePath(targetFileId); - await fs.cp(sourceFilePath, targetFilePath); + await fs.cp(sourcePath, targetPath); const fileSize = await this.getSize(targetFileId); @@ -106,8 +98,8 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId: targetFileId, fileSize }; } - async copyByFileId(_workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(executionId); + async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { + const targetFileId = this.toFileId(workflowId, executionId); const sourcePath = this.resolvePath(sourceFileId); const targetPath = this.resolvePath(targetFileId); @@ -117,12 +109,12 @@ export class FileSystemManager implements BinaryData.Manager { } async rename(oldFileId: string, newFileId: string) { - const oldPath = this.getPath(oldFileId); - const newPath = this.getPath(newFileId); + const oldPath = this.resolvePath(oldFileId); + const newPath = this.resolvePath(newFileId); await Promise.all([ - rename(oldPath, newPath), - rename(`${oldPath}.metadata`, `${newPath}.metadata`), + fs.rename(oldPath, newPath), + fs.rename(`${oldPath}.metadata`, `${newPath}.metadata`), ]); } @@ -130,7 +122,12 @@ export class FileSystemManager implements BinaryData.Manager { // private methods // ---------------------------------- - private toFileId(executionId: string) { + /** + * @tech_debt The `workflowId` argument is for compatibility with the + * `BinaryData.Manager` interface. Unused here until we refactor + * how we store binary data files in the `/binaryData` dir. + */ + private toFileId(_workflowId: string, executionId: string) { return [executionId, uuid()].join(''); } @@ -151,7 +148,7 @@ export class FileSystemManager implements BinaryData.Manager { } private async getSize(fileId: string) { - const filePath = this.getPath(fileId); + const filePath = this.resolvePath(fileId); try { const stats = await fs.stat(filePath); diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 0d08c1b364c94..8522d8f1b3bf9 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -3,12 +3,13 @@ import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { toBuffer } from './utils'; import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; + import type { Readable } from 'node:stream'; import type { BinaryData } from './types'; @Service() export class ObjectStoreManager implements BinaryData.Manager { - constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} // @TODO: Fix + constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} async init() { await this.objectStoreService.checkConnection(); @@ -71,11 +72,11 @@ export class ObjectStoreManager implements BinaryData.Manager { async copyByFilePath( workflowId: string, executionId: string, - sourceFilePath: string, + sourcePath: string, metadata: BinaryData.PreWriteMetadata, ) { const targetFileId = this.toFileId(workflowId, executionId); - const sourceFile = await fs.readFile(sourceFilePath); + const sourceFile = await fs.readFile(sourcePath); await this.objectStoreService.put(targetFileId, sourceFile, metadata); @@ -108,7 +109,7 @@ export class ObjectStoreManager implements BinaryData.Manager { // ---------------------------------- private toFileId(workflowId: string, executionId: string) { - if (!executionId) executionId = 'temp'; // missing in edge case, see PR #7244 + if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244 return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; } diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 042f29f93f615..84c38401ee0d2 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -4,8 +4,10 @@ import type { BINARY_DATA_MODES } from './utils'; export namespace BinaryData { export type Mode = (typeof BINARY_DATA_MODES)[number]; + export type NonDefaultMode = Exclude; + export type Config = { - mode: 'default' | 'filesystem'; + mode: Mode; availableModes: string[]; localStoragePath: string; }; @@ -37,16 +39,16 @@ export namespace BinaryData { getAsStream(fileId: string, chunkSize?: number): Promise; getMetadata(fileId: string): Promise; + deleteMany(ids: IdsForDeletion): Promise; + copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise; copyByFilePath( workflowId: string, executionId: string, - sourceFilePath: string, + sourcePath: string, metadata: PreWriteMetadata, ): Promise; - deleteMany(ids: IdsForDeletion): Promise; - rename(oldFileId: string, newFileId: string): Promise; } } diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index c380dd780f10c..9ef425fefb126 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -42,7 +42,7 @@ Follow these instructions to set up an AWS S3 bucket as n8n's object store. 6. Name the user and enable `Provide user with access to the AWS Management Console`, then `I want to create an IAM user` and `Next`. -7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password and console sign-in link. +7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password, and console sign-in link. 8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to the `Access key` section and click on `Create access key`. Select `Third-party service` or `Application running outside AWS` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. @@ -65,11 +65,16 @@ export N8N_EXTERNAL_STORAGE_S3_SECRET_KEY=... Configure n8n to store binary data in S3. ```sh -export N8N_EXTERNAL_STORAGE_S3_HOST=... export N8N_DEFAULT_BINARY_DATA_MODE=s3 -export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 +export N8N_EXTERNAL_STORAGE_S3_HOST=... ``` +Examples of valid hosts: + +- `s3.us-east-1.amazonaws.com` +- `s3.us-east-005.backblazeb2.com` +- `dde5a538e0ffa1b202a3ddf2f20022b0.r2.cloudflarestorage.com` + 10. Activate an [Enterprise license key](https://docs.n8n.io/enterprise-key/) for your instance. ## Usage notes @@ -77,6 +82,7 @@ export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 - To inspect binary data in the n8n S3 bucket... - You can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key. - You can also access the S3 section in the AWS Management Console with the details from step 7. - - Or you can query the AWS S3 API using n8n's AWS S3 node. + - You can query the AWS S3 API using n8n's AWS S3 node. - If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. - If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and as long as your S3 credentials remain valid. +- At this time, binary data pruning is based on the active binary data mode. For example, if your instance stored data in S3 and was later switched to filesystem mode, only binary data in the filesystem will be pruned. This may change in future. diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 84fcdcf50df33..7766fe580e904 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -5,7 +5,6 @@ import axios from 'axios'; import { Service } from 'typedi'; import { sign } from 'aws4'; import { isStream, parseXml, writeBlockedMessage } from './utils'; -import { ObjectStore } from './errors'; import { LoggerProxy as Logger } from 'n8n-workflow'; import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; @@ -240,7 +239,7 @@ export class ObjectStoreService { return { status: 403, statusText: 'Forbidden', data: logMessage, headers: {}, config: {} }; } - private async request( + private async request( method: Method, host: string, rawPath = '', @@ -271,10 +270,17 @@ export class ObjectStoreService { if (responseType) config.responseType = responseType; try { - this.logger.debug(`[Object Store service] Sending request to ${host}${path}`, { config }); - return await axios.request(config); - } catch (error) { - throw new ObjectStore.RequestFailedError(error, config); + this.logger.debug('Sending request to S3', { config }); + + return await axios.request(config); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + + const message = 'Request to S3 failed'; + + this.logger.error(message, { error, config }); + + throw new Error(message, { cause: { error, details: config } }); } } } diff --git a/packages/core/src/ObjectStore/errors.ts b/packages/core/src/ObjectStore/errors.ts deleted file mode 100644 index 46a2db8d76343..0000000000000 --- a/packages/core/src/ObjectStore/errors.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AxiosRequestConfig } from 'axios'; - -export namespace ObjectStore { - export class RequestFailedError extends Error { - constructor(error: unknown, config: AxiosRequestConfig) { - super('Request to external object storage failed.'); - this.cause = { error, details: config }; - } - } -} diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/ObjectStore/utils.ts index 34fa080c2135b..1ecad915f9091 100644 --- a/packages/core/src/ObjectStore/utils.ts +++ b/packages/core/src/ObjectStore/utils.ts @@ -16,5 +16,5 @@ export async function parseXml(xml: string): Promise { } export function writeBlockedMessage(filename: string) { - return `BLOCKED: Request to write file "${filename}" to object storage failed. This request was blocked because your current binary data mode is "s3" but storing binary data in S3 is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than "s3".`; + return `Request to write file "${filename}" to object storage was blocked because S3 storage is not available with your current license. Please upgrade to a license that supports this feature, or set N8N_DEFAULT_BINARY_DATA_MODE to an option other than "s3".`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9fdb093baa574..3eaecf6581d84 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,3 +17,4 @@ export * from './WorkflowExecute'; export { NodeExecuteFunctions, UserSettings }; export * from './errors'; export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee'; +export { BinaryData } from './BinaryData/types'; diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/test/ObjectStore.manager.test.ts index a8d0decc9012d..00559384e2a38 100644 --- a/packages/core/test/ObjectStore.manager.test.ts +++ b/packages/core/test/ObjectStore.manager.test.ts @@ -1,132 +1,147 @@ import fs from 'node:fs/promises'; -import { ObjectStoreManager } from '../src/BinaryData/ObjectStore.manager'; -import { ObjectStoreService } from '..'; -import { mockInstance, toStream } from './utils'; +import { ObjectStoreManager } from '@/BinaryData/ObjectStore.manager'; +import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; import { isStream } from '@/ObjectStore/utils'; +import { mockInstance, toStream } from './utils'; jest.mock('fs/promises'); const objectStoreService = mockInstance(ObjectStoreService); - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore const objectStoreManager = new ObjectStoreManager(objectStoreService); -const toFileId = (workflowId: string, executionId: string, fileUuid: string): string => +const toFileId = (workflowId: string, executionId: string, fileUuid: string) => `workflows/${workflowId}/executions/${executionId}/binary_data/${fileUuid}`; const workflowId = 'ObogjVbqpNOQpiyV'; const executionId = '999'; const fileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb32'; const fileId = toFileId(workflowId, executionId, fileUuid); +const prefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; const otherWorkflowId = 'FHio8ftV6SrCAfPJ'; const otherExecutionId = '888'; const otherFileUuid = '71f6209b-5d48-41a2-a224-80d529d8bb33'; const otherFileId = toFileId(otherWorkflowId, otherExecutionId, otherFileUuid); -test('store() should store a buffer', async () => { - const buffer = Buffer.from('Test data'); - const metadata = { mimeType: 'text/plain' }; +const mockBuffer = Buffer.from('Test data'); +const mockStream = toStream(mockBuffer); + +beforeAll(() => { + jest.restoreAllMocks(); +}); - const result = await objectStoreManager.store(workflowId, executionId, buffer, metadata); +describe('store()', () => { + it('should store a buffer', async () => { + const metadata = { mimeType: 'text/plain' }; - const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; + const result = await objectStoreManager.store(workflowId, executionId, mockBuffer, metadata); - expect(result.fileId.startsWith(expectedPrefix)).toBe(true); - expect(result.fileSize).toBe(buffer.length); + expect(result.fileId.startsWith(prefix)).toBe(true); + expect(result.fileSize).toBe(mockBuffer.length); + }); }); -test('getPath() should return a path', async () => { - const path = objectStoreManager.getPath(fileId); +describe('getPath()', () => { + it('should return a path', async () => { + const path = objectStoreManager.getPath(fileId); - expect(path).toBe(fileId); + expect(path).toBe(fileId); + }); }); -test('getAsBuffer() should return a buffer', async () => { - const fileId = - 'workflows/ObogjVbqpNOQpiyV/executions/123/binary_data/71f6209b-5d48-41a2-a224-80d529d8bb32'; - - // @ts-expect-error Overload signature seemingly causing Jest to misinfer the return type - objectStoreService.get.mockResolvedValueOnce(Buffer.from('Test data')); +describe('getAsBuffer()', () => { + it('should return a buffer', async () => { + // @ts-expect-error Overload signature seemingly causing Jest to misinfer the return type + objectStoreService.get.mockResolvedValue(mockBuffer); - const result = await objectStoreManager.getAsBuffer(fileId); + const result = await objectStoreManager.getAsBuffer(fileId); - expect(Buffer.isBuffer(result)).toBe(true); - expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + expect(Buffer.isBuffer(result)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + }); }); -test('getAsStream() should return a stream', async () => { - objectStoreService.get.mockResolvedValueOnce(toStream(Buffer.from('Test data'))); +describe('getAsStream()', () => { + it('should return a stream', async () => { + objectStoreService.get.mockResolvedValue(mockStream); - const stream = await objectStoreManager.getAsStream(fileId); + const stream = await objectStoreManager.getAsStream(fileId); - expect(isStream(stream)).toBe(true); - expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'stream' }); + expect(isStream(stream)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'stream' }); + }); }); -test('getMetadata() should return metadata', async () => { - objectStoreService.getMetadata.mockResolvedValue({ - 'content-length': '1', - 'content-type': 'text/plain', - 'x-amz-meta-filename': 'file.txt', - }); +describe('getMetadata()', () => { + it('should return metadata', async () => { + const mimeType = 'text/plain'; + const fileName = 'file.txt'; - const metadata = await objectStoreManager.getMetadata(fileId); + objectStoreService.getMetadata.mockResolvedValue({ + 'content-length': '1', + 'content-type': mimeType, + 'x-amz-meta-filename': fileName, + }); - expect(metadata).toEqual(expect.objectContaining({ fileSize: 1 })); - expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); -}); + const metadata = await objectStoreManager.getMetadata(fileId); -test('copyByFileId() should copy by file ID and return the file ID', async () => { - const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId); + expect(metadata).toEqual(expect.objectContaining({ fileSize: 1, mimeType, fileName })); + expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); + }); +}); - const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; +describe('copyByFileId()', () => { + it('should copy by file ID and return the file ID', async () => { + const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId); - expect(targetFileId.startsWith(expectedPrefix)).toBe(true); - expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + expect(targetFileId.startsWith(prefix)).toBe(true); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + }); }); -test('copyByFilePath() should copy by file path and return the file ID and size', async () => { - const sourceFilePath = 'path/to/file/in/filesystem'; - const metadata = { mimeType: 'text/plain' }; - const buffer = Buffer.from('Test file content'); +describe('copyByFilePath()', () => { + test('should copy by file path and return the file ID and size', async () => { + const sourceFilePath = 'path/to/file/in/filesystem'; + const metadata = { mimeType: 'text/plain' }; - fs.readFile = jest.fn().mockResolvedValueOnce(buffer); + fs.readFile = jest.fn().mockResolvedValue(mockBuffer); - const result = await objectStoreManager.copyByFilePath( - workflowId, - executionId, - sourceFilePath, - metadata, - ); + const result = await objectStoreManager.copyByFilePath( + workflowId, + executionId, + sourceFilePath, + metadata, + ); - const expectedPrefix = `workflows/${workflowId}/executions/${executionId}/binary_data/`; - - expect(result.fileId.startsWith(expectedPrefix)).toBe(true); - expect(fs.readFile).toHaveBeenCalledWith(sourceFilePath); - expect(result.fileSize).toBe(buffer.length); + expect(result.fileId.startsWith(prefix)).toBe(true); + expect(fs.readFile).toHaveBeenCalledWith(sourceFilePath); + expect(result.fileSize).toBe(mockBuffer.length); + }); }); -test('deleteMany() should delete many files by prefix', async () => { - const ids = [ - { workflowId, executionId }, - { workflowId: otherWorkflowId, executionId: otherExecutionId }, - ]; +describe('deleteMany()', () => { + it('should delete many files by prefix', async () => { + const ids = [ + { workflowId, executionId }, + { workflowId: otherWorkflowId, executionId: otherExecutionId }, + ]; - const promise = objectStoreManager.deleteMany(ids); + const promise = objectStoreManager.deleteMany(ids); - await expect(promise).resolves.not.toThrow(); + await expect(promise).resolves.not.toThrow(); - expect(objectStoreService.deleteMany).toHaveBeenCalledTimes(2); + expect(objectStoreService.deleteMany).toHaveBeenCalledTimes(2); + }); }); -test('rename() should rename a file', async () => { - const promise = objectStoreManager.rename(fileId, otherFileId); +describe('rename()', () => { + it('should rename a file', async () => { + const promise = objectStoreManager.rename(fileId, otherFileId); - await expect(promise).resolves.not.toThrow(); + await expect(promise).resolves.not.toThrow(); - expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); - expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); - expect(objectStoreService.deleteOne).toHaveBeenCalledWith(fileId); + expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); + expect(objectStoreService.getMetadata).toHaveBeenCalledWith(fileId); + expect(objectStoreService.deleteOne).toHaveBeenCalledWith(fileId); + }); }); diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index 53dc01b3991ff..e0921bda9da75 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { ObjectStoreService } from '../src/ObjectStore/ObjectStore.service.ee'; +import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; import { Readable } from 'stream'; import { writeBlockedMessage } from '@/ObjectStore/utils'; import { initLogger } from './helpers/utils'; @@ -8,325 +8,304 @@ jest.mock('axios'); const mockAxios = axios as jest.Mocked; -const MOCK_BUCKET = { region: 'us-east-1', name: 'test-bucket' }; -const MOCK_HOST = `s3.${MOCK_BUCKET.region}.amazonaws.com`; -const MOCK_CREDENTIALS = { accountId: 'mock-account-id', secretKey: 'mock-secret-key' }; -const MOCK_URL = `https://${MOCK_HOST}/${MOCK_BUCKET.name}`; -const FAILED_REQUEST_ERROR_MESSAGE = 'Request to external object storage failed'; -const MOCK_S3_ERROR = new Error('Something went wrong!'); +const mockBucket = { region: 'us-east-1', name: 'test-bucket' }; +const mockHost = `s3.${mockBucket.region}.amazonaws.com`; +const mockCredentials = { accountId: 'mock-account-id', secretKey: 'mock-secret-key' }; +const mockUrl = `https://${mockHost}/${mockBucket.name}`; +const FAILED_REQUEST_ERROR_MESSAGE = 'Request to S3 failed'; +const mockError = new Error('Something went wrong!'); +const fileId = + 'workflows/ObogjVbqpNOQpiyV/executions/999/binary_data/71f6209b-5d48-41a2-a224-80d529d8bb32'; +const mockBuffer = Buffer.from('Test data'); const toDeletionXml = (filename: string) => ` ${filename} `; -describe('ObjectStoreService', () => { - let objectStoreService: ObjectStoreService; - initLogger(); +let objectStoreService: ObjectStoreService; +initLogger(); - beforeEach(async () => { - objectStoreService = new ObjectStoreService(); - mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection - await objectStoreService.init(MOCK_HOST, MOCK_BUCKET, MOCK_CREDENTIALS); - jest.restoreAllMocks(); - }); +beforeEach(async () => { + objectStoreService = new ObjectStoreService(); + mockAxios.request.mockResolvedValueOnce({ status: 200 }); // for checkConnection + await objectStoreService.init(mockHost, mockBucket, mockCredentials); + jest.restoreAllMocks(); +}); - describe('checkConnection()', () => { - it('should send a HEAD request to the correct host', async () => { - mockAxios.request.mockResolvedValue({ status: 200 }); +describe('checkConnection()', () => { + it('should send a HEAD request to the correct host', async () => { + mockAxios.request.mockResolvedValue({ status: 200 }); - objectStoreService.setReady(false); + objectStoreService.setReady(false); - await objectStoreService.checkConnection(); + await objectStoreService.checkConnection(); - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'HEAD', - url: `https://${MOCK_HOST}/${MOCK_BUCKET.name}`, - headers: expect.objectContaining({ - 'X-Amz-Content-Sha256': expect.any(String), - 'X-Amz-Date': expect.any(String), - Authorization: expect.any(String), - }), + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'HEAD', + url: `https://${mockHost}/${mockBucket.name}`, + headers: expect.objectContaining({ + 'X-Amz-Content-Sha256': expect.any(String), + 'X-Amz-Date': expect.any(String), + Authorization: expect.any(String), }), - ); - }); + }), + ); + }); - it('should throw an error on request failure', async () => { - objectStoreService.setReady(false); + it('should throw an error on request failure', async () => { + objectStoreService.setReady(false); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + mockAxios.request.mockRejectedValue(mockError); - const promise = objectStoreService.checkConnection(); + const promise = objectStoreService.checkConnection(); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); +}); - describe('getMetadata()', () => { - it('should send a HEAD request to the correct host and path', async () => { - const fileId = 'file.txt'; - - mockAxios.request.mockResolvedValue({ status: 200 }); - - await objectStoreService.getMetadata(fileId); - - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'HEAD', - url: `${MOCK_URL}/${fileId}`, - headers: expect.objectContaining({ - Host: MOCK_HOST, - 'X-Amz-Content-Sha256': expect.any(String), - 'X-Amz-Date': expect.any(String), - Authorization: expect.any(String), - }), +describe('getMetadata()', () => { + it('should send a HEAD request to the correct host and path', async () => { + mockAxios.request.mockResolvedValue({ status: 200 }); + + await objectStoreService.getMetadata(fileId); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'HEAD', + url: `${mockUrl}/${fileId}`, + headers: expect.objectContaining({ + Host: mockHost, + 'X-Amz-Content-Sha256': expect.any(String), + 'X-Amz-Date': expect.any(String), + Authorization: expect.any(String), }), - ); - }); - - it('should throw an error on request failure', async () => { - const fileId = 'file.txt'; + }), + ); + }); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(mockError); - const promise = objectStoreService.getMetadata(fileId); + const promise = objectStoreService.getMetadata(fileId); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); +}); - describe('put()', () => { - it('should send a PUT request to upload an object', async () => { - const fileId = 'file.txt'; - const buffer = Buffer.from('Test content'); - const metadata = { fileName: fileId, mimeType: 'text/plain' }; - - mockAxios.request.mockResolvedValue({ status: 200 }); - - await objectStoreService.put(fileId, buffer, metadata); - - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PUT', - url: `${MOCK_URL}/${fileId}`, - headers: expect.objectContaining({ - 'Content-Length': buffer.length, - 'Content-MD5': expect.any(String), - 'x-amz-meta-filename': metadata.fileName, - 'Content-Type': metadata.mimeType, - }), - data: buffer, - }), - ); - }); - - it('should block without erroring if read-only', async () => { - initLogger(); - objectStoreService.setReadonly(true); +describe('put()', () => { + it('should send a PUT request to upload an object', async () => { + const metadata = { fileName: 'file.txt', mimeType: 'text/plain' }; - const path = 'file.txt'; - const buffer = Buffer.from('Test content'); - const metadata = { fileName: path, mimeType: 'text/plain' }; + mockAxios.request.mockResolvedValue({ status: 200 }); - const promise = objectStoreService.put(path, buffer, metadata); + await objectStoreService.put(fileId, mockBuffer, metadata); - await expect(promise).resolves.not.toThrow(); + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + url: `${mockUrl}/${fileId}`, + headers: expect.objectContaining({ + 'Content-Length': mockBuffer.length, + 'Content-MD5': expect.any(String), + 'x-amz-meta-filename': metadata.fileName, + 'Content-Type': metadata.mimeType, + }), + data: mockBuffer, + }), + ); + }); - const result = await promise; + it('should block if read-only', async () => { + initLogger(); + objectStoreService.setReadonly(true); - const blockedMessage = writeBlockedMessage(path); + const metadata = { fileName: 'file.txt', mimeType: 'text/plain' }; - expect(result.status).toBe(403); - expect(result.statusText).toBe('Forbidden'); - expect(result.data).toBe(blockedMessage); - }); + const promise = objectStoreService.put(fileId, mockBuffer, metadata); - it('should throw an error on request failure', async () => { - const path = 'file.txt'; - const buffer = Buffer.from('Test content'); - const metadata = { fileName: path, mimeType: 'text/plain' }; + await expect(promise).resolves.not.toThrow(); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + const result = await promise; - const promise = objectStoreService.put(path, buffer, metadata); + expect(result.status).toBe(403); + expect(result.statusText).toBe('Forbidden'); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + expect(result.data).toBe(writeBlockedMessage(fileId)); }); - describe('get()', () => { - it('should send a GET request to download an object as a buffer', async () => { - const fileId = 'file.txt'; + it('should throw an error on request failure', async () => { + const metadata = { fileName: 'file.txt', mimeType: 'text/plain' }; - mockAxios.request.mockResolvedValue({ status: 200, data: Buffer.from('Test content') }); + mockAxios.request.mockRejectedValue(mockError); - const result = await objectStoreService.get(fileId, { mode: 'buffer' }); - - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - url: `${MOCK_URL}/${fileId}`, - responseType: 'arraybuffer', - }), - ); + const promise = objectStoreService.put(fileId, mockBuffer, metadata); - expect(Buffer.isBuffer(result)).toBe(true); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); +}); - it('should send a GET request to download an object as a stream', async () => { - const fileId = 'file.txt'; +describe('get()', () => { + it('should send a GET request to download an object as a buffer', async () => { + const fileId = 'file.txt'; - mockAxios.request.mockResolvedValue({ status: 200, data: new Readable() }); + mockAxios.request.mockResolvedValue({ status: 200, data: Buffer.from('Test content') }); - const result = await objectStoreService.get(fileId, { mode: 'stream' }); + const result = await objectStoreService.get(fileId, { mode: 'buffer' }); - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - url: `${MOCK_URL}/${fileId}`, - responseType: 'stream', - }), - ); + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `${mockUrl}/${fileId}`, + responseType: 'arraybuffer', + }), + ); - expect(result instanceof Readable).toBe(true); - }); + expect(Buffer.isBuffer(result)).toBe(true); + }); - it('should throw an error on request failure', async () => { - const path = 'file.txt'; + it('should send a GET request to download an object as a stream', async () => { + mockAxios.request.mockResolvedValue({ status: 200, data: new Readable() }); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + const result = await objectStoreService.get(fileId, { mode: 'stream' }); - const promise = objectStoreService.get(path, { mode: 'buffer' }); + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `${mockUrl}/${fileId}`, + responseType: 'stream', + }), + ); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + expect(result instanceof Readable).toBe(true); }); - describe('deleteOne()', () => { - it('should send a DELETE request to delete a single object', async () => { - const fileId = 'file.txt'; + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(mockError); - mockAxios.request.mockResolvedValue({ status: 204 }); + const promise = objectStoreService.get(fileId, { mode: 'buffer' }); - await objectStoreService.deleteOne(fileId); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); + }); +}); - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'DELETE', - url: `${MOCK_URL}/${fileId}`, - }), - ); - }); +describe('deleteOne()', () => { + it('should send a DELETE request to delete a single object', async () => { + mockAxios.request.mockResolvedValue({ status: 204 }); + + await objectStoreService.deleteOne(fileId); - it('should throw an error on request failure', async () => { - const path = 'file.txt'; + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + url: `${mockUrl}/${fileId}`, + }), + ); + }); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(mockError); - const promise = objectStoreService.deleteOne(path); + const promise = objectStoreService.deleteOne(fileId); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); +}); - describe('deleteMany()', () => { - it('should send a POST request to delete multiple objects', async () => { - const prefix = 'test-dir/'; - const fileName = 'file.txt'; - - const mockList = [ - { - key: fileName, - lastModified: '2023-09-24T12:34:56Z', - eTag: 'abc123def456', - size: 456789, - storageClass: 'STANDARD', - }, - ]; - - objectStoreService.list = jest.fn().mockResolvedValue(mockList); - - mockAxios.request.mockResolvedValue({ status: 204 }); - - await objectStoreService.deleteMany(prefix); - - expect(mockAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - url: `${MOCK_URL}/?delete`, - headers: expect.objectContaining({ - 'Content-Type': 'application/xml', - 'Content-Length': expect.any(Number), - 'Content-MD5': expect.any(String), - }), - data: toDeletionXml(fileName), +describe('deleteMany()', () => { + it('should send a POST request to delete multiple objects', async () => { + const prefix = 'test-dir/'; + const fileName = 'file.txt'; + + const mockList = [ + { + key: fileName, + lastModified: '2023-09-24T12:34:56Z', + eTag: 'abc123def456', + size: 456789, + storageClass: 'STANDARD', + }, + ]; + + objectStoreService.list = jest.fn().mockResolvedValue(mockList); + + mockAxios.request.mockResolvedValue({ status: 204 }); + + await objectStoreService.deleteMany(prefix); + + expect(objectStoreService.list).toHaveBeenCalledWith(prefix); + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: `${mockUrl}/?delete`, + headers: expect.objectContaining({ + 'Content-Type': 'application/xml', + 'Content-Length': expect.any(Number), + 'Content-MD5': expect.any(String), }), - ); - }); - - it('should throw an error on request failure', async () => { - const prefix = 'test-dir/'; + data: toDeletionXml(fileName), + }), + ); + }); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(mockError); - const promise = objectStoreService.deleteMany(prefix); + const promise = objectStoreService.deleteMany('test-dir/'); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); +}); - describe('list()', () => { - it('should list objects with a common prefix', async () => { - const prefix = 'test-dir/'; - - const mockListPage = { - contents: [{ key: `${prefix}file1.txt` }, { key: `${prefix}file2.txt` }], - isTruncated: false, - }; +describe('list()', () => { + it('should list objects with a common prefix', async () => { + const prefix = 'test-dir/'; - objectStoreService.getListPage = jest.fn().mockResolvedValue(mockListPage); + const mockListPage = { + contents: [{ key: `${prefix}file1.txt` }, { key: `${prefix}file2.txt` }], + isTruncated: false, + }; - mockAxios.request.mockResolvedValue({ status: 200 }); + objectStoreService.getListPage = jest.fn().mockResolvedValue(mockListPage); - const result = await objectStoreService.list(prefix); + mockAxios.request.mockResolvedValue({ status: 200 }); - expect(result).toEqual(mockListPage.contents); - }); + const result = await objectStoreService.list(prefix); - it('should consolidate pages', async () => { - const prefix = 'test-dir/'; + expect(result).toEqual(mockListPage.contents); + }); - const mockFirstListPage = { - contents: [{ key: `${prefix}file1.txt` }], - isTruncated: true, - nextContinuationToken: 'token1', - }; + it('should consolidate pages', async () => { + const prefix = 'test-dir/'; - const mockSecondListPage = { - contents: [{ key: `${prefix}file2.txt` }], - isTruncated: false, - }; + const mockFirstListPage = { + contents: [{ key: `${prefix}file1.txt` }], + isTruncated: true, + nextContinuationToken: 'token1', + }; - objectStoreService.getListPage = jest - .fn() - .mockResolvedValueOnce(mockFirstListPage) - .mockResolvedValueOnce(mockSecondListPage); + const mockSecondListPage = { + contents: [{ key: `${prefix}file2.txt` }], + isTruncated: false, + }; - mockAxios.request.mockResolvedValue({ status: 200 }); + objectStoreService.getListPage = jest + .fn() + .mockResolvedValueOnce(mockFirstListPage) + .mockResolvedValueOnce(mockSecondListPage); - const result = await objectStoreService.list(prefix); + mockAxios.request.mockResolvedValue({ status: 200 }); - expect(result).toEqual([...mockFirstListPage.contents, ...mockSecondListPage.contents]); - }); + const result = await objectStoreService.list(prefix); - it('should throw an error on request failure', async () => { - const prefix = 'test-dir/'; + expect(result).toEqual([...mockFirstListPage.contents, ...mockSecondListPage.contents]); + }); - mockAxios.request.mockRejectedValue(MOCK_S3_ERROR); + it('should throw an error on request failure', async () => { + mockAxios.request.mockRejectedValue(mockError); - const promise = objectStoreService.list(prefix); + const promise = objectStoreService.list('test-dir/'); - await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); - }); + await expect(promise).rejects.toThrowError(FAILED_REQUEST_ERROR_MESSAGE); }); }); diff --git a/packages/core/test/utils.ts b/packages/core/test/utils.ts index b5eb94465cc0c..09e2a859bf006 100644 --- a/packages/core/test/utils.ts +++ b/packages/core/test/utils.ts @@ -1,6 +1,7 @@ import { Container } from 'typedi'; import { mock } from 'jest-mock-extended'; import { Duplex } from 'stream'; + import type { DeepPartial } from 'ts-essentials'; export const mockInstance = ( @@ -13,9 +14,9 @@ export const mockInstance = ( }; export function toStream(buffer: Buffer) { - const d = new Duplex(); - d.push(buffer); - d.push(null); + const duplexStream = new Duplex(); + duplexStream.push(buffer); + duplexStream.push(null); - return d; + return duplexStream; } From 258f21822c8e73c0c546e9a8ea2b1adb4196a403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 09:56:05 +0200 Subject: [PATCH 244/259] Add more logging --- packages/cli/src/License.ts | 8 ++++- packages/cli/src/commands/BaseCommand.ts | 33 ++++++++++++++++--- .../src/ObjectStore/ObjectStore.service.ee.ts | 25 +++++++------- packages/core/src/ObjectStore/types.ts | 2 ++ 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 0ef04d68eb921..e8ab30dca6e42 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -107,7 +107,13 @@ export class License { const isReadonly = _features['feat:binaryDataS3'] === false; - Container.get(ObjectStoreService).setReadonly(isReadonly); + if (isReadonly) { + this.logger.debug( + 'License changed with no support for external storage - blocking writes on object store. To restore writes, please upgrade to a license that supports this feature.', + ); + + Container.get(ObjectStoreService).setReadonly(true); + } } async saveCertStr(value: TLicenseBlock): Promise { diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 8e1072cdaa07b..832bb8a2df03f 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -132,26 +132,38 @@ export abstract class BaseCommand extends Command { const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); if (isSelected && isAvailable && isLicensed) { - // allow reads from anywhere, allow writes to S3 + LoggerProxy.debug( + 'License found for external storage - object store to init in read-write mode', + ); + await this._initObjectStoreService(); + return; } if (isSelected && isAvailable && !isLicensed) { - // allow reads from anywhere, block writes to s3 + LoggerProxy.debug( + 'No license found for external storage - object store to init with writes blocked. To enable writes, please upgrade to a license that supports this feature.', + ); + await this._initObjectStoreService({ isReadOnly: true }); + return; } if (!isSelected && isAvailable) { - // allow reads from anywhere, no writes to S3 will occur + LoggerProxy.debug( + 'External storage unselected but available - object store to init with writes unused', + ); + await this._initObjectStoreService(); + return; } if (isSelected && !isAvailable) { throw new Error( - 'External storage selected but unavailable. Please make external storage available, e.g. `export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3`', + 'External storage selected but unavailable. Stopping n8n startup. Please make external storage available by adding "s3" to `N8N_AVAILABLE_BINARY_DATA_MODES`.', ); } } @@ -171,7 +183,18 @@ export abstract class BaseCommand extends Command { secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), }; - await objectStoreService.init(host, bucket, credentials, options); + LoggerProxy.debug('Initializing object store service'); + + try { + await objectStoreService.init(host, bucket, credentials); + objectStoreService.setReadonly(options.isReadOnly); + + LoggerProxy.debug('Object store init completed'); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + + LoggerProxy.debug('Object store init failed', { error }); + } } async initBinaryDataService() { diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 7766fe580e904..39e7e8d95b65e 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -9,7 +9,13 @@ import { LoggerProxy as Logger } from 'n8n-workflow'; import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import type { Request as Aws4Options, Credentials as Aws4Credentials } from 'aws4'; -import type { Bucket, ListPage, RawListPage, RequestOptions } from './types'; +import type { + Bucket, + ConfigSchemaCredentials, + ListPage, + RawListPage, + RequestOptions, +} from './types'; import type { Readable } from 'stream'; import type { BinaryData } from '..'; @@ -27,12 +33,7 @@ export class ObjectStoreService { private logger = Logger; - async init( - host: string, - bucket: Bucket, - credentials: { accountId: string; secretKey: string }, - options?: { isReadOnly: boolean }, - ) { + async init(host: string, bucket: Bucket, credentials: ConfigSchemaCredentials) { this.host = host; this.bucket.name = bucket.name; this.bucket.region = bucket.region; @@ -42,8 +43,6 @@ export class ObjectStoreService { secretAccessKey: credentials.secretKey, }; - if (options?.isReadOnly) this.isReadOnly = true; - await this.checkConnection(); this.setReady(true); @@ -167,8 +166,6 @@ export class ObjectStoreService { /** * List objects with a common prefix in the configured bucket. - * - * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html */ async list(prefix: string) { const items = []; @@ -189,7 +186,11 @@ export class ObjectStoreService { } /** - * Fetch a page of objects with a common prefix in the configured bucket. Max 1000 per page. + * Fetch a page of objects with a common prefix in the configured bucket. + * + * Max 1000 objects per page - set by AWS. + * + * @doc https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html */ async getListPage(prefix: string, nextPageToken?: string) { const qs: Record = { 'list-type': 2, prefix }; diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index b0a4ade19bc15..5cb4ced5650e6 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -30,3 +30,5 @@ export type RequestOptions = { body?: string | Buffer; responseType?: ResponseType; }; + +export type ConfigSchemaCredentials = { accountId: string; secretKey: string }; From 07d2b1bee9e998922f393232969bf1b2ded67d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 09:56:42 +0200 Subject: [PATCH 245/259] Fix lint --- packages/cli/src/requests.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 76495f0dffe00..25eea3770a18a 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,7 +1,6 @@ import type express from 'express'; import type { BannerName, - IBinaryData, IConnections, ICredentialDataDecryptedObject, ICredentialNodeAccess, From b75cbbb8dda81aa66a3152cd1eba0a8519a5cac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:00:50 +0200 Subject: [PATCH 246/259] Improve check --- packages/cli/src/License.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index e8ab30dca6e42..2e217badd2f0a 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -105,9 +105,11 @@ export class License { }); } - const isReadonly = _features['feat:binaryDataS3'] === false; + const isSelected = config.getEnv('binaryDataManager.mode') === 's3'; + const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); + const isLicensed = _features['feat:binaryDataS3'] === false; - if (isReadonly) { + if (isSelected && isAvailable && !isLicensed) { this.logger.debug( 'License changed with no support for external storage - blocking writes on object store. To restore writes, please upgrade to a license that supports this feature.', ); From 9fde3f0abed14e0ca6a3d6457385a5c2e5a33d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:01:39 +0200 Subject: [PATCH 247/259] Simplify check --- packages/cli/src/License.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 2e217badd2f0a..5ed5b1cfc6e4b 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -107,7 +107,7 @@ export class License { const isSelected = config.getEnv('binaryDataManager.mode') === 's3'; const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); - const isLicensed = _features['feat:binaryDataS3'] === false; + const isLicensed = _features['feat:binaryDataS3']; if (isSelected && isAvailable && !isLicensed) { this.logger.debug( From 2a3c1e2e8b53347ded92ebd1e18adc2dc0abe098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:04:51 +0200 Subject: [PATCH 248/259] Add examples --- packages/cli/src/config/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index bd370f1fbda0b..2cefe639aaa45 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -920,7 +920,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_HOST', - doc: 'Host of the n8n bucket in S3-compatible external storage', + doc: 'Host of the n8n bucket in S3-compatible external storage, `e.g. s3.us-east-1.amazonaws.com`', }, bucket: { name: { @@ -933,7 +933,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION', - doc: 'Region of the n8n bucket in S3-compatible external storage', + doc: 'Region of the n8n bucket in S3-compatible external storage, e.g. `us-east-1`', }, }, credentials: { From f98a467a06a34cf44f915ceb1aff99e4928771a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:05:11 +0200 Subject: [PATCH 249/259] Typo --- packages/cli/src/config/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 2cefe639aaa45..88a74ac64a1f7 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -920,7 +920,7 @@ export const schema = { format: String, default: '', env: 'N8N_EXTERNAL_STORAGE_S3_HOST', - doc: 'Host of the n8n bucket in S3-compatible external storage, `e.g. s3.us-east-1.amazonaws.com`', + doc: 'Host of the n8n bucket in S3-compatible external storage, e.g. `s3.us-east-1.amazonaws.com`', }, bucket: { name: { From a398719507b561da9445298746da2caad7ea6a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:33:27 +0200 Subject: [PATCH 250/259] Refactor test --- .../cli/test/unit/execution.lifecycle.test.ts | 91 ++++++++++--------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/packages/cli/test/unit/execution.lifecycle.test.ts b/packages/cli/test/unit/execution.lifecycle.test.ts index 2d3790bd5fd87..72d841580395b 100644 --- a/packages/cli/test/unit/execution.lifecycle.test.ts +++ b/packages/cli/test/unit/execution.lifecycle.test.ts @@ -28,62 +28,67 @@ function getDataId(run: IRun, kind: 'binary' | 'json') { return run.data.resultData.runData.myNode[0].data.main[0][0][kind].data.id; } -describe('restoreBinaryDataId() [filesystem mode]', () => { - const binaryDataService = mockInstance(BinaryDataService); +describe('on filesystem mode', () => { + describe('restoreBinaryDataId()', () => { + const binaryDataService = mockInstance(BinaryDataService); - beforeEach(() => { - config.set('binaryDataManager.mode', 'filesystem'); - jest.clearAllMocks(); - }); + beforeAll(() => { + config.set('binaryDataManager.mode', 'filesystem'); + }); - it('should restore if binary data ID is missing execution ID', async () => { - const executionId = '999'; - const incorrectFileId = 'a5c3f1ed-9d59-4155-bc68-9a370b3c51f6'; - const run = toIRun({ - binary: { - data: { id: `filesystem:${incorrectFileId}` }, - }, + afterEach(() => { + jest.clearAllMocks(); }); - await restoreBinaryDataId(run, executionId); + it('should restore if binary data ID is missing execution ID', async () => { + const executionId = '999'; + const incorrectFileId = 'a5c3f1ed-9d59-4155-bc68-9a370b3c51f6'; + const run = toIRun({ + binary: { + data: { id: `filesystem:${incorrectFileId}` }, + }, + }); - const correctFileId = `${executionId}${incorrectFileId}`; - const correctBinaryDataId = `filesystem:${correctFileId}`; + await restoreBinaryDataId(run, executionId); - expect(binaryDataService.rename).toHaveBeenCalledWith(incorrectFileId, correctFileId); - expect(getDataId(run, 'binary')).toBe(correctBinaryDataId); - }); + const correctFileId = `${executionId}${incorrectFileId}`; + const correctBinaryDataId = `filesystem:${correctFileId}`; - it('should do nothing if binary data ID is not missing execution ID', async () => { - const executionId = '999'; - const fileId = `${executionId}a5c3f1ed-9d59-4155-bc68-9a370b3c51f6`; - const binaryDataId = `filesystem:${fileId}`; - const run = toIRun({ - binary: { - data: { - id: binaryDataId, - }, - }, + expect(binaryDataService.rename).toHaveBeenCalledWith(incorrectFileId, correctFileId); + expect(getDataId(run, 'binary')).toBe(correctBinaryDataId); }); - await restoreBinaryDataId(run, executionId); + it('should do nothing if binary data ID is not missing execution ID', async () => { + const executionId = '999'; + const fileId = `${executionId}a5c3f1ed-9d59-4155-bc68-9a370b3c51f6`; + const binaryDataId = `filesystem:${fileId}`; + const run = toIRun({ + binary: { + data: { + id: binaryDataId, + }, + }, + }); - expect(binaryDataService.rename).not.toHaveBeenCalled(); - expect(getDataId(run, 'binary')).toBe(binaryDataId); - }); + await restoreBinaryDataId(run, executionId); - it('should do nothing if no binary data ID', async () => { - const executionId = '999'; - const dataId = '123'; - const run = toIRun({ - json: { - data: { id: dataId }, - }, + expect(binaryDataService.rename).not.toHaveBeenCalled(); + expect(getDataId(run, 'binary')).toBe(binaryDataId); }); - await restoreBinaryDataId(run, executionId); + it('should do nothing if no binary data ID', async () => { + const executionId = '999'; + const dataId = '123'; + const run = toIRun({ + json: { + data: { id: dataId }, + }, + }); + + await restoreBinaryDataId(run, executionId); - expect(binaryDataService.rename).not.toHaveBeenCalled(); - expect(getDataId(run, 'json')).toBe(dataId); + expect(binaryDataService.rename).not.toHaveBeenCalled(); + expect(getDataId(run, 'json')).toBe(dataId); + }); }); }); From e584be127c48e61cbfdcea850168edeab651722a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:36:42 +0200 Subject: [PATCH 251/259] Expand tests --- .../cli/test/unit/execution.lifecycle.test.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/unit/execution.lifecycle.test.ts b/packages/cli/test/unit/execution.lifecycle.test.ts index 72d841580395b..8466ea4246e3e 100644 --- a/packages/cli/test/unit/execution.lifecycle.test.ts +++ b/packages/cli/test/unit/execution.lifecycle.test.ts @@ -28,10 +28,10 @@ function getDataId(run: IRun, kind: 'binary' | 'json') { return run.data.resultData.runData.myNode[0].data.main[0][0][kind].data.id; } +const binaryDataService = mockInstance(BinaryDataService); + describe('on filesystem mode', () => { describe('restoreBinaryDataId()', () => { - const binaryDataService = mockInstance(BinaryDataService); - beforeAll(() => { config.set('binaryDataManager.mode', 'filesystem'); }); @@ -92,3 +92,76 @@ describe('on filesystem mode', () => { }); }); }); + +describe('on s3 mode', () => { + describe('restoreBinaryDataId()', () => { + beforeAll(() => { + config.set('binaryDataManager.mode', 's3'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should restore if binary data ID is missing execution ID', async () => { + const workflowId = '6HYhhKmJch2cYxGj'; + const executionId = 'temp'; + const binaryDataFileUuid = 'a5c3f1ed-9d59-4155-bc68-9a370b3c51f6'; + + const incorrectFileId = `workflows/${workflowId}/executions/temp/binary_data/${binaryDataFileUuid}`; + + const run = toIRun({ + binary: { + data: { id: `s3:${incorrectFileId}` }, + }, + }); + + await restoreBinaryDataId(run, executionId); + + const correctFileId = incorrectFileId.replace('temp', executionId); + const correctBinaryDataId = `s3:${correctFileId}`; + + expect(binaryDataService.rename).toHaveBeenCalledWith(incorrectFileId, correctFileId); + expect(getDataId(run, 'binary')).toBe(correctBinaryDataId); + }); + + it('should do nothing if binary data ID is not missing execution ID', async () => { + const workflowId = '6HYhhKmJch2cYxGj'; + const executionId = '999'; + const binaryDataFileUuid = 'a5c3f1ed-9d59-4155-bc68-9a370b3c51f6'; + + const fileId = `workflows/${workflowId}/executions/${executionId}/binary_data/${binaryDataFileUuid}`; + + const binaryDataId = `s3:${fileId}`; + + const run = toIRun({ + binary: { + data: { + id: binaryDataId, + }, + }, + }); + + await restoreBinaryDataId(run, executionId); + + expect(binaryDataService.rename).not.toHaveBeenCalled(); + expect(getDataId(run, 'binary')).toBe(binaryDataId); + }); + + it('should do nothing if no binary data ID', async () => { + const executionId = '999'; + const dataId = '123'; + + const run = toIRun({ + json: { + data: { id: dataId }, + }, + }); + + await restoreBinaryDataId(run, executionId); + + expect(binaryDataService.rename).not.toHaveBeenCalled(); + expect(getDataId(run, 'json')).toBe(dataId); + }); + }); +}); From 39b318850a8f39a510fe38ff33e90136da4ec09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:39:25 +0200 Subject: [PATCH 252/259] Improve error message --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 39e7e8d95b65e..6a1925a770a4c 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -277,7 +277,7 @@ export class ObjectStoreService { } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); - const message = 'Request to S3 failed'; + const message = `Request to S3 failed: ${error.message}`; this.logger.error(message, { error, config }); From fe56d4ea256d014c7f1c4f47992680825c2530eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:44:27 +0200 Subject: [PATCH 253/259] Inject dependency --- packages/core/src/BinaryData/BinaryData.service.ts | 9 ++++++--- packages/core/src/BinaryData/ObjectStore.manager.ts | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index cb55ef14aea5d..e54f24ba9d82f 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -2,11 +2,11 @@ import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import { BINARY_ENCODING, LoggerProxy as Logger, IBinaryData } from 'n8n-workflow'; import { UnknownBinaryDataManagerError, InvalidBinaryDataModeError } from './errors'; -import { LogCatch } from '../decorators/LogCatch.decorator'; import { areValidModes, toBuffer } from './utils'; +import { LogCatch } from '../decorators/LogCatch.decorator'; import type { Readable } from 'stream'; import type { BinaryData } from './types'; @@ -27,6 +27,7 @@ export class BinaryDataService { if (config.availableModes.includes('filesystem')) { const { FileSystemManager } = await import('./FileSystem.manager'); + this.managers.filesystem = new FileSystemManager(config.localStoragePath); await this.managers.filesystem.init(); @@ -34,7 +35,9 @@ export class BinaryDataService { if (config.availableModes.includes('s3')) { const { ObjectStoreManager } = await import('./ObjectStore.manager'); - this.managers.s3 = new ObjectStoreManager(); + const { ObjectStoreService } = await import('../ObjectStore/ObjectStore.service.ee'); + + this.managers.s3 = new ObjectStoreManager(Container.get(ObjectStoreService)); await this.managers.s3.init(); } diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 8522d8f1b3bf9..9a6040b1b911a 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -1,5 +1,5 @@ import fs from 'node:fs/promises'; -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { toBuffer } from './utils'; import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; @@ -9,7 +9,7 @@ import type { BinaryData } from './types'; @Service() export class ObjectStoreManager implements BinaryData.Manager { - constructor(private readonly objectStoreService = Container.get(ObjectStoreService)) {} + constructor(private readonly objectStoreService: ObjectStoreService) {} async init() { await this.objectStoreService.checkConnection(); From 05f2467e456f673affb9e179ad6672ce6f8b1b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 10:47:58 +0200 Subject: [PATCH 254/259] Comment to clarify --- packages/core/test/ObjectStore.manager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/test/ObjectStore.manager.test.ts index 00559384e2a38..a9f23102fbaa6 100644 --- a/packages/core/test/ObjectStore.manager.test.ts +++ b/packages/core/test/ObjectStore.manager.test.ts @@ -51,7 +51,7 @@ describe('getPath()', () => { describe('getAsBuffer()', () => { it('should return a buffer', async () => { - // @ts-expect-error Overload signature seemingly causing Jest to misinfer the return type + // @ts-expect-error Overload signature seemingly causing the return type to be misinferred objectStoreService.get.mockResolvedValue(mockBuffer); const result = await objectStoreManager.getAsBuffer(fileId); From 02a54dac0adb4e2b668095c74f2fd3228a3223d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 28 Sep 2023 11:54:35 +0200 Subject: [PATCH 255/259] Refactor startup logic --- packages/cli/src/commands/BaseCommand.ts | 53 ++++++++++++++++--- packages/cli/src/commands/execute.ts | 1 - packages/cli/src/commands/executeBatch.ts | 1 - packages/cli/src/commands/start.ts | 1 - packages/cli/src/commands/webhook.ts | 1 - packages/cli/src/commands/worker.ts | 1 - packages/cli/src/config/schema.ts | 2 +- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 1 + 8 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 832bb8a2df03f..b908b6444cef1 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -129,6 +129,15 @@ export abstract class BaseCommand extends Command { async initObjectStoreService() { const isSelected = config.getEnv('binaryDataManager.mode') === 's3'; const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); + + if (!isSelected && !isAvailable) return; + + if (isSelected && !isAvailable) { + throw new Error( + 'External storage selected but unavailable. Please make external storage available by adding "s3" to `N8N_AVAILABLE_BINARY_DATA_MODES`.', + ); + } + const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); if (isSelected && isAvailable && isLicensed) { @@ -160,12 +169,6 @@ export abstract class BaseCommand extends Command { return; } - - if (isSelected && !isAvailable) { - throw new Error( - 'External storage selected but unavailable. Stopping n8n startup. Please make external storage available by adding "s3" to `N8N_AVAILABLE_BINARY_DATA_MODES`.', - ); - } } private async _initObjectStoreService(options = { isReadOnly: false }) { @@ -173,16 +176,46 @@ export abstract class BaseCommand extends Command { const host = config.getEnv('externalStorage.s3.host'); + if (host === '') { + throw new Error( + 'External storage host not configured. Please set `N8N_EXTERNAL_STORAGE_S3_HOST`.', + ); + } + const bucket = { name: config.getEnv('externalStorage.s3.bucket.name'), region: config.getEnv('externalStorage.s3.bucket.region'), }; + if (bucket.name === '') { + throw new Error( + 'External storage bucket name not configured. Please set `N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME`.', + ); + } + + if (bucket.region === '') { + throw new Error( + 'External storage bucket region not configured. Please set `N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION`.', + ); + } + const credentials = { accountId: config.getEnv('externalStorage.s3.credentials.accountId'), secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), }; + if (credentials.accountId === '') { + throw new Error( + 'External storage account ID not configured. Please set `N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID`.', + ); + } + + if (credentials.secretKey === '') { + throw new Error( + 'External storage secret key not configured. Please set `N8N_EXTERNAL_STORAGE_S3_SECRET_KEY`.', + ); + } + LoggerProxy.debug('Initializing object store service'); try { @@ -198,6 +231,14 @@ export abstract class BaseCommand extends Command { } async initBinaryDataService() { + try { + await this.initObjectStoreService(); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + LoggerProxy.error(`Failed to init object store: ${error.message}`, { error }); + process.exit(1); + } + const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); } diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index 749a4742c91da..f50d6c7f53d51 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -33,7 +33,6 @@ export class Execute extends BaseCommand { async init() { await super.init(); - await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index d37fa395c9a44..a20348c92e5da 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -180,7 +180,6 @@ export class ExecuteBatch extends BaseCommand { async init() { await super.init(); - await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index bbc26801b7c6a..676f8844fd9e0 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -213,7 +213,6 @@ export class Start extends BaseCommand { this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner); await this.initLicense(); - await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index e4e7cf36d1774..2c3ee358995d9 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -92,7 +92,6 @@ export class Webhook extends BaseCommand { await super.init(); await this.initLicense(); - await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index fde0ff8c750a4..5445b85f1fd9f 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -264,7 +264,6 @@ export class Worker extends BaseCommand { await this.initLicense(); - await this.initObjectStoreService(); await this.initBinaryDataService(); await this.initExternalHooks(); await this.initExternalSecrets(); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 88a74ac64a1f7..28420d59863dc 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -896,7 +896,7 @@ export const schema = { binaryDataManager: { availableModes: { format: 'comma-separated-list', - default: 'filesystem,s3', + default: 'filesystem', env: 'N8N_AVAILABLE_BINARY_DATA_MODES', doc: 'Available modes of binary data storage, as comma separated strings', }, diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md index 9ef425fefb126..220d3a2aa6c2c 100644 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ b/packages/core/src/ObjectStore/AWS-S3-SETUP.md @@ -65,6 +65,7 @@ export N8N_EXTERNAL_STORAGE_S3_SECRET_KEY=... Configure n8n to store binary data in S3. ```sh +export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 export N8N_DEFAULT_BINARY_DATA_MODE=s3 export N8N_EXTERNAL_STORAGE_S3_HOST=... ``` From a8c39edd9f255bc524a7900b724b52c62e20a75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 2 Oct 2023 10:46:52 +0200 Subject: [PATCH 256/259] Remove setup doc --- packages/core/src/ObjectStore/AWS-S3-SETUP.md | 89 ------------------- 1 file changed, 89 deletions(-) delete mode 100644 packages/core/src/ObjectStore/AWS-S3-SETUP.md diff --git a/packages/core/src/ObjectStore/AWS-S3-SETUP.md b/packages/core/src/ObjectStore/AWS-S3-SETUP.md deleted file mode 100644 index 220d3a2aa6c2c..0000000000000 --- a/packages/core/src/ObjectStore/AWS-S3-SETUP.md +++ /dev/null @@ -1,89 +0,0 @@ -# AWS S3 Setup - -n8n can use AWS S3 as an object store for binary data produced by workflow executions. - -Binary data is written to your n8n S3 bucket in this format: - -``` -workflows/{workflowId}/executions/{executionId}/binary_data/{binaryFileId} -``` - -Follow these instructions to set up an AWS S3 bucket as n8n's object store. - -## Create a bucket - -1. With your root user, [sign in](https://signin.aws.amazon.com/signin) to the AWS Management Console. - -2. Go to `S3` > `Create Bucket`. Name the bucket and select a region, ideally one close to your instance. Scroll to the bottom and `Create bucket`. Make a note of the bucket name and region. - -## Create a policy for the bucket - -3. Go to `IAM` > `Policies` > `Create Policy`. Select the `JSON` tab and paste the following policy. Replace `` with the name of the bucket you created in step 2. Click on `Next`. - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": ["s3:*"], - "Resource": ["arn:aws:s3:::", "arn:aws:s3:::/*"] - } - ] -} -``` - -4. Name the policy, scroll to the bottom, and `Create policy`. Make a note of the policy name. - -## Create a user and attach the policy to them - -5. Go to `IAM` > `Users` > `Create user`. - -6. Name the user and enable `Provide user with access to the AWS Management Console`, then `I want to create an IAM user` and `Next`. - -7. Click on `Attach policies directly`, then search and tick the checkbox for the policy you created in step 4. Click on `Next` and then `Create user`. Download the CSV with the user name, password, and console sign-in link. - -8. Click on `Return to users list`, access the user you just created, and select the `Security credentials` tab. Scroll down to the `Access key` section and click on `Create access key`. Select `Third-party service` or `Application running outside AWS` and `Next`. Click on `Create access key`. Download the CSV with the access key ID and secret access key. Click on `Done`. - -## Configure n8n to use S3 - -9. Set these environment variables using the bucket name and region from step 2. - -```sh -export N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME=... -export N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION=... -``` - -Set these environment variables using the credentials from step 8. - -```sh -export N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID=... -export N8N_EXTERNAL_STORAGE_S3_SECRET_KEY=... -``` - -Configure n8n to store binary data in S3. - -```sh -export N8N_AVAILABLE_BINARY_DATA_MODES=filesystem,s3 -export N8N_DEFAULT_BINARY_DATA_MODE=s3 -export N8N_EXTERNAL_STORAGE_S3_HOST=... -``` - -Examples of valid hosts: - -- `s3.us-east-1.amazonaws.com` -- `s3.us-east-005.backblazeb2.com` -- `dde5a538e0ffa1b202a3ddf2f20022b0.r2.cloudflarestorage.com` - -10. Activate an [Enterprise license key](https://docs.n8n.io/enterprise-key/) for your instance. - -## Usage notes - -- To inspect binary data in the n8n S3 bucket... - - You can use the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) with your access key ID and secret access key. - - You can also access the S3 section in the AWS Management Console with the details from step 7. - - You can query the AWS S3 API using n8n's AWS S3 node. -- If your license key has expired and you remain on S3 mode, the instance will be able to read from, but not write to, the S3 bucket. -- If your instance stored data in S3 and was later switched to filesystem mode, the instance will continue to read any data that was stored in S3, as long as `s3` remains listed in `N8N_AVAILABLE_BINARY_DATA_MODES` and as long as your S3 credentials remain valid. -- At this time, binary data pruning is based on the active binary data mode. For example, if your instance stored data in S3 and was later switched to filesystem mode, only binary data in the filesystem will be pruned. This may change in future. From 4e99a904011389a2fef9ce0478606d9680ce1ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 2 Oct 2023 11:03:47 +0200 Subject: [PATCH 257/259] Rename envs --- packages/cli/src/commands/BaseCommand.ts | 12 ++++++------ packages/cli/src/config/schema.ts | 12 ++++++------ .../core/src/ObjectStore/ObjectStore.service.ee.ts | 4 ++-- packages/core/src/ObjectStore/types.ts | 2 +- packages/core/test/ObjectStore.service.test.ts | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index b908b6444cef1..d5534fd078c8e 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -200,19 +200,19 @@ export abstract class BaseCommand extends Command { } const credentials = { - accountId: config.getEnv('externalStorage.s3.credentials.accountId'), - secretKey: config.getEnv('externalStorage.s3.credentials.secretKey'), + accessKey: config.getEnv('externalStorage.s3.credentials.accessKey'), + accessSecret: config.getEnv('externalStorage.s3.credentials.accessSecret'), }; - if (credentials.accountId === '') { + if (credentials.accessKey === '') { throw new Error( - 'External storage account ID not configured. Please set `N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID`.', + 'External storage access key not configured. Please set `N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY`.', ); } - if (credentials.secretKey === '') { + if (credentials.accessSecret === '') { throw new Error( - 'External storage secret key not configured. Please set `N8N_EXTERNAL_STORAGE_S3_SECRET_KEY`.', + 'External storage access secret not configured. Please set `N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET`.', ); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 28420d59863dc..45e91490720bc 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -937,17 +937,17 @@ export const schema = { }, }, credentials: { - accountId: { + accessKey: { format: String, default: '', - env: 'N8N_EXTERNAL_STORAGE_S3_ACCOUNT_ID', - doc: 'Account ID in S3-compatible external storage', + env: 'N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY', + doc: 'Access key in S3-compatible external storage', }, - secretKey: { + accessSecret: { format: String, default: '', - env: 'N8N_EXTERNAL_STORAGE_S3_SECRET_KEY', - doc: 'Secret key in S3-compatible external storage', + env: 'N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET', + doc: 'Access secret in S3-compatible external storage', }, }, }, diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 6a1925a770a4c..0417e37cb0614 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -39,8 +39,8 @@ export class ObjectStoreService { this.bucket.region = bucket.region; this.credentials = { - accessKeyId: credentials.accountId, - secretAccessKey: credentials.secretKey, + accessKeyId: credentials.accessKey, + secretAccessKey: credentials.accessSecret, }; await this.checkConnection(); diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/ObjectStore/types.ts index 5cb4ced5650e6..444630e924e94 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/ObjectStore/types.ts @@ -31,4 +31,4 @@ export type RequestOptions = { responseType?: ResponseType; }; -export type ConfigSchemaCredentials = { accountId: string; secretKey: string }; +export type ConfigSchemaCredentials = { accessKey: string; accessSecret: string }; diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/test/ObjectStore.service.test.ts index e0921bda9da75..bb87a0e0a5c1b 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/test/ObjectStore.service.test.ts @@ -10,7 +10,7 @@ const mockAxios = axios as jest.Mocked; const mockBucket = { region: 'us-east-1', name: 'test-bucket' }; const mockHost = `s3.${mockBucket.region}.amazonaws.com`; -const mockCredentials = { accountId: 'mock-account-id', secretKey: 'mock-secret-key' }; +const mockCredentials = { accessKey: 'mock-access-key', accessSecret: 'mock-secret-key' }; const mockUrl = `https://${mockHost}/${mockBucket.name}`; const FAILED_REQUEST_ERROR_MESSAGE = 'Request to S3 failed'; const mockError = new Error('Something went wrong!'); From deb607662d6b0d7f0af984ed0689372d66224263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 2 Oct 2023 12:29:25 +0200 Subject: [PATCH 258/259] Remove Axios error from log --- packages/core/src/ObjectStore/ObjectStore.service.ee.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts index 0417e37cb0614..cb6c33737338b 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/ObjectStore/ObjectStore.service.ee.ts @@ -279,7 +279,7 @@ export class ObjectStoreService { const message = `Request to S3 failed: ${error.message}`; - this.logger.error(message, { error, config }); + this.logger.error(message, { config }); throw new Error(message, { cause: { error, details: config } }); } From 7a0039c74c2d0d80c7528c3a8bc949f7bf1d77c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 2 Oct 2023 13:52:26 +0200 Subject: [PATCH 259/259] Better var naming --- packages/cli/src/License.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 5ed5b1cfc6e4b..3ddbd105cd7cd 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -105,11 +105,11 @@ export class License { }); } - const isSelected = config.getEnv('binaryDataManager.mode') === 's3'; - const isAvailable = config.getEnv('binaryDataManager.availableModes').includes('s3'); - const isLicensed = _features['feat:binaryDataS3']; + const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; + const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); + const isS3Licensed = _features['feat:binaryDataS3']; - if (isSelected && isAvailable && !isLicensed) { + if (isS3Selected && isS3Available && !isS3Licensed) { this.logger.debug( 'License changed with no support for external storage - blocking writes on object store. To restore writes, please upgrade to a license that supports this feature.', );