diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index a09626633..d43a90698 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -19,6 +19,12 @@ "format": [ "esm" ] + }, + "local-exec-child": { + "entry": "./src/vite/local-exec-child.js", + "format": [ + "cjs" + ] } } }, diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 263df3e89..fca295d81 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -208,7 +208,34 @@ describe('Dev Server Middleware', () => { expect(res.end).toHaveBeenCalled(); }); - test('Should handle /__dd/executeAction POST', async () => { + test('Should handle /__dd/executeAction POST by running the bundle locally, not via the Datadog API', async () => { + mockBuildWithParsedBackend( + 'export async function main($) { return { echo: $.backendFunctionArgs }; }', + ); + + // No nock scope registered -- if the middleware still called the + // Datadog API for this endpoint, the request would fail outright + // (nock throws on unmocked requests by default), so an unmocked + // 200 here already proves local execution, not the cloud path. + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: ['world'], + }); + const res = createMockResponse(); + const next = jest.fn(); + + middleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { echo: ['world'] } }); + }, 20_000); + + test('Should handle /__dd/executeActionViaCloud POST', async () => { mockBuildWithParsedBackend(); // Mock the Datadog API via nock. @@ -225,7 +252,7 @@ describe('Dev Server Middleware', () => { }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: ['world'], }); @@ -321,7 +348,7 @@ describe('Dev Server Middleware', () => { }); }); - describe('executeAction handler', () => { + describe('executeAction handler (local execution)', () => { const middleware = createDevServerMiddleware( mockViteBuild, () => mockFunctions, @@ -353,6 +380,105 @@ describe('Dev Server Middleware', () => { expect(res.statusCode).toBe(404); }); + test('Should run the bundle in a real forked child process and return its result', async () => { + mockBuildWithParsedBackend( + 'export async function main($) { return { doubled: $.backendFunctionArgs[0] * 2 }; }', + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [21], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { doubled: 42 } }); + }, 20_000); + + test('Should propagate a real crash in the bundled function as a 500, not a hang', async () => { + mockBuildWithParsedBackend( + "export async function main() { throw new Error('deliberate crash'); }", + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(500); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(false); + expect(body.error).toContain('deliberate crash'); + }, 20_000); + + test('Should work without any Datadog credentials configured, unlike executeActionViaCloud', async () => { + const noAuthMiddleware = createDevServerMiddleware( + mockViteBuild, + () => mockFunctions, + mockOauthOnlyAuth, + undefined, + '/project', + mockLog, + ); + mockBuildWithParsedBackend('export async function main() { return { ok: true }; }'); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + noAuthMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { ok: true } }); + }, 20_000); + }); + + describe('executeActionViaCloud handler', () => { + const middleware = createDevServerMiddleware( + mockViteBuild, + () => mockFunctions, + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + ); + + test('Should return 400 for missing functionRef', async () => { + const req = createMockRequest('/__dd/executeActionViaCloud', {}); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(400); + }); + + test('Should return 404 for unknown function', async () => { + const req = createMockRequest('/__dd/executeActionViaCloud', { + functionName: 'nonexistent.nonexistent', + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(404); + }); + /* * The nock mock replies with 403 to simulate the upstream Datadog API * rejecting the request (e.g. bad credentials). The middleware still @@ -369,7 +495,7 @@ describe('Dev Server Middleware', () => { .post('/api/v2/app-builder/queries/preview-async') .reply(403, 'Forbidden'); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -421,7 +547,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { value: 42 } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: ['hello', 42], }); @@ -469,7 +595,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -495,7 +621,7 @@ describe('Dev Server Middleware', () => { mockLog, ); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -546,7 +672,7 @@ describe('Dev Server Middleware', () => { }); const trickyArgs = ["don't break", "'); alert(1); //", '😀']; - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: trickyArgs, }); @@ -605,7 +731,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(functionsWithAllowlist[1]), args: [], }); @@ -660,7 +786,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -687,7 +813,7 @@ describe('Dev Server Middleware', () => { errors: [{ title: 'ExecutionFailed', detail: 'Script threw an error' }], }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -715,7 +841,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 3d0c78d58..b04cfe2f1 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -18,6 +18,7 @@ import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; +import { executeScriptLocally } from './local-execution'; interface BundleResult { func: BackendFunction; @@ -308,9 +309,52 @@ async function handleDebugBundle( } /** - * Handle POST /__dd/executeAction — bundles a backend function and executes it via Datadog API. + * Handle POST /__dd/executeAction — bundles a backend function and runs it + * locally in a forked Node child process (see local-execution.ts). This is + * the new default per the local Node execution design doc: `npm run dev` + * unconditionally uses local execution, no customer-facing toggle. + * + * NOTE: `$.Actions` calls are currently stubbed (see local-execution.ts's + * executeActionRemotely) -- the single-action execution endpoint this needs + * doesn't exist publicly yet (design doc's "Open Dependency" section). Real + * action results are only available via /__dd/executeActionViaCloud below + * until that resolves. */ async function handleExecuteAction( + req: IncomingMessage, + res: ServerResponse, + functionsByName: Map, + bundle: BundleFn, + log: Logger, +): Promise { + try { + const { func, code, args } = await validateAndBundle(req, functionsByName, bundle); + const displayName = formatRef(func); + + log.debug(`Executing action locally: ${displayName} with args`); + + const result = await executeScriptLocally(code, func, args, log); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse)); + } catch (error: unknown) { + const statusCode = error instanceof HttpError ? error.statusCode : 500; + const message = error instanceof Error ? error.message : 'Internal server error'; + log.debug(`Error handling executeAction: ${message}`); + sendError(res, statusCode, message); + } +} + +/** + * Handle POST /__dd/executeActionViaCloud — bundles a backend function and + * executes it via Datadog's cloud API (today's `preview-async` + long-poll + * round trip), unchanged from before local execution existed. Preserves this + * path for pre-publish parity verification (the design doc's "Mode B") -- + * not yet wired to an `npm run dev:verify` script (that requires changes to + * the create-apps scaffolding templates, outside this package's scope). + */ +async function handleExecuteActionViaCloud( req: IncomingMessage, res: ServerResponse, functionsByName: Map, @@ -323,7 +367,7 @@ async function handleExecuteAction( const { func, code, args } = await validateAndBundle(req, functionsByName, bundle); const displayName = formatRef(func); - log.debug(`Executing action: ${displayName} with args`); + log.debug(`Executing action via cloud: ${displayName} with args`); const result = await executeScriptViaDatadog( code, @@ -340,7 +384,7 @@ async function handleExecuteAction( } catch (error: unknown) { const statusCode = error instanceof HttpError ? error.statusCode : 500; const message = error instanceof Error ? error.message : 'Internal server error'; - log.debug(`Error handling executeAction: ${message}`); + log.debug(`Error handling executeActionViaCloud: ${message}`); sendError(res, statusCode, message); } } @@ -380,7 +424,7 @@ export function createDevServerMiddleware( if (!doAuthenticatedRequest) { log.warn( - `Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ${AUTH_GUIDANCE}`, + `Auth credentials not configured. The /__dd/executeActionViaCloud endpoint will be unavailable. ${AUTH_GUIDANCE}`, ); } @@ -397,11 +441,19 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { + // Local execution doesn't need Datadog credentials today -- + // `$.Actions` calls are stubbed until the single-action execution + // endpoint exists (see local-execution.ts). Unlike the cloud path + // below, this endpoint works without any auth configured. + handleExecuteAction(req, res, functionsByName, bundle, log).catch(() => { + sendError(res, 500, 'Unexpected error'); + }); + } else if (req.url === '/__dd/executeActionViaCloud') { if (!doAuthenticatedRequest) { sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); return; } - handleExecuteAction( + handleExecuteActionViaCloud( req, res, functionsByName, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 831ce75c2..f3af0768f 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -23,6 +23,7 @@ import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; import { createDevServerMiddleware } from './dev-server'; import { handleUpload } from './handle-upload'; +import { killAllLocalExecutionChildren } from './local-execution'; export type ViteBundler = { build: typeof build; @@ -210,6 +211,13 @@ export const getVitePlugin = ({ log, ), ); + + // Local execution forks a real child process per backend-function + // call (see local-execution.ts). Without this, killing the dev + // server mid-execution would leave that child orphaned. + server.httpServer?.once('close', () => { + killAllLocalExecutionChildren(); + }); }, }; }; diff --git a/packages/plugins/apps/src/vite/local-exec-child.js b/packages/plugins/apps/src/vite/local-exec-child.js index b28609ae7..bbcad5228 100644 --- a/packages/plugins/apps/src/vite/local-exec-child.js +++ b/packages/plugins/apps/src/vite/local-exec-child.js @@ -68,13 +68,33 @@ function makeActionsProxy(pathParts = []) { }); } +// @datadog/apps-backend's buildRuntimeFromJsFunctionWithActions (injected into +// every bundle when that package is installed, regardless of which specific +// function is called -- see virtual-entry.ts's SET_BACKEND_CONTEXT_SNIPPET) +// requires $.Source.{initiator,runAsUser} to each be a User object with +// non-empty id/orgId strings. In production this comes from the real +// workflow-execution's trigger/run-as identity (domains/workflow's Go proto +// data); no equivalent identity exists for a local fork, and no impersonation +// (initiator vs. runAsUser) is meaningful when one developer is running their +// own code locally, so both roles use the same clearly-synthetic identity. +const LOCAL_DEV_USER = { + id: 'local-dev-user', + orgId: 'local-dev-org', + email: null, + name: 'Local Development', +}; + process.on('message', async function onExecute(msg) { if (!msg || msg.type !== 'execute') { return; } try { - const $ = { backendFunctionArgs: msg.backendFunctionArgs, Actions: makeActionsProxy() }; + const $ = { + backendFunctionArgs: msg.backendFunctionArgs, + Actions: makeActionsProxy(), + Source: { initiator: LOCAL_DEV_USER, runAsUser: LOCAL_DEV_USER }, + }; globalThis.$ = $; // The real bundled code (from vite.build(), format:'es', no externals diff --git a/packages/plugins/apps/src/vite/local-execution.integration.test.ts b/packages/plugins/apps/src/vite/local-execution.integration.test.ts index 0208a8db6..a593e5c94 100644 --- a/packages/plugins/apps/src/vite/local-execution.integration.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.integration.test.ts @@ -15,7 +15,7 @@ */ import { outputFileSync } from '@dd/core/helpers/fs'; -import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env'; +import { getTempWorkingDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { build } from 'vite'; @@ -247,4 +247,47 @@ describe('executeScriptLocally (real bundle, no mocks)', () => { await expect(execution).rejects.toThrow(); expect(child?.killed).toBe(true); }, 20_000); + + test('supplies a valid $.Source so @datadog/apps-backend does not throw, when the package is installed', async () => { + // Unlike the other tests in this file (a bare temp dir with only the + // one .backend.ts file written into it), this uses the real fixture + // project tree, which has a real @datadog/apps-backend installed -- + // matching a real scaffolded app, and the exact condition that + // exposed this bug: isDatadogAppsBackendInstalled(workingDir) resolves + // true here, so generateDevVirtualEntryContent injects + // SET_BACKEND_CONTEXT_SNIPPET, which throws unless $.Source is valid. + const workingDir = await prepareWorkingDir(`local-exec-poc-apps-backend-${Date.now()}`); + const sourceCode = ` + import { getExecutionUser, getInitiatingUser } from '@datadog/apps-backend/user'; + export async function usesSdk() { + const [executionUser, initiatingUser] = await Promise.all([ + getExecutionUser(), + getInitiatingUser(), + ]); + return { executionUser, initiatingUser }; + } + `; + const code = await bundleRealBackendFunction(workingDir, 'usesSdk', sourceCode); + + // Confirms the bundle actually took the @datadog/apps-backend branch + // (the bug this test guards would otherwise be silently untested if + // the fixture stopped resolving as installed for some other reason). + // Asserts on the snippet's literal comment, not the imported + // identifiers -- Rollup renames those during bundling. + expect(code).toContain('Supply the backend runtime context'); + + const func: BackendFunction = { + relativePath: 'src/usesSdk', + name: 'usesSdk', + absolutePath: `${workingDir}/src/usesSdk.backend.ts`, + allowedConnectionIds: [], + }; + + const outputs = await executeScriptLocally(code, func, [], log); + + expect(outputs.data).toMatchObject({ + executionUser: { id: 'local-dev-user', orgId: 'local-dev-org' }, + initiatingUser: { id: 'local-dev-user', orgId: 'local-dev-org' }, + }); + }, 20_000); });