diff --git a/src/deploy/functions/functionsDeployHelper.spec.ts b/src/deploy/functions/functionsDeployHelper.spec.ts index e3bed886c22..67916b07636 100644 --- a/src/deploy/functions/functionsDeployHelper.spec.ts +++ b/src/deploy/functions/functionsDeployHelper.spec.ts @@ -163,6 +163,7 @@ describe("functionsDeployHelper", () => { interface Testcase { desc: string; selector: string; + strict?: boolean; expected: EndpointFilter[]; } @@ -172,11 +173,11 @@ describe("functionsDeployHelper", () => { selector: "func", expected: [ { - codebase: DEFAULT_CODEBASE, - idChunks: ["func"], + codebase: "func", }, { - codebase: "func", + codebase: DEFAULT_CODEBASE, + idChunks: ["func"], }, ], }, @@ -185,11 +186,11 @@ describe("functionsDeployHelper", () => { selector: "g1.func", expected: [ { - codebase: DEFAULT_CODEBASE, - idChunks: ["g1", "func"], + codebase: "g1.func", }, { - codebase: "g1.func", + codebase: DEFAULT_CODEBASE, + idChunks: ["g1", "func"], }, ], }, @@ -197,18 +198,59 @@ describe("functionsDeployHelper", () => { desc: "parses group selector (with '-') without codebase", selector: "g1-func", expected: [ + { + codebase: "g1-func", + }, { codebase: DEFAULT_CODEBASE, idChunks: ["g1", "func"], }, + ], + }, + { + desc: "parses group selector (with '-') with codebase", + selector: "node:g1-func", + expected: [ + { + codebase: "node", + idChunks: ["g1", "func"], + }, + ], + }, + { + desc: "parses selector without codebase (strict)", + selector: "func", + strict: true, + expected: [ + { + codebase: "func", + }, + ], + }, + { + desc: "parses group selector (with '.') without codebase (strict)", + selector: "g1.func", + strict: true, + expected: [ + { + codebase: "g1.func", + }, + ], + }, + { + desc: "parses group selector (with '-') without codebase (strict)", + selector: "g1-func", + strict: true, + expected: [ { codebase: "g1-func", }, ], }, { - desc: "parses group selector (with '-') with codebase", + desc: "parses group selector (with '-') with codebase (strict)", selector: "node:g1-func", + strict: true, expected: [ { codebase: "node", @@ -220,10 +262,9 @@ describe("functionsDeployHelper", () => { for (const tc of testcases) { it(tc.desc, () => { - const actual = parseFunctionSelector(tc.selector); - - expect(actual.length).to.equal(tc.expected.length); - expect(actual).to.deep.include.members(tc.expected); + const actual = parseFunctionSelector(tc.selector, tc.strict ?? false); + expect(actual).to.have.length(tc.expected.length); + expect(actual).to.have.deep.members(tc.expected); }); } }); @@ -232,46 +273,105 @@ describe("functionsDeployHelper", () => { interface Testcase { desc: string; only: string; - expected: EndpointFilter[]; + strict?: boolean; + expected: EndpointFilter[] | undefined; } const testcases: Testcase[] = [ + { + desc: "should return undefined given no only option", + only: "", + expected: undefined, + }, + { + desc: "should return undefined given no functions selector", + only: "hosting:siteA,storage:bucketB", + expected: undefined, + }, { desc: "should parse multiple selectors", only: "functions:myFunc,functions:myOtherFunc", expected: [ + { + codebase: "myFunc", + }, { codebase: DEFAULT_CODEBASE, idChunks: ["myFunc"], }, { - codebase: "myFunc", + codebase: "myOtherFunc", }, { codebase: DEFAULT_CODEBASE, idChunks: ["myOtherFunc"], }, - { - codebase: "myOtherFunc", - }, ], }, { desc: "should parse nested selector", only: "functions:groupA.myFunc", expected: [ + { + codebase: "groupA.myFunc", + }, { codebase: DEFAULT_CODEBASE, idChunks: ["groupA", "myFunc"], }, + ], + }, + { + desc: "should parse selector with codebase", + only: "functions:my-codebase:myFunc,functions:another-codebase:anotherFunc", + expected: [ + { + codebase: "my-codebase", + idChunks: ["myFunc"], + }, + { + codebase: "another-codebase", + idChunks: ["anotherFunc"], + }, + ], + }, + { + desc: "should parse nested selector with codebase", + only: "functions:my-codebase:groupA.myFunc", + expected: [ + { + codebase: "my-codebase", + idChunks: ["groupA", "myFunc"], + }, + ], + }, + { + desc: "should parse multiple selectors (strict)", + only: "functions:myFunc,functions:myOtherFunc", + strict: true, + expected: [ + { + codebase: "myFunc", + }, + { + codebase: "myOtherFunc", + }, + ], + }, + { + desc: "should parse nested selector (strict)", + only: "functions:groupA.myFunc", + strict: true, + expected: [ { codebase: "groupA.myFunc", }, ], }, { - desc: "should parse selector with codebase", + desc: "should parse selector with codebase (strict)", only: "functions:my-codebase:myFunc,functions:another-codebase:anotherFunc", + strict: true, expected: [ { codebase: "my-codebase", @@ -284,8 +384,9 @@ describe("functionsDeployHelper", () => { ], }, { - desc: "should parse nested selector with codebase", + desc: "should parse nested selector with codebase (strict)", only: "functions:my-codebase:groupA.myFunc", + strict: true, expected: [ { codebase: "my-codebase", @@ -293,6 +394,20 @@ describe("functionsDeployHelper", () => { }, ], }, + { + desc: "should parse mixed selectors (strict)", + only: "functions:myFunc,functions:another:anotherFunc", + strict: true, + expected: [ + { + codebase: "myFunc", + }, + { + codebase: "another", + idChunks: ["anotherFunc"], + }, + ], + }, ]; for (const tc of testcases) { @@ -301,10 +416,15 @@ describe("functionsDeployHelper", () => { only: tc.only, } as Options; - const actual = helper.getEndpointFilters(options); + const actual = helper.getEndpointFilters(options, !!tc.strict); - expect(actual?.length).to.equal(tc.expected.length); - expect(actual).to.deep.include.members(tc.expected); + if (tc.expected === undefined) { + expect(actual).to.be.undefined; + } else { + expect(actual).to.not.be.undefined; + expect(actual).to.have.length(tc.expected.length); + expect(actual).to.have.deep.members(tc.expected); + } }); } diff --git a/src/deploy/functions/functionsDeployHelper.ts b/src/deploy/functions/functionsDeployHelper.ts index 5bfe405ad1a..0d7f795718c 100644 --- a/src/deploy/functions/functionsDeployHelper.ts +++ b/src/deploy/functions/functionsDeployHelper.ts @@ -56,7 +56,7 @@ export function endpointMatchesFilter(endpoint: backend.Endpoint, filter: Endpoi /** * Returns list of filters after parsing selector. */ -export function parseFunctionSelector(selector: string): EndpointFilter[] { +export function parseFunctionSelector(selector: string, strict: boolean): EndpointFilter[] { const fragments = selector.split(":"); if (fragments.length < 2) { // This is a plain selector w/o codebase prefix (e.g. "abc" not "abc:efg") . @@ -67,10 +67,13 @@ export function parseFunctionSelector(selector: string): EndpointFilter[] { // // We decide here to create filter for both conditions. This sounds sloppy, but it's only troublesome if there is // conflict between a codebase name as function id in the default codebase. - return [ - { codebase: fragments[0] }, - { codebase: DEFAULT_CODEBASE, idChunks: fragments[0].split(/[-.]/) }, - ]; + // + // In strict mode, 'default' codebase is not optional and we always assume it is a codebase fragment. + const filters: EndpointFilter[] = [{ codebase: fragments[0] }]; + if (!strict) { + filters.push({ codebase: DEFAULT_CODEBASE, idChunks: fragments[0].split(/[-.]/) }); + } + return filters; } return [ { @@ -98,11 +101,16 @@ export function parseFunctionSelector(selector: string): EndpointFilter[] { * 2) Grouped functions w/ "abc" prefix in the default codebase? * 3) All functions in the "abc" codebase? * - * Current implementation creates filters that match against all conditions. + * The default implementation creates filters that match against all conditions. + * + * In "strict" mode, we always assume the only filter follows the format functions:[codebase]:[fn]. * * If no filter exists, we return undefined which the caller should interpret as "match all functions". */ -export function getEndpointFilters(options: { only?: string }): EndpointFilter[] | undefined { +export function getEndpointFilters( + options: { only?: string }, + strict = false, +): EndpointFilter[] | undefined { if (!options.only) { return undefined; } @@ -113,7 +121,7 @@ export function getEndpointFilters(options: { only?: string }): EndpointFilter[] if (selector.startsWith("functions:")) { selector = selector.replace("functions:", ""); if (selector.length > 0) { - filters.push(...parseFunctionSelector(selector)); + filters.push(...parseFunctionSelector(selector, strict)); } } } diff --git a/src/emulator/controller.spec.ts b/src/emulator/controller.spec.ts index a56f7ba5945..bda0109e9fe 100644 --- a/src/emulator/controller.spec.ts +++ b/src/emulator/controller.spec.ts @@ -1,7 +1,11 @@ import { Emulators } from "./types"; import { EmulatorRegistry } from "./registry"; import { expect } from "chai"; +import * as controller from "./controller"; import { FakeEmulator } from "./testing/fakeEmulator"; +import { EmulatorOptions } from "./controller"; +import { ValidatedConfig } from "../functions/projectConfig"; +import { Config } from "../config"; describe("EmulatorController", () => { afterEach(async () => { @@ -19,4 +23,88 @@ describe("EmulatorController", () => { expect(EmulatorRegistry.isRunning(name)).to.be.true; expect(EmulatorRegistry.getInfo(name)!.port).to.eql(fake.getInfo().port); }); -}).timeout(2000); + + describe("prepareFunctionsBackends", () => { + const baseOptions = { + project: "demo-project", + projectDir: "/path/to/project", + config: new Config({}, { projectDir: "/path/to/project", cwd: "/path/to/project" }), + nonInteractive: true, + } as unknown as EmulatorOptions; + + const functionsConfig = [ + { source: "functions-a", codebase: "codebasea" }, + { source: "functions-b", codebase: "codebaseb" }, + { source: "functions-default" }, + ] as ValidatedConfig; + + it("should include all codebases without only flag", () => { + const options = { ...baseOptions }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(3); + expect(backends.map((b) => b.codebase)).to.have.members([ + "codebasea", + "codebaseb", + undefined, + ]); + }); + + it("should include only specified codebases", () => { + const options = { ...baseOptions, only: "functions:codebasea" }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(1); + expect(backends[0].codebase).to.equal("codebasea"); + }); + + it("should include all specified codebases", () => { + const options = { ...baseOptions, only: "functions:codebasea,functions:codebaseb" }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(2); + expect(backends.map((b) => b.codebase)).to.have.members(["codebasea", "codebaseb"]); + }); + + it("should include default codebase", () => { + const options = { ...baseOptions, only: "functions:default" }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(1); + expect(backends[0].codebase).to.equal(undefined); + }); + + it("should ignore non-functions filters", () => { + const options = { ...baseOptions, only: "hosting,functions:codebasea" }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(1); + expect(backends[0].codebase).to.equal("codebasea"); + }); + + it("should include all codebases given --only functions", () => { + const options = { ...baseOptions, only: "functions" }; + const backends = controller.prepareFunctionsBackends( + options, + functionsConfig, + "/project/dir", + ); + expect(backends.length).to.equal(3); + }); + }); +}); diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 607efc8524a..fb174e8e301 100755 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -61,6 +61,8 @@ import { TasksEmulator } from "./tasksEmulator"; import { AppHostingEmulator } from "./apphosting"; import { sendVSCodeMessage, VSCODE_MESSAGE } from "../dataconnect/webhook"; import { dataConnectLocalConnString } from "../api"; +import { getEndpointFilters } from "../deploy/functions/functionsDeployHelper"; +import { ValidatedConfig } from "../functions/projectConfig"; const START_LOGGING_EMULATOR = utils.envOverride( "START_LOGGING_EMULATOR", @@ -111,6 +113,68 @@ export async function cleanShutdown(): Promise { await sendVSCodeMessage({ message: VSCODE_MESSAGE.EMULATORS_SHUTDOWN }); } +/** + * Prepares a list of EmulatableBackend objects based on functions configuration and options. + * This includes filtering based on the --only flag. + * + * @internal Exported for testing. + */ +export function prepareFunctionsBackends( + options: EmulatorOptions, + functionsCfg: ValidatedConfig, + projectDir: string, +): EmulatableBackend[] { + const functionsLogger = EmulatorLogger.forEmulator(Emulators.FUNCTIONS); + const emulatableBackends: EmulatableBackend[] = []; + + const filters = getEndpointFilters(options, true /* strict */); + const codebaseFilters = filters?.map((f) => f.codebase); + + const filterFn = (codebase: string): boolean => { + if (!codebaseFilters) { + return true; + } + return codebaseFilters.includes(codebase); + }; + + for (const cfg of functionsCfg) { + const codebase = cfg.codebase ?? "default"; + if (!filterFn(codebase)) { + functionsLogger.logLabeled( + "INFO", + "functions", + `Skipping codebase ${codebase} due to --only filter`, + ); + continue; + } + const functionsDir = path.join(projectDir, cfg.source); + const runtime = (options.extDevRuntime ?? cfg.runtime) as Runtime | undefined; + // N.B. (Issue #6965) it's OK for runtime to be undefined because the functions discovery process + // will dynamically detect it later. + // TODO: does runtime even need to be a part of EmultableBackend now that we have dynamic runtime + // detection? Might be an extensions thing. + if (runtime && !isRuntime(runtime)) { + throw new FirebaseError( + `Cannot load functions from ${functionsDir} because it has invalid runtime ${runtime as string}`, + ); + } + emulatableBackends.push({ + functionsDir, + runtime, + codebase: cfg.codebase, + env: { + ...options.extDevEnv, + }, + secretEnv: [], // CF3 secrets are bound to specific functions, so we'll get them during trigger discovery. + // TODO(b/213335255): predefinedTriggers and nodeMajorVersion are here to support ext:dev:emulators:* commands. + // Ideally, we should handle that case via ExtensionEmulator. + predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, + ignore: cfg.ignore, + }); + } + return emulatableBackends; +} + /** * Filters a list of emulators to only those specified in the config * @param options @@ -260,7 +324,7 @@ function findExportMetadata(importPath: string): ExportMetadata | undefined { } } -interface EmulatorOptions extends Options { +export interface EmulatorOptions extends Options { extDevEnv?: Record; logVerbosity?: "DEBUG" | "INFO" | "QUIET" | "SILENT"; } @@ -522,35 +586,19 @@ export async function startAll( const projectDir = (options.extDevDir || options.config.projectDir) as string; if (shouldStart(options, Emulators.FUNCTIONS)) { - const functionsCfg = normalizeAndValidate(options.config.src.functions); // Note: ext:dev:emulators:* commands hit this path, not the Emulators.EXTENSIONS path utils.assertIsStringOrUndefined(options.extDevDir); - - for (const cfg of functionsCfg) { - const functionsDir = path.join(projectDir, cfg.source); - const runtime = (options.extDevRuntime ?? cfg.runtime) as Runtime | undefined; - // N.B. (Issue #6965) it's OK for runtime to be undefined because the functions discovery process - // will dynamically detect it later. - // TODO: does runtime even need to be a part of EmultableBackend now that we have dynamic runtime - // detection? Might be an extensions thing. - if (runtime && !isRuntime(runtime)) { - throw new FirebaseError( - `Cannot load functions from ${functionsDir} because it has invalid runtime ${runtime as string}`, - ); - } - emulatableBackends.push({ - functionsDir, - runtime, - codebase: cfg.codebase, - env: { - ...options.extDevEnv, - }, - secretEnv: [], // CF3 secrets are bound to specific functions, so we'll get them during trigger discovery. - // TODO(b/213335255): predefinedTriggers and nodeMajorVersion are here to support ext:dev:emulators:* commands. - // Ideally, we should handle that case via ExtensionEmulator. - predefinedTriggers: options.extDevTriggers as ParsedTriggerDefinition[] | undefined, - ignore: cfg.ignore, - }); + try { + const functionsCfg = normalizeAndValidate(options.config.src.functions); + const functionsBackends = prepareFunctionsBackends(options, functionsCfg, projectDir); + emulatableBackends.push(...functionsBackends); + } catch (err: any) { + // This error is already logged in shouldStart, but we catch it again here + // to be safe. + EmulatorLogger.forEmulator(Emulators.FUNCTIONS).log( + "WARN", + `Could not prepare functions backends: ${err?.message}`, + ); } }