diff --git a/packages/plugins/apps/src/backend/discovery.ts b/packages/plugins/apps/src/backend/discovery.ts index f489d1480..5a74719cb 100644 --- a/packages/plugins/apps/src/backend/discovery.ts +++ b/packages/plugins/apps/src/backend/discovery.ts @@ -12,6 +12,8 @@ export interface BackendFunction { name: string; /** Absolute path to the .backend.ts source file */ absolutePath: string; + /** Connection IDs this backend function is allowed to use. */ + allowedConnectionIds: string[]; } /** diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index fd327cd6f..5daa06bdc 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -17,6 +17,7 @@ import { mockLogFn, } from '@dd/tests/_jest/helpers/mocks'; import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; @@ -149,10 +150,18 @@ describe('Apps Plugin - getPlugins', () => { ]; jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets); jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); - jest.spyOn(archive, 'createArchive').mockResolvedValue({ - archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', - assets: mockedAssets, - size: 10, + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: archiveAssets, + size: 10, + }; }); jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], @@ -163,12 +172,18 @@ describe('Apps Plugin - getPlugins', () => { await closeBundle(); expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot); - expect(archive.createArchive).toHaveBeenCalledWith([ - { - absolutePath: '/project/dist/index.js', - relativePath: path.join('frontend', 'dist/index.js'), - }, - ]); + expect(archive.createArchive).toHaveBeenCalledWith( + expect.arrayContaining([ + { + absolutePath: '/project/dist/index.js', + relativePath: path.join('frontend', 'dist/index.js'), + }, + expect.objectContaining({ + relativePath: 'manifest.json', + }), + ]), + ); + expect(manifest).toEqual({ backend: { functions: {} } }); expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { @@ -188,6 +203,94 @@ describe('Apps Plugin - getPlugins', () => { 'warn', ); expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-123')); + expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-')); + }); + + test('Should emit root manifest.json with backend function connection allowlists', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ + identifier: 'repo:app', + name: 'test-app', + }); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: [], + }); + + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-789/datadog-apps-assets.zip', + assets: archiveAssets, + size: 30, + }; + }); + + const viteBuild = jest.fn().mockResolvedValue({ + output: [ + { + type: 'chunk', + isEntry: true, + name: expect.any(String), + fileName: 'unused.greet.js', + }, + ], + }); + const args = getArgs(); + args.bundler = { build: viteBuild }; + const plugins = getPlugins(args); + const transform = plugins[0].transform as { + handler: (code: string, id: string) => unknown; + }; + transform.handler.call( + { + parse: () => ({ + type: 'Program', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'FunctionDeclaration', + id: { type: 'Identifier', name: 'greet' }, + }, + specifiers: [], + }, + ], + }), + }, + 'export function greet() {}', + '/project/src/backend/greet.backend.js', + ); + + await extractCloseBundle(plugins)(); + + expect(archive.createArchive).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ relativePath: 'manifest.json' }), + expect.objectContaining({ + relativePath: expect.stringMatching(/^backend\/.*\.greet\.js$/), + }), + ]), + ); + expect( + Object.keys((manifest as { backend: { functions: object } }).backend.functions), + ).toEqual([expect.stringMatching(/^[a-f0-9]{64}\.greet$/)]); + expect(manifest).toMatchObject({ + backend: { functions: expect.any(Object) }, + }); + expect( + Object.values( + (manifest as { backend: { functions: Record } }).backend.functions, + ), + ).toEqual([{ allowedConnectionIds: [] }]); }); test('Should surface upload errors', async () => { @@ -215,6 +318,7 @@ describe('Apps Plugin - getPlugins', () => { expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('upload failed'), 'error'); expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-456')); + expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-')); }); test('Should upload assets with vite bundler', async () => { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 13af8ff23..9b54e625c 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -6,6 +6,8 @@ import { rm } from '@dd/core/helpers/fs'; import type { GetPlugins } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; +import fsp from 'fs/promises'; +import os from 'os'; import path from 'path'; import { createArchive } from './archive'; @@ -17,7 +19,7 @@ import { encodeQueryName } from './backend/encodeQueryName'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; -import type { AppsOptions } from './types'; +import type { AppsManifest, AppsOptions } from './types'; import { uploadArchive } from './upload'; import { validateOptions } from './validate'; import { getVitePlugin } from './vite/index'; @@ -40,7 +42,12 @@ function buildProxyModule( const proxyExports: Array<{ exportName: string; queryName: string }> = []; for (const exportName of exportNames) { - const func = { relativePath: refPath, name: exportName, absolutePath: id }; + const func = { + relativePath: refPath, + name: exportName, + absolutePath: id, + allowedConnectionIds: [], + }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); } @@ -50,6 +57,7 @@ function buildProxyModule( const yellow = chalk.yellow.bold; const red = chalk.red.bold; +const MANIFEST_FILE_NAME = 'manifest.json'; /** * Create a registry for tracking discovered backend functions. @@ -71,6 +79,37 @@ function createBackendFunctionRegistry() { }; } +function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { + const functions: AppsManifest['backend']['functions'] = {}; + for (const func of backendFunctions) { + functions[encodeQueryName(func)] = { + allowedConnectionIds: [...func.allowedConnectionIds], + }; + } + return { backend: { functions } }; +} + +async function writeManifestFile(backendFunctions: BackendFunction[]): Promise<{ + manifestAsset: Asset; + cleanup: () => Promise; +}> { + const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); + const manifestPath = path.join(manifestDir, MANIFEST_FILE_NAME); + try { + await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2)); + } catch (error) { + await rm(manifestDir); + throw error; + } + return { + manifestAsset: { + absolutePath: manifestPath, + relativePath: MANIFEST_FILE_NAME, + }, + cleanup: () => rm(manifestDir), + }; +} + export type types = { // Add the types you'd like to expose here. AppsOptions: AppsOptions; @@ -109,6 +148,7 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => { const handleUpload = async (backendOutputs: Map) => { const handleTimer = log.time('handle assets'); let archiveDir: string | undefined; + let cleanupManifest: (() => Promise) | undefined; try { const identifierTimer = log.time('resolve identifier'); @@ -158,6 +198,11 @@ Either: }); } + const backendFunctions = getBackendFunctions(); + const { manifestAsset, cleanup } = await writeManifestFile(backendFunctions); + cleanupManifest = cleanup; + allAssets.push(manifestAsset); + const archiveTimer = log.time('archive assets'); const archive = await createArchive(allAssets); archiveTimer.end(); @@ -202,6 +247,9 @@ Either: if (archiveDir) { await rm(archiveDir); } + if (cleanupManifest) { + await cleanupManifest(); + } handleTimer.end(); if (toThrow) { diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index f62ffd83b..f53ac1a6a 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -12,5 +12,17 @@ export type AppsOptions = { name?: string; }; +export type AppsManifest = { + backend: { + /** Mapping of encoded query name to information about that backend function. */ + functions: Record< + string, + { + allowedConnectionIds: string[]; + } + >; + }; +}; + // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = WithRequired; diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 2c7b203a0..4592e9437 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -20,11 +20,13 @@ const mockFunctions: BackendFunction[] = [ relativePath: 'backend/greet', name: 'greet', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'backend/compute', name: 'compute', absolutePath: '/project/backend/compute.backend.ts', + allowedConnectionIds: [], }, ]; @@ -320,13 +322,28 @@ describe('Dev Server Middleware', () => { test('Should call Datadog API with correct endpoint and return result', async () => { mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + type PreviewAsyncBody = { + data: { + attributes: { + query: { + properties: { + spec: { inputs: { allowedConnectionIds: string[] } }; + }; + }; + }; + }; + }; + let capturedBody: PreviewAsyncBody | undefined; const apiScope = nock(`https://${DD_SITE}`, { reqheaders: { 'DD-API-KEY': 'test-api-key', 'DD-APPLICATION-KEY': 'test-app-key', }, }) - .post('/api/v2/app-builder/queries/preview-async') + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedBody = body as PreviewAsyncBody; + return true; + }) .reply(200, { data: { id: 'receipt-1' } }) .get('/api/v2/app-builder/queries/execution-long-polling/receipt-1') .reply(200, { @@ -347,6 +364,66 @@ describe('Dev Server Middleware', () => { expect(body.success).toBe(true); expect(body.result).toEqual({ data: { value: 42 } }); expect(apiScope.isDone()).toBe(true); + expect( + capturedBody?.data.attributes.query.properties.spec.inputs.allowedConnectionIds, + ).toEqual([]); + }); + + test('Should forward the selected backend function allowedConnectionIds', async () => { + mockViteBuild.mockResolvedValue(mockBuildResult('// code')); + + const functionsWithAllowlist: BackendFunction[] = [ + mockFunctions[0], + { + ...mockFunctions[1], + allowedConnectionIds: ['conn-1', 'conn-2'], + }, + ]; + const middlewareWithAllowlist = createDevServerMiddleware( + mockViteBuild, + () => functionsWithAllowlist, + mockAuth, + '/project', + mockLog, + ); + + type PreviewAsyncBody = { + data: { + attributes: { + query: { + properties: { + spec: { inputs: { allowedConnectionIds: string[] } }; + }; + }; + }; + }; + }; + let capturedBody: PreviewAsyncBody | undefined; + const apiScope = nock(`https://${DD_SITE}`) + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedBody = body as PreviewAsyncBody; + return true; + }) + .reply(200, { data: { id: 'receipt-allowlist' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-allowlist') + .reply(200, { + data: { attributes: { done: true, outputs: { data: { ok: true } } } }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(functionsWithAllowlist[1]), + args: [], + }); + const res = createMockResponse(); + + middlewareWithAllowlist(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + expect(apiScope.isDone()).toBe(true); + expect( + capturedBody?.data.attributes.query.properties.spec.inputs.allowedConnectionIds, + ).toEqual(['conn-1', 'conn-2']); }); test('Should handle errors array from long-polling endpoint', async () => { @@ -422,6 +499,7 @@ describe('Dev Server Middleware', () => { relativePath: 'backend/greet', name: 'greetV2', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, mockFunctions[1], ]; diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index f2ecbb43b..d3d8a2821 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -114,11 +114,12 @@ async function bundleBackendFunction( */ async function executeScriptViaDatadog( scriptBody: string, - displayName: string, + func: BackendFunction, auth: AuthConfig, log: Logger, ): Promise { const endpoint = `https://${auth.site}/api/v2/app-builder/queries/preview-async`; + const displayName = formatRef(func); log.debug(`Calling Datadog API: ${endpoint}`); @@ -133,7 +134,10 @@ async function executeScriptViaDatadog( properties: { spec: { fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', - inputs: { script: scriptBody }, + inputs: { + script: scriptBody, + allowedConnectionIds: func.allowedConnectionIds, + }, }, onlyTriggerManually: true, }, @@ -248,7 +252,7 @@ async function validateAndBundle( req: IncomingMessage, functionsByName: Map, bundle: BundleFn, -): Promise<{ displayName: string; code: string }> { +): Promise<{ func: BackendFunction; code: string }> { const { functionName, args = [] } = await parseRequestBody(req); if (!functionName || typeof functionName !== 'string') { @@ -261,7 +265,7 @@ async function validateAndBundle( } const code = await bundle(func, args); - return { displayName: formatRef(func), code }; + return { func, code }; } /** @@ -298,11 +302,12 @@ async function handleExecuteAction( log: Logger, ): Promise { try { - const { displayName, code } = await validateAndBundle(req, functionsByName, bundle); + const { func, code } = await validateAndBundle(req, functionsByName, bundle); + const displayName = formatRef(func); log.debug(`Executing action: ${displayName} with args`); - const result = await executeScriptViaDatadog(code, displayName, auth, log); + const result = await executeScriptViaDatadog(code, func, auth, log); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 5d923f8a7..b39465640 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -15,11 +15,13 @@ const functions: BackendFunction[] = [ relativePath: 'src/backend/myHandler', name: 'myHandler', absolutePath: '/src/backend/myHandler.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'src/backend/otherFunc', name: 'otherFunc', absolutePath: '/src/backend/otherFunc.backend.ts', + allowedConnectionIds: [], }, ]; diff --git a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts index 9e3f4768c..d89cfcd71 100644 --- a/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts +++ b/packages/tests/src/e2e/appsPlugin/appsPlugin.spec.ts @@ -178,9 +178,9 @@ describe('Apps Plugin', () => { const filePaths = Object.keys(zip.files); expect(filePaths.length).toBeGreaterThan(0); - // Every file should be under frontend/ or backend/. + // Every file should be under frontend/ or backend/, except the root manifest. for (const filePath of filePaths) { - expect(filePath).toMatch(/^(frontend|backend)\//); + expect(filePath).toMatch(/^(frontend|backend)\/|^manifest\.json$/); } // There should be at least one frontend asset. @@ -213,5 +213,18 @@ describe('Apps Plugin', () => { const greetContent = await zip.file(greetFile!)!.async('string'); expect(greetContent).toContain('main'); expect(greetContent).toContain('greet'); + + const manifestContent = await zip.file('manifest.json')!.async('string'); + const manifest = JSON.parse(manifestContent); + const greetName = greetFile!.replace(/^backend\//, '').replace(/\.js$/, ''); + expect(manifest).toEqual({ + backend: { + functions: { + [greetName]: { + allowedConnectionIds: [], + }, + }, + }, + }); }); });