diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 517f12ca713..c86db3f356c 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -279,6 +279,9 @@ } ] }, + "prefix": { + "type": "string" + }, "runtime": { "enum": [ "nodejs18", diff --git a/scripts/emulator-tests/functionsEmulator.spec.ts b/scripts/emulator-tests/functionsEmulator.spec.ts index fc1e04ecd77..1e10fee9ef6 100644 --- a/scripts/emulator-tests/functionsEmulator.spec.ts +++ b/scripts/emulator-tests/functionsEmulator.spec.ts @@ -760,6 +760,48 @@ describe("FunctionsEmulator", function () { }).timeout(TIMEOUT_MED); }); + it("should support multiple codebases with the same source and apply prefixes", async () => { + const backend1: EmulatableBackend = { + ...TEST_BACKEND, + codebase: "one", + prefix: "prefix-one", + }; + const backend2: EmulatableBackend = { + ...TEST_BACKEND, + codebase: "two", + prefix: "prefix-two", + }; + + emu = new FunctionsEmulator({ + projectId: TEST_PROJECT_ID, + projectDir: MODULE_ROOT, + emulatableBackends: [backend1, backend2], + verbosity: "QUIET", + debugPort: false, + }); + + await writeSource(() => { + return { + functionId: require("firebase-functions").https.onRequest( + (req: express.Request, res: express.Response) => { + res.json({ path: req.path }); + }, + ), + }; + }); + + await emu.start(); + await emu.connect(); + + await supertest(emu.createHubServer()) + .get(`/${TEST_PROJECT_ID}/us-central1/prefix-one-functionId`) + .expect(200); + + await supertest(emu.createHubServer()) + .get(`/${TEST_PROJECT_ID}/us-central1/prefix-two-functionId`) + .expect(200); + }); + describe("user-defined environment variables", () => { let cleanup: (() => Promise) | undefined; diff --git a/src/deploy/functions/build.spec.ts b/src/deploy/functions/build.spec.ts index ff279f8ce47..c88f96eab8b 100644 --- a/src/deploy/functions/build.spec.ts +++ b/src/deploy/functions/build.spec.ts @@ -293,3 +293,88 @@ describe("envWithType", () => { expect(out.WHOOPS_SECRET.asString()).to.equal("super-secret"); }); }); + +describe("applyPrefix", () => { + const createTestBuild = (): build.Build => ({ + endpoints: { + func1: { + region: "us-central1", + project: "test-project", + platform: "gcfv2", + runtime: "nodejs18", + entryPoint: "func1", + httpsTrigger: {}, + }, + func2: { + region: "us-west1", + project: "test-project", + platform: "gcfv1", + runtime: "nodejs16", + entryPoint: "func2", + httpsTrigger: {}, + }, + }, + params: [], + requiredAPIs: [], + }); + + it("should update endpoint keys with prefix", () => { + const testBuild = createTestBuild(); + build.applyPrefix(testBuild, "test"); + expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["test-func1", "test-func2"]); + expect(testBuild.endpoints["test-func1"].entryPoint).to.equal("func1"); + expect(testBuild.endpoints["test-func2"].entryPoint).to.equal("func2"); + }); + + it("should do nothing for an empty prefix", () => { + const testBuild = createTestBuild(); + build.applyPrefix(testBuild, ""); + expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["func1", "func2"]); + }); + + it("should prefix secret names in secretEnvironmentVariables", () => { + const testBuild: build.Build = { + endpoints: { + func1: { + region: "us-central1", + project: "test-project", + platform: "gcfv2", + runtime: "nodejs18", + entryPoint: "func1", + httpsTrigger: {}, + secretEnvironmentVariables: [ + { key: "API_KEY", secret: "api-secret", projectId: "test-project" }, + { key: "DB_PASSWORD", secret: "db-secret", projectId: "test-project" }, + ], + }, + func2: { + region: "us-west1", + project: "test-project", + platform: "gcfv1", + runtime: "nodejs16", + entryPoint: "func2", + httpsTrigger: {}, + secretEnvironmentVariables: [ + { key: "SERVICE_TOKEN", secret: "service-secret", projectId: "test-project" }, + ], + }, + }, + params: [], + requiredAPIs: [], + }; + + build.applyPrefix(testBuild, "staging"); + + expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal([ + "staging-func1", + "staging-func2", + ]); + expect(testBuild.endpoints["staging-func1"].secretEnvironmentVariables).to.deep.equal([ + { key: "API_KEY", secret: "staging-api-secret", projectId: "test-project" }, + { key: "DB_PASSWORD", secret: "staging-db-secret", projectId: "test-project" }, + ]); + expect(testBuild.endpoints["staging-func2"].secretEnvironmentVariables).to.deep.equal([ + { key: "SERVICE_TOKEN", secret: "staging-service-secret", projectId: "test-project" }, + ]); + }); +}); diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 9fca9b58d09..c85cdbdacca 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -650,3 +650,25 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe } assertExhaustive(endpoint); } + +/** + * Prefixes all endpoint IDs in a build with a given prefix. + */ +export function applyPrefix(build: Build, prefix: string): void { + if (!prefix) { + return; + } + const newEndpoints: Record = {}; + for (const id of Object.keys(build.endpoints)) { + const endpoint = build.endpoints[id]; + newEndpoints[`${prefix}-${id}`] = endpoint; + + if (endpoint.secretEnvironmentVariables) { + endpoint.secretEnvironmentVariables = endpoint.secretEnvironmentVariables.map((secret) => ({ + ...secret, + secret: `${prefix}-${secret.secret}`, + })); + } + } + build.endpoints = newEndpoints; +} diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 0cb7a35a8c9..93b8a400f86 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -1,13 +1,17 @@ import { expect } from "chai"; - -import * as backend from "./backend"; +import * as sinon from "sinon"; +import * as build from "./build"; import * as prepare from "./prepare"; +import * as runtimes from "./runtimes"; +import { RuntimeDelegate } from "./runtimes"; +import { FirebaseError } from "../../error"; +import { Options } from "../../options"; +import { ValidatedConfig } from "../../functions/projectConfig"; +import * as backend from "./backend"; import * as ensureApiEnabled from "../../ensureApiEnabled"; import * as serviceusage from "../../gcp/serviceusage"; import { BEFORE_CREATE_EVENT, BEFORE_SIGN_IN_EVENT } from "../../functions/events/v1"; -import * as sinon from "sinon"; import * as prompt from "../../prompt"; -import { FirebaseError } from "../../error"; describe("prepare", () => { const ENDPOINT_BASE: Omit = { @@ -16,7 +20,7 @@ describe("prepare", () => { region: "region", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", }; const ENDPOINT: backend.Endpoint = { @@ -24,6 +28,78 @@ describe("prepare", () => { httpsTrigger: {}, }; + describe("loadCodebases", () => { + let sandbox: sinon.SinonSandbox; + let runtimeDelegateStub: RuntimeDelegate; + let discoverBuildStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + discoverBuildStub = sandbox.stub(); + runtimeDelegateStub = { + language: "nodejs", + runtime: "nodejs22", + bin: "node", + validate: sandbox.stub().resolves(), + build: sandbox.stub().resolves(), + watch: sandbox.stub().resolves(() => Promise.resolve()), + discoverBuild: discoverBuildStub, + }; + discoverBuildStub.resolves( + build.of({ + test: { + platform: "gcfv2", + entryPoint: "test", + project: "project", + runtime: "nodejs22", + httpsTrigger: {}, + }, + }), + ); + sandbox.stub(runtimes, "getRuntimeDelegate").resolves(runtimeDelegateStub); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should apply the prefix to the function name", async () => { + const config: ValidatedConfig = [ + { source: "source", codebase: "codebase", prefix: "my-prefix", runtime: "nodejs22" }, + ]; + const options = { + config: { + path: (p: string) => p, + }, + projectId: "project", + } as unknown as Options; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = {}; + + const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + + expect(Object.keys(builds.codebase.endpoints)).to.deep.equal(["my-prefix-test"]); + }); + + it("should preserve runtime from codebase config", async () => { + const config: ValidatedConfig = [ + { source: "source", codebase: "codebase", runtime: "nodejs20" }, + ]; + const options = { + config: { + path: (p: string) => p, + }, + projectId: "project", + } as unknown as Options; + const firebaseConfig = { projectId: "project" }; + const runtimeConfig = {}; + + const builds = await prepare.loadCodebases(config, options, firebaseConfig, runtimeConfig); + + expect(builds.codebase.runtime).to.equal("nodejs20"); + }); + }); + describe("inferDetailsFromExisting", () => { it("merges env vars if .env is not used", () => { const oldE = { @@ -304,7 +380,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", httpsTrigger: {}, }; @@ -314,7 +390,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", callableTrigger: { genkitAction: "action", }, @@ -333,7 +409,7 @@ describe("prepare", () => { region: "us-central1", project: "project", entryPoint: "entry", - runtime: "nodejs16", + runtime: "nodejs22", callableTrigger: { genkitAction: "action", }, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 2f2445261e8..a0915353a90 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -155,7 +155,7 @@ export async function prepare( } for (const endpoint of backend.allEndpoints(wantBackend)) { - endpoint.environmentVariables = { ...wantBackend.environmentVariables } || {}; + endpoint.environmentVariables = { ...(wantBackend.environmentVariables || {}) }; let resource: string; if (endpoint.platform === "gcfv1") { resource = `projects/${endpoint.project}/locations/${endpoint.region}/functions/${endpoint.id}`; @@ -475,14 +475,16 @@ export async function loadCodebases( "functions", `Loading and analyzing source code for codebase ${codebase} to determine what to deploy`, ); - wantBuilds[codebase] = await runtimeDelegate.discoverBuild(runtimeConfig, { + const discoveredBuild = await runtimeDelegate.discoverBuild(runtimeConfig, { ...firebaseEnvs, // Quota project is required when using GCP's Client-based APIs // Some GCP client SDKs, like Vertex AI, requires appropriate quota project setup // in order for .init() calls to succeed. GOOGLE_CLOUD_QUOTA_PROJECT: projectId, }); - wantBuilds[codebase].runtime = codebaseConfig.runtime; + discoveredBuild.runtime = codebaseConfig.runtime; + build.applyPrefix(discoveredBuild, codebaseConfig.prefix || ""); + wantBuilds[codebase] = discoveredBuild; } return wantBuilds; } diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 17dfb6a302c..85578f513c9 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -170,18 +170,16 @@ export function shouldStart(options: Options, name: Emulators): boolean { ); } - // Don't start the functions emulator if we can't find the source directory + // Don't start the functions emulator if we can't validate the functions config if (name === Emulators.FUNCTIONS && emulatorInTargets) { try { normalizeAndValidate(options.config.src.functions); return true; } catch (err: any) { EmulatorLogger.forEmulator(Emulators.FUNCTIONS).logLabeled( - "WARN", + "ERROR", "functions", - `The functions emulator is configured but there is no functions source directory. Have you run ${clc.bold( - "firebase init functions", - )}?`, + `Failed to start Functions emulator: ${err.message}`, ); return false; } @@ -544,6 +542,7 @@ export async function startAll( functionsDir, runtime, codebase: cfg.codebase, + prefix: cfg.prefix, env: { ...options.extDevEnv, }, diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index c8f69de5343..6922021fb57 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -53,8 +53,9 @@ import { import { functionIdsAreValid } from "../deploy/functions/validate"; import { Extension, ExtensionSpec, ExtensionVersion } from "../extensions/types"; import { accessSecretVersion } from "../gcp/secretManager"; -import * as runtimes from "../deploy/functions/runtimes"; import * as backend from "../deploy/functions/backend"; +import * as build from "../deploy/functions/build"; +import * as runtimes from "../deploy/functions/runtimes"; import * as functionsEnv from "../functions/env"; import { AUTH_BLOCKING_EVENTS, BEFORE_CREATE_EVENT } from "../functions/events/v1"; import { BlockingFunctionsConfig } from "../gcp/identityPlatform"; @@ -87,6 +88,7 @@ export interface EmulatableBackend { env: Record; secretEnv: backend.SecretEnvVar[]; codebase: string; + prefix?: string; predefinedTriggers?: ParsedTriggerDefinition[]; runtime?: Runtime; bin?: string; @@ -563,6 +565,7 @@ export class FunctionsEmulator implements EmulatorInstance { ); await this.loadDynamicExtensionBackends(); } + build.applyPrefix(discoveredBuild, emulatableBackend.prefix || ""); const resolution = await resolveBackend({ build: discoveredBuild, firebaseConfig: JSON.parse(firebaseConfig), diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index dbffa8e335f..c6252c8ecb9 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -171,6 +171,7 @@ export type FunctionConfig = { ignore?: string[]; runtime?: ActiveRuntime; codebase?: string; + prefix?: string; } & Deployable; export type FunctionsConfig = FunctionConfig | FunctionConfig[]; diff --git a/src/functions/projectConfig.spec.ts b/src/functions/projectConfig.spec.ts index de9e8d97b84..7f2dd86f85a 100644 --- a/src/functions/projectConfig.spec.ts +++ b/src/functions/projectConfig.spec.ts @@ -42,10 +42,43 @@ describe("projectConfig", () => { ); }); - it("fails validation given config w/ duplicate source", () => { - expect(() => - projectConfig.validate([TEST_CONFIG_0, { ...TEST_CONFIG_0, codebase: "unique-codebase" }]), - ).to.throw(FirebaseError, /source must be unique/); + it("passes validation for multi-instance config with same source", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar" }, + { source: "foo", codebase: "baz", prefix: "prefix-two" }, + ]; + expect(projectConfig.validate(config)).to.deep.equal(config); + }); + + it("passes validation for multi-instance config with one missing codebase", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar", prefix: "bar-prefix" }, + { source: "foo" }, + ]; + const expected = [ + { source: "foo", codebase: "bar", prefix: "bar-prefix" }, + { source: "foo", codebase: "default" }, + ]; + expect(projectConfig.validate(config)).to.deep.equal(expected); + }); + + it("fails validation for multi-instance config with missing codebase and a default codebase", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "default" }, + { source: "foo" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions.codebase must be unique but 'default' was used more than once./, + ); + }); + + it("fails validation for multi-instance config with multiple missing codebases", () => { + const config: projectConfig.NormalizedConfig = [{ source: "foo" }, { source: "foo" }]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /functions.codebase must be unique but 'default' was used more than once./, + ); }); it("fails validation given codebase name with capital letters", () => { @@ -72,6 +105,48 @@ describe("projectConfig", () => { ]), ).to.throw(FirebaseError, /Invalid codebase name/); }); + + it("fails validation given prefix with invalid characters", () => { + expect(() => projectConfig.validate([{ ...TEST_CONFIG_0, prefix: "abc.efg" }])).to.throw( + FirebaseError, + /Invalid prefix/, + ); + }); + + it("fails validation given prefix with capital letters", () => { + expect(() => projectConfig.validate([{ ...TEST_CONFIG_0, prefix: "ABC" }])).to.throw( + FirebaseError, + /Invalid prefix/, + ); + }); + + it("fails validation given a duplicate source/prefix pair", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar", prefix: "a" }, + { source: "foo", codebase: "baz", prefix: "a" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /More than one functions config specifies the same source directory \('foo'\) and prefix \('a'\)/, + ); + }); + + it("fails validation for multi-instance config with same source and no prefixes", () => { + const config: projectConfig.NormalizedConfig = [ + { source: "foo", codebase: "bar" }, + { source: "foo", codebase: "baz" }, + ]; + expect(() => projectConfig.validate(config)).to.throw( + FirebaseError, + /More than one functions config specifies the same source directory \('foo'\) and prefix \(''\)/, + ); + }); + + it("should allow a single function in an array to have a default codebase", () => { + const config: projectConfig.NormalizedConfig = [{ source: "foo" }]; + const expected = [{ source: "foo", codebase: "default" }]; + expect(projectConfig.validate(config)).to.deep.equal(expected); + }); }); describe("normalizeAndValidate", () => { @@ -104,13 +179,6 @@ describe("projectConfig", () => { ); }); - it("fails validation given config w/ duplicate source", () => { - expect(() => projectConfig.normalizeAndValidate([TEST_CONFIG_0, TEST_CONFIG_0])).to.throw( - FirebaseError, - /functions.source must be unique/, - ); - }); - it("fails validation given config w/ duplicate codebase", () => { expect(() => projectConfig.normalizeAndValidate([ diff --git a/src/functions/projectConfig.ts b/src/functions/projectConfig.ts index 313db95d48a..b28d8c78c6e 100644 --- a/src/functions/projectConfig.ts +++ b/src/functions/projectConfig.ts @@ -37,6 +37,20 @@ export function validateCodebase(codebase: string): void { } } +/** + * Check that the prefix contains only allowed characters. + */ +export function validatePrefix(prefix: string): void { + if (prefix.length > 30) { + throw new FirebaseError("Invalid prefix. Prefix must be 30 characters or less."); + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(prefix)) { + throw new FirebaseError( + "Invalid prefix. Prefix can contain only lowercase letters, numeric characters, and dashes, and cannot start or end with a dash.", + ); + } +} + function validateSingle(config: FunctionConfig): ValidatedSingle { if (!config.source) { throw new FirebaseError("codebase source must be specified"); @@ -45,6 +59,9 @@ function validateSingle(config: FunctionConfig): ValidatedSingle { config.codebase = DEFAULT_CODEBASE; } validateCodebase(config.codebase); + if (config.prefix) { + validatePrefix(config.prefix); + } return { ...config, source: config.source, codebase: config.codebase }; } @@ -72,13 +89,30 @@ export function assertUnique( } } +function assertUniqueSourcePrefixPair(config: ValidatedConfig): void { + const sourcePrefixPairs = new Set(); + for (const c of config) { + const key = JSON.stringify({ source: c.source, prefix: c.prefix || "" }); + if (sourcePrefixPairs.has(key)) { + throw new FirebaseError( + `More than one functions config specifies the same source directory ('${ + c.source + }') and prefix ('${ + c.prefix ?? "" + }'). Please add a unique 'prefix' to each function configuration that shares this source to resolve the conflict.`, + ); + } + sourcePrefixPairs.add(key); + } +} + /** * Validate functions config. */ export function validate(config: NormalizedConfig): ValidatedConfig { const validated = config.map((cfg) => validateSingle(cfg)) as ValidatedConfig; - assertUnique(validated, "source"); assertUnique(validated, "codebase"); + assertUniqueSourcePrefixPair(validated); return validated; }