mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Use versioned prebuilt Daytona snapshots for Instance AI sandboxes (#29359)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ecd0ba8eba
commit
308d0b42b3
43
.github/workflows/release-build-daytona-snapshot.yml
vendored
Normal file
43
.github/workflows/release-build-daytona-snapshot.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: 'Release: Build Daytona snapshot'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
n8n_version:
|
||||
description: 'n8n version to build the Daytona snapshot for'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DAYTONA_API_KEY:
|
||||
required: true
|
||||
DAYTONA_API_URL:
|
||||
required: false
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
n8n_version:
|
||||
description: 'n8n version to build the Daytona snapshot for (e.g. 1.123.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-snapshot:
|
||||
name: Build versioned Daytona snapshot
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js and build
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
|
||||
- name: Build versioned Daytona snapshot
|
||||
env:
|
||||
N8N_VERSION: ${{ inputs.n8n_version }}
|
||||
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
||||
DAYTONA_API_URL: ${{ secrets.DAYTONA_API_URL }}
|
||||
run: node packages/@n8n/instance-ai/scripts/build-snapshot.cjs --version "$N8N_VERSION"
|
||||
11
.github/workflows/release-publish.yml
vendored
11
.github/workflows/release-publish.yml
vendored
|
|
@ -105,6 +105,15 @@ jobs:
|
|||
release_type: ${{ needs.determine-version-info.outputs.release_type }}
|
||||
secrets: inherit
|
||||
|
||||
build-daytona-snapshot:
|
||||
name: Build Daytona snapshot
|
||||
needs: [determine-version-info]
|
||||
if: github.event.pull_request.merged == true
|
||||
uses: ./.github/workflows/release-build-daytona-snapshot.yml
|
||||
with:
|
||||
n8n_version: ${{ needs.determine-version-info.outputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
create-github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
|
||||
|
|
@ -183,11 +192,13 @@ jobs:
|
|||
create-github-release,
|
||||
move-track-tag,
|
||||
promote-stable-tag,
|
||||
build-daytona-snapshot,
|
||||
]
|
||||
if: |
|
||||
always() &&
|
||||
needs.publish-to-npm.result == 'success' &&
|
||||
needs.create-github-release.result == 'success' &&
|
||||
needs.build-daytona-snapshot.result == 'success' &&
|
||||
(needs.move-track-tag.result == 'success' || needs.move-track-tag.result == 'skipped') &&
|
||||
(needs.promote-stable-tag.result == 'success' || needs.promote-stable-tag.result == 'skipped')
|
||||
uses: ./.github/workflows/release-publish-post-release.yml
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { defineConfig } from 'eslint/config';
|
|||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
ignores: ['scripts/**/*.cjs'],
|
||||
}, {
|
||||
rules: {
|
||||
// Mastra tool names are kebab-case identifiers (e.g. 'list-workflows')
|
||||
// which require quotes in object literals — skip naming checks for those
|
||||
|
|
|
|||
80
packages/@n8n/instance-ai/scripts/build-snapshot.cjs
Executable file
80
packages/@n8n/instance-ai/scripts/build-snapshot.cjs
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build a versioned Daytona snapshot for the running n8n version.
|
||||
*
|
||||
* Run from the n8n release pipeline (see
|
||||
* `.github/workflows/release-build-daytona-snapshot.yml`). Authenticates
|
||||
* with a static Daytona admin API key supplied via env vars and creates
|
||||
* the snapshot named `n8n-instance-ai-<version>` from the same image
|
||||
* descriptor used by the runtime fallback path. Re-runs against the same
|
||||
* version are idempotent — "already exists" is treated as success.
|
||||
*
|
||||
* The runtime never calls `snapshot.create` through the sandbox proxy in
|
||||
* cloud mode; CI is the only producer of cloud snapshots.
|
||||
*
|
||||
* The actual create-with-already-exists logic lives in
|
||||
* `SnapshotManager.createSnapshot` so the runtime (direct mode) and CI
|
||||
* share a single implementation.
|
||||
*
|
||||
* CommonJS so Node resolves the package via `main: dist/index.js` instead
|
||||
* of the bundler-only `module: src/index.ts` entry.
|
||||
*
|
||||
* Required env vars:
|
||||
* DAYTONA_API_KEY admin key with snapshot.create permissions
|
||||
* DAYTONA_API_URL Daytona API base URL (optional — SDK default used if absent)
|
||||
*
|
||||
* Usage:
|
||||
* node packages/@n8n/instance-ai/scripts/build-snapshot.cjs --version 1.123.0
|
||||
*/
|
||||
|
||||
const { Daytona } = require('@daytonaio/sdk');
|
||||
const { SnapshotManager } = require('@n8n/instance-ai');
|
||||
|
||||
function parseVersion(argv) {
|
||||
const flagIdx = argv.indexOf('--version');
|
||||
if (flagIdx !== -1 && argv[flagIdx + 1]) return argv[flagIdx + 1];
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--version=')) return arg.slice('--version='.length);
|
||||
}
|
||||
return process.env.N8N_VERSION;
|
||||
}
|
||||
|
||||
const consoleLogger = {
|
||||
info: (msg, meta) => console.log(JSON.stringify({ level: 'info', msg, ...meta })),
|
||||
warn: (msg, meta) => console.warn(JSON.stringify({ level: 'warn', msg, ...meta })),
|
||||
error: (msg, meta) => console.error(JSON.stringify({ level: 'error', msg, ...meta })),
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const version = parseVersion(process.argv.slice(2));
|
||||
if (!version) {
|
||||
console.error('Missing --version (or N8N_VERSION env)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apiKey = process.env.DAYTONA_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error('Missing DAYTONA_API_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
const apiUrl = process.env.DAYTONA_API_URL || undefined;
|
||||
|
||||
const daytona = new Daytona({ apiKey, apiUrl });
|
||||
const baseImage = process.env.SANDBOX_IMAGE || undefined;
|
||||
const manager = new SnapshotManager(baseImage, consoleLogger, version);
|
||||
|
||||
const name = await manager.createSnapshot(daytona, {
|
||||
timeout: 1800,
|
||||
onLogs: (chunk) => process.stdout.write(`${chunk}\n`),
|
||||
});
|
||||
|
||||
consoleLogger.info('Snapshot ready', { name });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
consoleLogger.error('Snapshot creation failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -5,3 +5,18 @@ export interface Logger {
|
|||
error(message: string, metadata?: Record<string, unknown>): void;
|
||||
debug(message: string, metadata?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal error-reporter contract — structurally compatible with the n8n-core
|
||||
* `ErrorReporter`. Wired to Sentry by the cli layer; defaults to a no-op when
|
||||
* the package is used standalone (e.g. in CI scripts and tests).
|
||||
*/
|
||||
export interface ErrorReporter {
|
||||
error(
|
||||
error: unknown,
|
||||
options?: {
|
||||
tags?: Record<string, string>;
|
||||
extra?: Record<string, unknown>;
|
||||
},
|
||||
): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,99 @@
|
|||
// Tight mocks so we can drive `createN8nSandbox` end-to-end in Jest without
|
||||
// touching real sandboxes, filesystems, or the Mastra runtime.
|
||||
jest.mock('@mastra/core/workspace', () => {
|
||||
class Workspace {
|
||||
sandbox: unknown;
|
||||
filesystem: unknown;
|
||||
constructor(opts: { sandbox: unknown; filesystem: unknown }) {
|
||||
this.sandbox = opts.sandbox;
|
||||
this.filesystem = opts.filesystem;
|
||||
}
|
||||
async init(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
// Mock external SDKs and other workspace modules so we can drive the factory
|
||||
// end-to-end in Jest without touching real sandboxes, filesystems, or the
|
||||
// Mastra runtime.
|
||||
|
||||
interface DaytonaCreateParams {
|
||||
snapshot?: string;
|
||||
image?: { dockerfile: string };
|
||||
language?: string;
|
||||
ephemeral?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DaytonaCreateOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const daytonaCreateMock = jest.fn<
|
||||
Promise<{ id: string }>,
|
||||
[DaytonaCreateParams, DaytonaCreateOptions?]
|
||||
>();
|
||||
const daytonaDeleteMock = jest.fn<Promise<void>, [unknown]>().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock('@daytonaio/sdk', () => {
|
||||
class Daytona {
|
||||
create = daytonaCreateMock;
|
||||
delete = daytonaDeleteMock;
|
||||
}
|
||||
class DaytonaError extends Error {
|
||||
statusCode?: number;
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
class LocalSandbox {}
|
||||
class LocalFilesystem {}
|
||||
return { Workspace, LocalSandbox, LocalFilesystem };
|
||||
class Image {
|
||||
dockerfile = 'FROM node:20';
|
||||
static base() {
|
||||
return new Image();
|
||||
}
|
||||
runCommands() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return { Daytona, DaytonaError, Image };
|
||||
});
|
||||
|
||||
jest.mock('@mastra/daytona', () => ({ DaytonaSandbox: class {} }));
|
||||
jest.mock('@daytonaio/sdk', () => ({ Daytona: class {} }));
|
||||
|
||||
jest.mock('../daytona-filesystem', () => ({
|
||||
DaytonaFilesystem: class {
|
||||
constructor(public sandbox: unknown) {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../n8n-sandbox-filesystem', () => ({
|
||||
N8nSandboxFilesystem: class {
|
||||
constructor(public sandbox: unknown) {}
|
||||
writeFile = jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../n8n-sandbox-image-manager', () => ({
|
||||
N8nSandboxImageManager: class {
|
||||
getDockerfile() {
|
||||
return 'FROM node:20';
|
||||
jest.mock('@mastra/core/workspace', () => {
|
||||
class LocalSandbox {
|
||||
constructor(public opts: unknown) {}
|
||||
}
|
||||
class LocalFilesystem {
|
||||
constructor(public opts: unknown) {}
|
||||
}
|
||||
class Workspace {
|
||||
sandbox: { processes?: { list: jest.Mock; kill: jest.Mock; get: jest.Mock } } & {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
filesystem: { writeFile: jest.Mock };
|
||||
constructor(public opts: { sandbox: unknown; filesystem: unknown }) {
|
||||
this.sandbox = {
|
||||
...(opts.sandbox as Record<string, unknown>),
|
||||
processes: {
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
kill: jest.fn().mockResolvedValue(undefined),
|
||||
get: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
this.filesystem = { writeFile: jest.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
},
|
||||
}));
|
||||
init = jest.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
return { LocalSandbox, LocalFilesystem, Workspace };
|
||||
});
|
||||
|
||||
jest.mock('@mastra/daytona', () => {
|
||||
class DaytonaSandbox {
|
||||
constructor(public opts: unknown) {}
|
||||
}
|
||||
return { DaytonaSandbox };
|
||||
});
|
||||
|
||||
jest.mock('../daytona-filesystem', () => {
|
||||
class DaytonaFilesystem {
|
||||
writeFile = jest.fn().mockResolvedValue(undefined);
|
||||
constructor(public sandbox: unknown) {}
|
||||
}
|
||||
return { DaytonaFilesystem };
|
||||
});
|
||||
|
||||
jest.mock('../n8n-sandbox-filesystem', () => {
|
||||
class N8nSandboxFilesystem {
|
||||
writeFile = jest.fn().mockResolvedValue(undefined);
|
||||
constructor(public sandbox: unknown) {}
|
||||
}
|
||||
return { N8nSandboxFilesystem };
|
||||
});
|
||||
|
||||
type MockN8nSandbox = { destroy: jest.Mock };
|
||||
const capturedSandboxes: MockN8nSandbox[] = [];
|
||||
|
|
@ -57,6 +109,28 @@ jest.mock('../n8n-sandbox-sandbox', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('../n8n-sandbox-image-manager', () => ({
|
||||
N8nSandboxImageManager: class {
|
||||
getDockerfile() {
|
||||
return 'FROM node:20';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../pack-workspace-sdk', () => ({
|
||||
packWorkspaceSdk: jest.fn().mockResolvedValue(null),
|
||||
isLinkWorkspaceSdkEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('../sandbox-setup', () => ({
|
||||
formatNodeCatalogLine: jest.fn((x: { name?: string }) => x.name ?? ''),
|
||||
getWorkspaceRoot: jest.fn(async () => await Promise.resolve('/home/daytona/workspace')),
|
||||
setupSandboxWorkspace: jest.fn(async () => await Promise.resolve()),
|
||||
PACKAGE_JSON: '{}',
|
||||
TSCONFIG_JSON: '{}',
|
||||
BUILD_MJS: '',
|
||||
}));
|
||||
|
||||
jest.mock('../sandbox-fs', () => ({
|
||||
runInSandbox: jest.fn(async () => await Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
|
||||
writeFileViaSandbox: jest.fn(async () => {
|
||||
|
|
@ -64,28 +138,39 @@ jest.mock('../sandbox-fs', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../sandbox-setup', () => ({
|
||||
getWorkspaceRoot: jest.fn(async () => await Promise.resolve('/workspace')),
|
||||
formatNodeCatalogLine: jest.fn((x: { name?: string }) => x.name ?? ''),
|
||||
setupSandboxWorkspace: jest.fn(async () => await Promise.resolve()),
|
||||
}));
|
||||
|
||||
import type { Logger } from '../../logger';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
import { BuilderSandboxFactory } from '../builder-sandbox-factory';
|
||||
import type { SandboxConfig } from '../create-workspace';
|
||||
import { SnapshotManager } from '../snapshot-manager';
|
||||
|
||||
const { BuilderSandboxFactory } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../builder-sandbox-factory') as typeof import('../builder-sandbox-factory');
|
||||
const NOOP_LOGGER: Logger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
function makeContext(): InstanceAiContext {
|
||||
return {
|
||||
nodeService: {
|
||||
listSearchable: jest.fn(async () => await Promise.resolve([{ name: 'node-a' }])),
|
||||
listSearchable: jest.fn().mockResolvedValue([{ name: 'node-a' }]),
|
||||
},
|
||||
} as unknown as InstanceAiContext;
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeConfig(): SandboxConfig {
|
||||
function makeDaytonaConfig(overrides: Partial<SandboxConfig> = {}): SandboxConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: 'daytona',
|
||||
daytonaApiKey: 'test-key',
|
||||
daytonaApiUrl: 'https://api.daytona.io',
|
||||
n8nVersion: '1.123.0',
|
||||
...overrides,
|
||||
} as SandboxConfig;
|
||||
}
|
||||
|
||||
function makeN8nSandboxConfig(): SandboxConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: 'n8n-sandbox',
|
||||
|
|
@ -94,6 +179,177 @@ function makeConfig(): SandboxConfig {
|
|||
} as SandboxConfig;
|
||||
}
|
||||
|
||||
describe('BuilderSandboxFactory createDaytona snapshot branching', () => {
|
||||
beforeEach(() => {
|
||||
daytonaCreateMock.mockReset();
|
||||
daytonaCreateMock.mockResolvedValue({ id: 'sandbox-id' });
|
||||
daytonaDeleteMock.mockClear();
|
||||
});
|
||||
|
||||
it('passes { snapshot } when ensureSnapshot returns a name', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue('n8n/instance-ai:1.123.0');
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(daytonaCreateMock).toHaveBeenCalledTimes(1);
|
||||
const [params] = daytonaCreateMock.mock.calls[0];
|
||||
expect(params.snapshot).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(params.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes { image } when ensureSnapshot returns null', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
|
||||
const ensureImageSpy = jest.spyOn(snapshotManager, 'ensureImage');
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(daytonaCreateMock).toHaveBeenCalledTimes(1);
|
||||
const [params] = daytonaCreateMock.mock.calls[0];
|
||||
expect(params.image).toBeDefined();
|
||||
expect(params.snapshot).toBeUndefined();
|
||||
expect(ensureImageSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes mode "direct" to ensureSnapshot when getAuthToken is absent', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
const ensureSnapshotSpy = jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(ensureSnapshotSpy).toHaveBeenCalledWith(expect.anything(), 'direct');
|
||||
});
|
||||
|
||||
it('passes mode "proxy" to ensureSnapshot when getAuthToken is present', async () => {
|
||||
const config = makeDaytonaConfig({
|
||||
getAuthToken: jest.fn().mockResolvedValue('jwt-token'),
|
||||
} as Partial<SandboxConfig>);
|
||||
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
const ensureSnapshotSpy = jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(ensureSnapshotSpy).toHaveBeenCalledWith(expect.anything(), 'proxy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuilderSandboxFactory createDaytona error reporting', () => {
|
||||
beforeEach(() => {
|
||||
daytonaCreateMock.mockReset();
|
||||
daytonaDeleteMock.mockClear();
|
||||
});
|
||||
|
||||
function makeManager(): SnapshotManager {
|
||||
const manager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
jest.spyOn(manager, 'ensureSnapshot').mockResolvedValue('n8n/instance-ai:1.123.0');
|
||||
jest.spyOn(manager, 'ensureImage').mockReturnValue({ dockerfile: 'FROM node:20' } as never);
|
||||
return manager;
|
||||
}
|
||||
|
||||
it('falls back to declarative image when create with snapshot fails', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = makeManager();
|
||||
const errorReporter = { error: jest.fn() };
|
||||
daytonaCreateMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Snapshot n8n/instance-ai:1.123.0 not found'), {
|
||||
statusCode: 400,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ id: 'sandbox-id' });
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(daytonaCreateMock).toHaveBeenCalledTimes(2);
|
||||
expect(daytonaCreateMock.mock.calls[0][0].snapshot).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytonaCreateMock.mock.calls[1][0].image).toBeDefined();
|
||||
expect(daytonaCreateMock.mock.calls[1][0].snapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports snapshot-strategy create failures to the error reporter', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = makeManager();
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const error = Object.assign(new Error('Snapshot not found'), { statusCode: 400 });
|
||||
daytonaCreateMock.mockRejectedValueOnce(error).mockResolvedValueOnce({ id: 'sandbox-id' });
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
|
||||
await factory.create('builder-1', makeContext());
|
||||
|
||||
expect(errorReporter.error).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
component: 'builder-sandbox-factory',
|
||||
strategy: 'snapshot',
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports image-strategy create failures and rethrows', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = new SnapshotManager('node:20', NOOP_LOGGER, '1.123.0');
|
||||
jest.spyOn(snapshotManager, 'ensureSnapshot').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(snapshotManager, 'ensureImage')
|
||||
.mockReturnValue({ dockerfile: 'FROM node:20' } as never);
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const error = new Error('Daytona is on fire');
|
||||
daytonaCreateMock.mockRejectedValue(error);
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
|
||||
|
||||
await expect(factory.create('builder-1', makeContext())).rejects.toThrow('Daytona is on fire');
|
||||
expect(errorReporter.error).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
component: 'builder-sandbox-factory',
|
||||
strategy: 'image',
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports both strategies and rethrows when both fail', async () => {
|
||||
const config = makeDaytonaConfig();
|
||||
const snapshotManager = makeManager();
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const snapshotError = Object.assign(new Error('Snapshot not found'), { statusCode: 400 });
|
||||
const imageError = new Error('Image build failed');
|
||||
daytonaCreateMock.mockRejectedValueOnce(snapshotError).mockRejectedValueOnce(imageError);
|
||||
|
||||
const factory = new BuilderSandboxFactory(config, snapshotManager, NOOP_LOGGER, errorReporter);
|
||||
|
||||
await expect(factory.create('builder-1', makeContext())).rejects.toThrow('Image build failed');
|
||||
expect(errorReporter.error).toHaveBeenCalledTimes(2);
|
||||
expect(errorReporter.error).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
snapshotError,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({ strategy: 'snapshot' }) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(errorReporter.error).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
imageError,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({ strategy: 'image' }) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
|
||||
beforeEach(() => {
|
||||
capturedSandboxes.length = 0;
|
||||
|
|
@ -108,7 +364,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
|
|||
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
|
||||
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
|
||||
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
|
||||
|
||||
await expect(factory.create('b-1', makeContext())).rejects.toThrow('setup boom');
|
||||
|
||||
|
|
@ -121,7 +377,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
|
|||
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
|
||||
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
|
||||
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
|
||||
const createPromise = factory.create('b-2', makeContext());
|
||||
|
||||
// Arrange: when createN8nSandbox tries to destroy after the error, that
|
||||
|
|
@ -134,7 +390,7 @@ describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
|
|||
});
|
||||
|
||||
it('returns a cleanup handle that destroys the sandbox when create succeeds', async () => {
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
const factory = new BuilderSandboxFactory(makeN8nSandboxConfig(), undefined);
|
||||
const bw = await factory.create('b-3', makeContext());
|
||||
|
||||
expect(capturedSandboxes).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
// Mock the Daytona SDK before importing — its source has require() paths that
|
||||
// jest can't resolve in this monorepo, and we don't need the real types here.
|
||||
jest.mock('@daytonaio/sdk', () => {
|
||||
class DaytonaError extends Error {
|
||||
statusCode?: number;
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message);
|
||||
this.name = 'DaytonaError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
class DaytonaNotFoundError extends DaytonaError {
|
||||
constructor(message: string, statusCode = 404) {
|
||||
super(message, statusCode);
|
||||
this.name = 'DaytonaNotFoundError';
|
||||
}
|
||||
}
|
||||
class Image {
|
||||
dockerfile = 'FROM node:20\nRUN echo mock';
|
||||
static base() {
|
||||
return new Image();
|
||||
}
|
||||
runCommands() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return { DaytonaError, DaytonaNotFoundError, Image };
|
||||
});
|
||||
|
||||
import { DaytonaError } from '@daytonaio/sdk';
|
||||
|
||||
import type { Logger } from '../../logger';
|
||||
import { SnapshotManager } from '../snapshot-manager';
|
||||
|
||||
const NOOP_LOGGER: Logger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
interface CreateSnapshotParams {
|
||||
name: string;
|
||||
image: { dockerfile: string };
|
||||
}
|
||||
|
||||
interface FakeSnapshotApi {
|
||||
get: jest.Mock<Promise<{ name: string }>, [string]>;
|
||||
create: jest.Mock<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>;
|
||||
}
|
||||
|
||||
interface FakeDaytona {
|
||||
snapshot: FakeSnapshotApi;
|
||||
}
|
||||
|
||||
function makeFakeDaytona(): FakeDaytona {
|
||||
return {
|
||||
snapshot: {
|
||||
get: jest.fn<Promise<{ name: string }>, [string]>(),
|
||||
create: jest.fn<Promise<{ name: string }>, [CreateSnapshotParams, unknown?]>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnapshotManager.createSnapshot', () => {
|
||||
it('returns the snapshot name on successful create', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
||||
const result = await manager.createSnapshot(daytona as never);
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
|
||||
const callArgs = daytona.snapshot.create.mock.calls[0][0];
|
||||
expect(callArgs).toEqual(expect.objectContaining({ name: 'n8n/instance-ai:1.123.0' }));
|
||||
expect(callArgs.image).toBeDefined();
|
||||
});
|
||||
|
||||
it('treats 409 conflict as success', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
|
||||
|
||||
const result = await manager.createSnapshot(daytona as never);
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
});
|
||||
|
||||
it('treats messages mentioning "already exists" as success', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(
|
||||
new DaytonaError('Snapshot with this name already exists', 400),
|
||||
);
|
||||
|
||||
const result = await manager.createSnapshot(daytona as never);
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
});
|
||||
|
||||
it('throws on transient errors', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(new DaytonaError('upstream 500', 500));
|
||||
|
||||
await expect(manager.createSnapshot(daytona as never)).rejects.toThrow('upstream 500');
|
||||
});
|
||||
|
||||
it('throws when no version is configured', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
|
||||
const daytona = makeFakeDaytona();
|
||||
|
||||
await expect(manager.createSnapshot(daytona as never)).rejects.toThrow();
|
||||
expect(daytona.snapshot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards options to daytona.snapshot.create', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
const onLogs = jest.fn();
|
||||
|
||||
await manager.createSnapshot(daytona as never, { timeout: 1800, onLogs });
|
||||
|
||||
expect(daytona.snapshot.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'n8n/instance-ai:1.123.0' }),
|
||||
expect.objectContaining({ timeout: 1800, onLogs }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnapshotManager.ensureSnapshot', () => {
|
||||
describe('when no version is provided', () => {
|
||||
it('returns null without calling daytona', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
|
||||
const daytona = makeFakeDaytona();
|
||||
|
||||
const result = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(daytona.snapshot.get).not.toHaveBeenCalled();
|
||||
expect(daytona.snapshot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null in proxy mode without calling daytona', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, undefined);
|
||||
const daytona = makeFakeDaytona();
|
||||
|
||||
const result = await manager.ensureSnapshot(daytona as never, 'proxy');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(daytona.snapshot.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy mode', () => {
|
||||
it('returns the snapshot name without calling daytona', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
|
||||
const result = await manager.ensureSnapshot(daytona as never, 'proxy');
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytona.snapshot.get).not.toHaveBeenCalled();
|
||||
expect(daytona.snapshot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct mode', () => {
|
||||
it('optimistically creates and returns the snapshot name', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
||||
const result = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
|
||||
expect(daytona.snapshot.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats 409 conflict as success', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
|
||||
|
||||
const result = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(result).toBe('n8n/instance-ai:1.123.0');
|
||||
});
|
||||
|
||||
it('returns null and clears memoization on transient errors', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create
|
||||
.mockRejectedValueOnce(new DaytonaError('upstream 500', 500))
|
||||
.mockResolvedValueOnce({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
||||
const first = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
const second = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(first).toBeNull();
|
||||
expect(second).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytona.snapshot.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('memoizes a successful create — does not call create twice', async () => {
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0');
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
||||
await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
const second = await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(second).toBe('n8n/instance-ai:1.123.0');
|
||||
expect(daytona.snapshot.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports transient failures via the error reporter', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
const error = new DaytonaError('upstream 500', 500);
|
||||
daytona.snapshot.create.mockRejectedValue(error);
|
||||
|
||||
await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(errorReporter.error).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({ component: 'snapshot-manager' }) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report when create succeeds', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockResolvedValue({ name: 'n8n/instance-ai:1.123.0' });
|
||||
|
||||
await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(errorReporter.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report 409/already-exists as an error', async () => {
|
||||
const errorReporter = { error: jest.fn() };
|
||||
const manager = new SnapshotManager(undefined, NOOP_LOGGER, '1.123.0', errorReporter);
|
||||
const daytona = makeFakeDaytona();
|
||||
daytona.snapshot.create.mockRejectedValue(new DaytonaError('already exists', 409));
|
||||
|
||||
await manager.ensureSnapshot(daytona as never, 'direct');
|
||||
|
||||
expect(errorReporter.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import { DaytonaSandbox } from '@mastra/daytona';
|
|||
import assert from 'node:assert/strict';
|
||||
import { join as posixJoin } from 'node:path/posix';
|
||||
|
||||
import type { Logger } from '../logger';
|
||||
import type { ErrorReporter, Logger } from '../logger';
|
||||
import type { SandboxConfig } from './create-workspace';
|
||||
import { DaytonaFilesystem } from './daytona-filesystem';
|
||||
import { N8nSandboxFilesystem } from './n8n-sandbox-filesystem';
|
||||
|
|
@ -76,6 +76,7 @@ export class BuilderSandboxFactory {
|
|||
private readonly config: SandboxConfig,
|
||||
private readonly imageManager?: SnapshotManager,
|
||||
private readonly logger: Logger = NOOP_LOGGER,
|
||||
private readonly errorReporter?: ErrorReporter,
|
||||
) {}
|
||||
|
||||
/** Cached workspace-SDK tarball promise (one pack per process). */
|
||||
|
|
@ -171,22 +172,60 @@ export class BuilderSandboxFactory {
|
|||
): Promise<BuilderWorkspace> {
|
||||
const config = this.assertIsDaytona();
|
||||
assert(this.imageManager, 'Daytona snapshot manager required');
|
||||
const snapshotManager = this.imageManager;
|
||||
|
||||
// Get pre-warmed image (config + deps, no catalog — catalog is too large for API body)
|
||||
const image = this.imageManager.ensureImage();
|
||||
const mode: 'direct' | 'proxy' = config.getAuthToken ? 'proxy' : 'direct';
|
||||
|
||||
// Start sandbox creation AND catalog generation in parallel
|
||||
// Resolve sandbox source — versioned named snapshot when available,
|
||||
// fallback to declarative image otherwise. Every Daytona create
|
||||
// failure is reported with a `strategy` tag so missing-snapshot bugs
|
||||
// are loud and trackable in Sentry, regardless of which path
|
||||
// ultimately succeeds.
|
||||
const createSandboxFn = async () => {
|
||||
const daytona = await this.getDaytona();
|
||||
return await daytona.create(
|
||||
{
|
||||
image,
|
||||
language: 'typescript',
|
||||
ephemeral: true,
|
||||
labels: { 'n8n-builder': builderId },
|
||||
},
|
||||
{ timeout: 300 },
|
||||
);
|
||||
const snapshotName = await snapshotManager.ensureSnapshot(daytona, mode);
|
||||
const baseParams = {
|
||||
language: 'typescript',
|
||||
ephemeral: true,
|
||||
labels: { 'n8n-builder': builderId },
|
||||
} as const;
|
||||
|
||||
if (snapshotName) {
|
||||
try {
|
||||
return await daytona.create({ ...baseParams, snapshot: snapshotName }, { timeout: 300 });
|
||||
} catch (error) {
|
||||
this.errorReporter?.error(error, {
|
||||
tags: {
|
||||
component: 'builder-sandbox-factory',
|
||||
strategy: 'snapshot',
|
||||
mode,
|
||||
},
|
||||
extra: { snapshotName, builderId },
|
||||
});
|
||||
this.logger.warn('Sandbox create from snapshot failed; falling back to image', {
|
||||
snapshotName,
|
||||
mode,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await daytona.create(
|
||||
{ ...baseParams, image: snapshotManager.ensureImage() },
|
||||
{ timeout: 300 },
|
||||
);
|
||||
} catch (error) {
|
||||
this.errorReporter?.error(error, {
|
||||
tags: {
|
||||
component: 'builder-sandbox-factory',
|
||||
strategy: 'image',
|
||||
mode,
|
||||
},
|
||||
extra: { builderId },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const [sandbox, catalog] = await Promise.all([createSandboxFn(), this.getNodeCatalog(context)]);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ interface DaytonaSandboxConfig extends SandboxConfigBase {
|
|||
daytonaApiUrl?: string;
|
||||
daytonaApiKey?: string;
|
||||
image?: string;
|
||||
/** Running n8n version, used to resolve a versioned prebuilt snapshot (`n8n-instance-ai-<version>`). */
|
||||
n8nVersion?: string;
|
||||
/** When provided, called before each Daytona interaction to get a fresh auth token (e.g. a short-lived JWT for proxy mode). */
|
||||
getAuthToken?: () => Promise<string>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,56 @@
|
|||
/**
|
||||
* Prepares and caches a Daytona Image descriptor with config files and
|
||||
* node_modules pre-installed. The Image is declarative — actual building
|
||||
* happens when a sandbox is created from it.
|
||||
* node_modules pre-installed, and resolves a versioned named snapshot
|
||||
* (`n8n/instance-ai:<n8nVersion>`) for sandbox creation.
|
||||
*
|
||||
* Two strategies for `ensureSnapshot`:
|
||||
* - 'direct' mode (self-hosted): optimistic create via `snapshot.create`.
|
||||
* Treats a 409 / "already exists" response as success. Any other failure
|
||||
* is reported (when an `errorReporter` is wired) and the manager falls
|
||||
* back to declarative image so the next request retries the create.
|
||||
* - 'proxy' mode (cloud): trusts CI to have published the snapshot for
|
||||
* this version and returns the name without an upstream call. The
|
||||
* sandbox-create request validates existence; missing-snapshot failures
|
||||
* surface there with a discriminable Daytona error.
|
||||
*
|
||||
* The node-types catalog is NOT baked into the image (too large for API body limit).
|
||||
* It's written to each sandbox after creation via the filesystem API.
|
||||
*
|
||||
* Exported as SnapshotManager for backward compatibility (name in index.ts/service).
|
||||
*/
|
||||
|
||||
import { Image } from '@daytonaio/sdk';
|
||||
import type { Daytona } from '@daytonaio/sdk';
|
||||
import { DaytonaError, Image } from '@daytonaio/sdk';
|
||||
|
||||
import type { Logger } from '../logger';
|
||||
import type { ErrorReporter, Logger } from '../logger';
|
||||
import { PACKAGE_JSON, TSCONFIG_JSON, BUILD_MJS } from './sandbox-setup';
|
||||
|
||||
export type SnapshotMode = 'direct' | 'proxy';
|
||||
|
||||
export interface CreateSnapshotOptions {
|
||||
timeout?: number;
|
||||
onLogs?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
/** Base64-encode content for safe embedding in RUN commands (avoids newline/quote issues). */
|
||||
function b64(s: string): string {
|
||||
return Buffer.from(s, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(error: unknown): boolean {
|
||||
if (!(error instanceof DaytonaError)) return false;
|
||||
if (error.statusCode === 409) return true;
|
||||
return /already exists/i.test(error.message);
|
||||
}
|
||||
|
||||
export class SnapshotManager {
|
||||
private cachedImage: Image | null = null;
|
||||
|
||||
private snapshotPromise: Promise<string | null> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly baseImage: string | undefined,
|
||||
private readonly logger: Logger,
|
||||
private readonly n8nVersion: string | undefined,
|
||||
private readonly errorReporter?: ErrorReporter,
|
||||
) {}
|
||||
|
||||
/** Get or prepare the image descriptor. Synchronous after first call. */
|
||||
|
|
@ -52,8 +78,77 @@ export class SnapshotManager {
|
|||
return this.cachedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the versioned Daytona snapshot for the configured n8n version.
|
||||
* Treats 409 / "already exists" as success — re-runs against the same
|
||||
* version are idempotent. Throws on transient or unexpected errors so
|
||||
* callers can decide whether to retry, fall back, or fail loudly.
|
||||
*
|
||||
* Single source of truth for snapshot creation across:
|
||||
* - Runtime direct mode (lazy create on first builder invocation)
|
||||
* - CI release pipeline (`scripts/build-snapshot.mjs`)
|
||||
*/
|
||||
async createSnapshot(daytona: Daytona, options?: CreateSnapshotOptions): Promise<string> {
|
||||
const name = this.snapshotName();
|
||||
try {
|
||||
await daytona.snapshot.create({ name, image: this.ensureImage() }, options);
|
||||
this.logger.info('Created versioned Daytona snapshot', { name });
|
||||
return name;
|
||||
} catch (error) {
|
||||
if (isAlreadyExistsError(error)) {
|
||||
this.logger.info('Versioned Daytona snapshot already exists', { name });
|
||||
return name;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the named snapshot for the running n8n version, returning the
|
||||
* snapshot name if it can be used, or null if the caller should fall back
|
||||
* to the declarative image.
|
||||
*
|
||||
* - 'proxy': returns the name without an upstream call. CI is the only
|
||||
* producer of cloud snapshots; trusting the name keeps the proxy
|
||||
* surface minimal (no `snapshot.get` allow-list needed). Existence is
|
||||
* validated implicitly by the subsequent `daytona.create({ snapshot })`.
|
||||
* - 'direct': lazy-creates the snapshot via `createSnapshot`, memoized
|
||||
* per process. On transient failure, clears the memo so the next
|
||||
* request retries, and reports the error.
|
||||
*/
|
||||
async ensureSnapshot(daytona: Daytona, mode: SnapshotMode): Promise<string | null> {
|
||||
if (!this.n8nVersion) return null;
|
||||
const name = this.snapshotName();
|
||||
|
||||
if (mode === 'proxy') return name;
|
||||
|
||||
this.snapshotPromise ??= this.createSnapshot(daytona).catch((error) => {
|
||||
this.errorReporter?.error(error, {
|
||||
tags: { component: 'snapshot-manager', operation: 'create-snapshot' },
|
||||
extra: { snapshotName: name },
|
||||
});
|
||||
this.logger.warn('Failed to create versioned snapshot; using declarative image', {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await this.snapshotPromise;
|
||||
if (result === null) this.snapshotPromise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private snapshotName(): string {
|
||||
if (!this.n8nVersion) {
|
||||
throw new Error('SnapshotManager: n8nVersion is required to derive a snapshot name');
|
||||
}
|
||||
return `n8n/instance-ai:${this.n8nVersion}`;
|
||||
}
|
||||
|
||||
/** Invalidate cached image (e.g., when base image changes). */
|
||||
invalidate(): void {
|
||||
this.cachedImage = null;
|
||||
this.snapshotPromise = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { InstanceAiConfig } from '@n8n/config';
|
||||
import { AiBuilderTemporaryWorkflowRepository, UserRepository, type User } from '@n8n/db';
|
||||
|
|
@ -229,6 +230,7 @@ export class InstanceAiService {
|
|||
private readonly telemetry: Telemetry,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
this.instanceAiConfig = globalConfig.instanceAi;
|
||||
|
|
@ -293,6 +295,7 @@ export class InstanceAiService {
|
|||
daytonaApiUrl: daytonaApiUrl || undefined,
|
||||
daytonaApiKey: daytonaApiKey || undefined,
|
||||
image: sandboxImage || undefined,
|
||||
n8nVersion: N8N_VERSION || undefined,
|
||||
timeout: sandboxTimeout,
|
||||
};
|
||||
}
|
||||
|
|
@ -363,8 +366,9 @@ export class InstanceAiService {
|
|||
if (config.provider === 'daytona') {
|
||||
return new BuilderSandboxFactory(
|
||||
config,
|
||||
new SnapshotManager(config.image, this.logger),
|
||||
new SnapshotManager(config.image, this.logger, config.n8nVersion, this.errorReporter),
|
||||
this.logger,
|
||||
this.errorReporter,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user