Skip to content

Commit a10ea0f

Browse files
authored
feat: Add support for function prefixes (#8911)
This change adds a new `prefix` property to firebase functions configuration in firebase.json. The new property can be used to prepend function name with the given prefix for all functions in the codebase. Note that prefix is also used for all secret associated with the codebase to allow different codebases to refer to different configuration value. The new feature also requires two new validation rules for function configurations in `firebase.json`: 1. The `prefix` property, if specified, must only contain lowercase letters, numbers, and hyphens. 2. The combination of `source` and `prefix` must be unique across all function configurations. These changes ensure that function deployments with prefixes are valid and that there are no conflicts when deploying multiple functions from the same source directory.
1 parent 2e14b2d commit a10ea0f

File tree

13 files changed

+454
-31
lines changed

13 files changed

+454
-31
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ firebase-debug.log
1515
firebase-debug.*.log
1616
npm-debug.log
1717
ui-debug.log
18+
test_output.log
19+
scripts/emulator-tests/functions/index.js
1820
yarn.lock
1921
.npmrc
2022

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
- Added prefix support for multi-instance Cloud Functions extension parameters. (#8911)
12
- Fixed a bug when `firebase deploy --only dataconnect` doesn't include GQL in nested folders (#8981)

schema/firebase-config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@
279279
}
280280
]
281281
},
282+
"prefix": {
283+
"type": "string"
284+
},
282285
"runtime": {
283286
"enum": [
284287
"nodejs18",

scripts/emulator-tests/functionsEmulator.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,52 @@ describe("FunctionsEmulator", function () {
760760
}).timeout(TIMEOUT_MED);
761761
});
762762

763+
it("should support multiple codebases with the same source and apply prefixes", async () => {
764+
const backend1: EmulatableBackend = {
765+
...TEST_BACKEND,
766+
codebase: "one",
767+
prefix: "prefix-one",
768+
};
769+
const backend2: EmulatableBackend = {
770+
...TEST_BACKEND,
771+
codebase: "two",
772+
prefix: "prefix-two",
773+
};
774+
775+
const prefixEmu = new FunctionsEmulator({
776+
projectId: TEST_PROJECT_ID,
777+
projectDir: MODULE_ROOT,
778+
emulatableBackends: [backend1, backend2],
779+
verbosity: "QUIET",
780+
debugPort: false,
781+
});
782+
783+
await writeSource(() => {
784+
return {
785+
functionId: require("firebase-functions").https.onRequest(
786+
(req: express.Request, res: express.Response) => {
787+
res.json({ path: req.path });
788+
},
789+
),
790+
};
791+
});
792+
793+
try {
794+
await registry.EmulatorRegistry.start(prefixEmu);
795+
await prefixEmu.connect();
796+
797+
await supertest(prefixEmu.createHubServer())
798+
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-one-functionId`)
799+
.expect(200);
800+
801+
await supertest(prefixEmu.createHubServer())
802+
.get(`/${TEST_PROJECT_ID}/us-central1/prefix-two-functionId`)
803+
.expect(200);
804+
} finally {
805+
await registry.EmulatorRegistry.stop(Emulators.FUNCTIONS);
806+
}
807+
});
808+
763809
describe("user-defined environment variables", () => {
764810
let cleanup: (() => Promise<void>) | undefined;
765811

src/deploy/functions/build.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,120 @@ describe("envWithType", () => {
293293
expect(out.WHOOPS_SECRET.asString()).to.equal("super-secret");
294294
});
295295
});
296+
297+
describe("applyPrefix", () => {
298+
const createTestBuild = (): build.Build => ({
299+
endpoints: {
300+
func1: {
301+
region: "us-central1",
302+
project: "test-project",
303+
platform: "gcfv2",
304+
runtime: "nodejs18",
305+
entryPoint: "func1",
306+
httpsTrigger: {},
307+
},
308+
func2: {
309+
region: "us-west1",
310+
project: "test-project",
311+
platform: "gcfv1",
312+
runtime: "nodejs16",
313+
entryPoint: "func2",
314+
httpsTrigger: {},
315+
},
316+
},
317+
params: [],
318+
requiredAPIs: [],
319+
});
320+
321+
it("should update endpoint keys with prefix", () => {
322+
const testBuild = createTestBuild();
323+
build.applyPrefix(testBuild, "test");
324+
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["test-func1", "test-func2"]);
325+
expect(testBuild.endpoints["test-func1"].entryPoint).to.equal("func1");
326+
expect(testBuild.endpoints["test-func2"].entryPoint).to.equal("func2");
327+
});
328+
329+
it("should do nothing for an empty prefix", () => {
330+
const testBuild = createTestBuild();
331+
build.applyPrefix(testBuild, "");
332+
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["func1", "func2"]);
333+
});
334+
335+
it("should prefix secret names in secretEnvironmentVariables", () => {
336+
const testBuild: build.Build = {
337+
endpoints: {
338+
func1: {
339+
region: "us-central1",
340+
project: "test-project",
341+
platform: "gcfv2",
342+
runtime: "nodejs18",
343+
entryPoint: "func1",
344+
httpsTrigger: {},
345+
secretEnvironmentVariables: [
346+
{ key: "API_KEY", secret: "api-secret", projectId: "test-project" },
347+
{ key: "DB_PASSWORD", secret: "db-secret", projectId: "test-project" },
348+
],
349+
},
350+
func2: {
351+
region: "us-west1",
352+
project: "test-project",
353+
platform: "gcfv1",
354+
runtime: "nodejs16",
355+
entryPoint: "func2",
356+
httpsTrigger: {},
357+
secretEnvironmentVariables: [
358+
{ key: "SERVICE_TOKEN", secret: "service-secret", projectId: "test-project" },
359+
],
360+
},
361+
},
362+
params: [],
363+
requiredAPIs: [],
364+
};
365+
366+
build.applyPrefix(testBuild, "staging");
367+
368+
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal([
369+
"staging-func1",
370+
"staging-func2",
371+
]);
372+
expect(testBuild.endpoints["staging-func1"].secretEnvironmentVariables).to.deep.equal([
373+
{ key: "API_KEY", secret: "staging-api-secret", projectId: "test-project" },
374+
{ key: "DB_PASSWORD", secret: "staging-db-secret", projectId: "test-project" },
375+
]);
376+
expect(testBuild.endpoints["staging-func2"].secretEnvironmentVariables).to.deep.equal([
377+
{ key: "SERVICE_TOKEN", secret: "staging-service-secret", projectId: "test-project" },
378+
]);
379+
});
380+
381+
it("throws if combined function id exceeds 63 characters", () => {
382+
const longId = "a".repeat(34); // with 30-char prefix + dash = 65 total
383+
const testBuild: build.Build = build.of({
384+
[longId]: {
385+
region: "us-central1",
386+
project: "test-project",
387+
platform: "gcfv2",
388+
runtime: "nodejs18",
389+
entryPoint: longId,
390+
httpsTrigger: {},
391+
},
392+
});
393+
const longPrefix = "p".repeat(30);
394+
expect(() => build.applyPrefix(testBuild, longPrefix)).to.throw(/exceeds 63 characters/);
395+
});
396+
397+
it("throws if prefix makes function id invalid (must start with a letter)", () => {
398+
const testBuild: build.Build = build.of({
399+
func: {
400+
region: "us-central1",
401+
project: "test-project",
402+
platform: "gcfv2",
403+
runtime: "nodejs18",
404+
entryPoint: "func",
405+
httpsTrigger: {},
406+
},
407+
});
408+
expect(() => build.applyPrefix(testBuild, "1abc")).to.throw(
409+
/Function names must start with a letter/,
410+
);
411+
});
412+
});

src/deploy/functions/build.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ export async function resolveBackend(
321321
}
322322

323323
// Exported for testing
324+
/**
325+
*
326+
*/
324327
export function envWithTypes(
325328
definedParams: params.Param[],
326329
rawEnvs: Record<string, string>,
@@ -651,3 +654,41 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
651654
}
652655
assertExhaustive(endpoint);
653656
}
657+
658+
/**
659+
* Prefixes all endpoint IDs and secret names in a build with a given prefix.
660+
* This ensures that functions and their associated secrets from different codebases
661+
* remain isolated and don't conflict when deployed to the same project.
662+
*/
663+
export function applyPrefix(build: Build, prefix: string): void {
664+
if (!prefix) {
665+
return;
666+
}
667+
const newEndpoints: Record<string, Endpoint> = {};
668+
for (const [id, endpoint] of Object.entries(build.endpoints)) {
669+
const newId = `${prefix}-${id}`;
670+
671+
// Enforce function id constraints early for clearer errors.
672+
if (newId.length > 63) {
673+
throw new FirebaseError(
674+
`Function id '${newId}' exceeds 63 characters after applying prefix '${prefix}'. Please shorten the prefix or function name.`,
675+
);
676+
}
677+
const fnIdRegex = /^[a-zA-Z][a-zA-Z0-9_-]{0,62}$/;
678+
if (!fnIdRegex.test(newId)) {
679+
throw new FirebaseError(
680+
`Function id '${newId}' is invalid after applying prefix '${prefix}'. Function names must start with a letter and can contain letters, numbers, underscores, and hyphens, with a maximum length of 63 characters.`,
681+
);
682+
}
683+
684+
newEndpoints[newId] = endpoint;
685+
686+
if (endpoint.secretEnvironmentVariables) {
687+
endpoint.secretEnvironmentVariables = endpoint.secretEnvironmentVariables.map((secret) => ({
688+
...secret,
689+
secret: `${prefix}-${secret.secret}`,
690+
}));
691+
}
692+
}
693+
build.endpoints = newEndpoints;
694+
}

0 commit comments

Comments
 (0)