Skip to content

feat: Add support for function prefixes #8911

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@
}
]
},
"prefix": {
"type": "string"
},
"runtime": {
"enum": [
"nodejs18",
Expand Down
42 changes: 42 additions & 0 deletions scripts/emulator-tests/functionsEmulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import * as secretManager from "../../src/gcp/secretManager";

if ((process.env.DEBUG || "").toLowerCase().includes("spec")) {
const dropLogLevels = (info: logform.TransformableInfo) => info.message;

Check warning on line 22 in scripts/emulator-tests/functionsEmulator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
logger.add(
new winston.transports.Console({
level: "debug",
Expand Down Expand Up @@ -49,7 +49,7 @@
// bin: path.join(MODULE_ROOT, "node_modules/.bin/ts-node"),
};

async function setupEnvFiles(envs: Record<string, string>) {

Check warning on line 52 in scripts/emulator-tests/functionsEmulator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const envFiles: string[] = [];
for (const [filename, data] of Object.entries(envs)) {
const envPath = path.join(FUNCTIONS_DIR, filename);
Expand Down Expand Up @@ -760,6 +760,48 @@
}).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<void>) | undefined;

Expand Down
85 changes: 85 additions & 0 deletions src/deploy/functions/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});
});
22 changes: 22 additions & 0 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Endpoint> = {};
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;
}
92 changes: 84 additions & 8 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
@@ -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<backend.Endpoint, "httpsTrigger"> = {
Expand All @@ -16,14 +20,86 @@ describe("prepare", () => {
region: "region",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
};

const ENDPOINT: backend.Endpoint = {
...ENDPOINT_BASE,
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 = {
Expand Down Expand Up @@ -304,7 +380,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
httpsTrigger: {},
};

Expand All @@ -314,7 +390,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
callableTrigger: {
genkitAction: "action",
},
Expand All @@ -333,7 +409,7 @@ describe("prepare", () => {
region: "us-central1",
project: "project",
entryPoint: "entry",
runtime: "nodejs16",
runtime: "nodejs22",
callableTrigger: {
genkitAction: "action",
},
Expand Down
8 changes: 5 additions & 3 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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;
}
Expand Down
9 changes: 4 additions & 5 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -544,6 +542,7 @@ export async function startAll(
functionsDir,
runtime,
codebase: cfg.codebase,
prefix: cfg.prefix,
env: {
...options.extDevEnv,
},
Expand Down
5 changes: 4 additions & 1 deletion src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +88,7 @@ export interface EmulatableBackend {
env: Record<string, string>;
secretEnv: backend.SecretEnvVar[];
codebase: string;
prefix?: string;
predefinedTriggers?: ParsedTriggerDefinition[];
runtime?: Runtime;
bin?: string;
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading