Skip to content

Commit 8aeb377

Browse files
committed
[APPS] Add backend connection manifest upload plumbing
1 parent f4a94d6 commit 8aeb377

8 files changed

Lines changed: 285 additions & 21 deletions

File tree

packages/plugins/apps/src/backend/discovery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface BackendFunction {
1212
name: string;
1313
/** Absolute path to the .backend.ts source file */
1414
absolutePath: string;
15+
/** Connection IDs this backend function is allowed to use. */
16+
allowedConnectionIds: string[];
1517
}
1618

1719
/**

packages/plugins/apps/src/index.test.ts

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
mockLogFn,
1818
} from '@dd/tests/_jest/helpers/mocks';
1919
import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers';
20+
import fsp from 'fs/promises';
2021
import nock from 'nock';
2122
import path from 'path';
2223

@@ -149,10 +150,18 @@ describe('Apps Plugin - getPlugins', () => {
149150
];
150151
jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets);
151152
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
152-
jest.spyOn(archive, 'createArchive').mockResolvedValue({
153-
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
154-
assets: mockedAssets,
155-
size: 10,
153+
let manifest: unknown;
154+
jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => {
155+
const manifestAsset = archiveAssets.find(
156+
(asset) => asset.relativePath === 'manifest.json',
157+
);
158+
expect(manifestAsset).toBeDefined();
159+
manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8'));
160+
return {
161+
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
162+
assets: archiveAssets,
163+
size: 10,
164+
};
156165
});
157166
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({
158167
errors: [],
@@ -163,12 +172,18 @@ describe('Apps Plugin - getPlugins', () => {
163172
await closeBundle();
164173

165174
expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot);
166-
expect(archive.createArchive).toHaveBeenCalledWith([
167-
{
168-
absolutePath: '/project/dist/index.js',
169-
relativePath: path.join('frontend', 'dist/index.js'),
170-
},
171-
]);
175+
expect(archive.createArchive).toHaveBeenCalledWith(
176+
expect.arrayContaining([
177+
{
178+
absolutePath: '/project/dist/index.js',
179+
relativePath: path.join('frontend', 'dist/index.js'),
180+
},
181+
expect.objectContaining({
182+
relativePath: 'manifest.json',
183+
}),
184+
]),
185+
);
186+
expect(manifest).toEqual({ backend: { functions: {} } });
172187
expect(uploader.uploadArchive).toHaveBeenCalledWith(
173188
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
174189
{
@@ -188,6 +203,94 @@ describe('Apps Plugin - getPlugins', () => {
188203
'warn',
189204
);
190205
expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-123'));
206+
expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-'));
207+
});
208+
209+
test('Should emit root manifest.json with backend function connection allowlists', async () => {
210+
jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({
211+
identifier: 'repo:app',
212+
name: 'test-app',
213+
});
214+
jest.spyOn(assets, 'collectAssets').mockResolvedValue([
215+
{ absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' },
216+
]);
217+
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
218+
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({
219+
errors: [],
220+
warnings: [],
221+
});
222+
223+
let manifest: unknown;
224+
jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => {
225+
const manifestAsset = archiveAssets.find(
226+
(asset) => asset.relativePath === 'manifest.json',
227+
);
228+
expect(manifestAsset).toBeDefined();
229+
manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8'));
230+
return {
231+
archivePath: '/tmp/dd-apps-789/datadog-apps-assets.zip',
232+
assets: archiveAssets,
233+
size: 30,
234+
};
235+
});
236+
237+
const viteBuild = jest.fn().mockResolvedValue({
238+
output: [
239+
{
240+
type: 'chunk',
241+
isEntry: true,
242+
name: expect.any(String),
243+
fileName: 'unused.greet.js',
244+
},
245+
],
246+
});
247+
const args = getArgs();
248+
args.bundler = { build: viteBuild };
249+
const plugins = getPlugins(args);
250+
const transform = plugins[0].transform as {
251+
handler: (code: string, id: string) => unknown;
252+
};
253+
transform.handler.call(
254+
{
255+
parse: () => ({
256+
type: 'Program',
257+
body: [
258+
{
259+
type: 'ExportNamedDeclaration',
260+
declaration: {
261+
type: 'FunctionDeclaration',
262+
id: { type: 'Identifier', name: 'greet' },
263+
},
264+
specifiers: [],
265+
},
266+
],
267+
}),
268+
},
269+
'export function greet() {}',
270+
'/project/src/backend/greet.backend.js',
271+
);
272+
273+
await extractCloseBundle(plugins)();
274+
275+
expect(archive.createArchive).toHaveBeenCalledWith(
276+
expect.arrayContaining([
277+
expect.objectContaining({ relativePath: 'manifest.json' }),
278+
expect.objectContaining({
279+
relativePath: expect.stringMatching(/^backend\/.*\.greet\.js$/),
280+
}),
281+
]),
282+
);
283+
expect(
284+
Object.keys((manifest as { backend: { functions: object } }).backend.functions),
285+
).toEqual([expect.stringMatching(/^[a-f0-9]{64}\.greet$/)]);
286+
expect(manifest).toMatchObject({
287+
backend: { functions: expect.any(Object) },
288+
});
289+
expect(
290+
Object.values(
291+
(manifest as { backend: { functions: Record<string, unknown> } }).backend.functions,
292+
),
293+
).toEqual([{ allowedConnectionIds: [] }]);
191294
});
192295

193296
test('Should surface upload errors', async () => {
@@ -215,6 +318,7 @@ describe('Apps Plugin - getPlugins', () => {
215318

216319
expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('upload failed'), 'error');
217320
expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-456'));
321+
expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-'));
218322
});
219323

220324
test('Should upload assets with vite bundler', async () => {

packages/plugins/apps/src/index.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { rm } from '@dd/core/helpers/fs';
66
import type { GetPlugins } from '@dd/core/types';
77
import { InjectPosition } from '@dd/core/types';
88
import chalk from 'chalk';
9+
import fsp from 'fs/promises';
10+
import os from 'os';
911
import path from 'path';
1012

1113
import { createArchive } from './archive';
@@ -17,7 +19,7 @@ import { encodeQueryName } from './backend/encodeQueryName';
1719
import { generateProxyModule } from './backend/proxy-codegen';
1820
import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants';
1921
import { resolveIdentifier } from './identifier';
20-
import type { AppsOptions } from './types';
22+
import type { AppsManifest, AppsOptions } from './types';
2123
import { uploadArchive } from './upload';
2224
import { validateOptions } from './validate';
2325
import { getVitePlugin } from './vite/index';
@@ -40,7 +42,12 @@ function buildProxyModule(
4042
const proxyExports: Array<{ exportName: string; queryName: string }> = [];
4143

4244
for (const exportName of exportNames) {
43-
const func = { relativePath: refPath, name: exportName, absolutePath: id };
45+
const func = {
46+
relativePath: refPath,
47+
name: exportName,
48+
absolutePath: id,
49+
allowedConnectionIds: [],
50+
};
4451
functions.push(func);
4552
proxyExports.push({ exportName, queryName: encodeQueryName(func) });
4653
}
@@ -50,6 +57,7 @@ function buildProxyModule(
5057

5158
const yellow = chalk.yellow.bold;
5259
const red = chalk.red.bold;
60+
const MANIFEST_FILE_NAME = 'manifest.json';
5361

5462
/**
5563
* Create a registry for tracking discovered backend functions.
@@ -71,6 +79,37 @@ function createBackendFunctionRegistry() {
7179
};
7280
}
7381

82+
function buildManifest(backendFunctions: BackendFunction[]): AppsManifest {
83+
const functions: AppsManifest['backend']['functions'] = {};
84+
for (const func of backendFunctions) {
85+
functions[encodeQueryName(func)] = {
86+
allowedConnectionIds: [...func.allowedConnectionIds],
87+
};
88+
}
89+
return { backend: { functions } };
90+
}
91+
92+
async function writeManifestFile(backendFunctions: BackendFunction[]): Promise<{
93+
manifestAsset: Asset;
94+
cleanup: () => Promise<void>;
95+
}> {
96+
const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-'));
97+
const manifestPath = path.join(manifestDir, MANIFEST_FILE_NAME);
98+
try {
99+
await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2));
100+
} catch (error) {
101+
await rm(manifestDir);
102+
throw error;
103+
}
104+
return {
105+
manifestAsset: {
106+
absolutePath: manifestPath,
107+
relativePath: MANIFEST_FILE_NAME,
108+
},
109+
cleanup: () => rm(manifestDir),
110+
};
111+
}
112+
74113
export type types = {
75114
// Add the types you'd like to expose here.
76115
AppsOptions: AppsOptions;
@@ -109,6 +148,7 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
109148
const handleUpload = async (backendOutputs: Map<string, string>) => {
110149
const handleTimer = log.time('handle assets');
111150
let archiveDir: string | undefined;
151+
let cleanupManifest: (() => Promise<void>) | undefined;
112152
try {
113153
const identifierTimer = log.time('resolve identifier');
114154

@@ -158,6 +198,11 @@ Either:
158198
});
159199
}
160200

201+
const backendFunctions = getBackendFunctions();
202+
const { manifestAsset, cleanup } = await writeManifestFile(backendFunctions);
203+
cleanupManifest = cleanup;
204+
allAssets.push(manifestAsset);
205+
161206
const archiveTimer = log.time('archive assets');
162207
const archive = await createArchive(allAssets);
163208
archiveTimer.end();
@@ -202,6 +247,9 @@ Either:
202247
if (archiveDir) {
203248
await rm(archiveDir);
204249
}
250+
if (cleanupManifest) {
251+
await cleanupManifest();
252+
}
205253
handleTimer.end();
206254

207255
if (toThrow) {

packages/plugins/apps/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,17 @@ export type AppsOptions = {
1212
name?: string;
1313
};
1414

15+
export type AppsManifest = {
16+
backend: {
17+
/** Mapping of encoded query name to information about that backend function. */
18+
functions: Record<
19+
string,
20+
{
21+
allowedConnectionIds: string[];
22+
}
23+
>;
24+
};
25+
};
26+
1527
// We don't enforce identifier, as it needs to be dynamically computed if absent.
1628
export type AppsOptionsWithDefaults = WithRequired<AppsOptions, 'enable' | 'include' | 'dryRun'>;

0 commit comments

Comments
 (0)