diff --git a/src/commands/functions-delete.ts b/src/commands/functions-delete.ts index 8feb45734b2..78eafb57991 100644 --- a/src/commands/functions-delete.ts +++ b/src/commands/functions-delete.ts @@ -12,6 +12,7 @@ import * as args from "../deploy/functions/args"; import * as helper from "../deploy/functions/functionsDeployHelper"; import * as utils from "../utils"; import * as backend from "../deploy/functions/backend"; +import * as projectConfig from "../functions/projectConfig"; import * as planner from "../deploy/functions/release/planner"; import * as fabricator from "../deploy/functions/release/fabricator"; import * as executor from "../deploy/functions/release/executor"; @@ -19,7 +20,7 @@ import * as reporter from "../deploy/functions/release/reporter"; import { getProjectNumber } from "../getProjectNumber"; export const command = new Command("functions:delete [filters...]") - .description("delete one or more Cloud Functions by name or group name.") + .description("delete one or more Cloud Functions by name, group name, or codebase.") .option( "--region ", "Specify region of the function to be deleted. " + @@ -34,19 +35,45 @@ export const command = new Command("functions:delete [filters...]") const context: args.Context = { projectId: needProjectId(options), - filters: filters.map((f) => ({ idChunks: f.split(/[-.]/) })), + filters: [], }; - - const [config, existingBackend] = await Promise.all([ + const [firebaseConfig, letExistingBackend] = await Promise.all([ functionsConfig.getFirebaseConfig(options), backend.existingBackend(context), ]); + let existingBackend = letExistingBackend; await backend.checkAvailability(context, /* want=*/ backend.empty()); - const appEngineLocation = functionsConfig.getAppEngineLocation(config); + const appEngineLocation = functionsConfig.getAppEngineLocation(firebaseConfig); if (options.region) { - existingBackend.endpoints = { [options.region]: existingBackend.endpoints[options.region] }; + existingBackend = backend.matchingBackend( + existingBackend, + (ep) => ep.region === options.region, + ); } + + // Discover all active codebases directly from live endpoints in prod backend. + // If a codebase is not live in prod, there is nothing to delete. + const activeCodebases = [ + ...new Set( + backend + .allEndpoints(existingBackend) + .map((ep) => ep.codebase || projectConfig.DEFAULT_CODEBASE), + ), + ]; + const liveCodebasesConfig = activeCodebases.map((codebase) => ({ source: "", codebase })); + + const parsedFilters = filters.flatMap((f) => { + const parsed = helper.parseFunctionSelector(f, liveCodebasesConfig); + return parsed.map((filter) => + !f.includes(":") && filter.codebase === projectConfig.DEFAULT_CODEBASE && filter.idChunks + ? { idChunks: filter.idChunks } + : filter, + ); + }); + + context.filters = parsedFilters; + const plan = await planner.createDeploymentPlan({ wantBackend: backend.empty(), haveBackend: existingBackend, @@ -67,6 +94,31 @@ export const command = new Command("functions:delete [filters...]") ); } + // Inform the user when a name collision exists between a codebase name and a function name. + // Codebase deletion takes precedence by design, but we provide the explicit ':' workaround. + const allEndpoints = backend.allEndpoints(existingBackend); + for (const f of filters) { + if (f.includes(":") || !activeCodebases.includes(f)) { + continue; + } + const matchingEndpoints = allEndpoints.filter( + (ep) => ep.id === f || ep.id.startsWith(`${f}-`), + ); + if (matchingEndpoints.length > 0) { + const ep = matchingEndpoints[0]; + const prefix = ep.codebase || projectConfig.DEFAULT_CODEBASE; + utils.logLabeledBullet( + "functions", + `Target '${clc.bold(f)}' matches both a codebase and a function (${helper.getFunctionLabel( + ep, + )}). Codebase deletion takes precedence. ` + + `(To delete the function instead, run: ${clc.bold( + `firebase functions:delete ${prefix}:${f}`, + )})`, + ); + } + } + const deleteList = allEpToDelete.map((func) => `\t${helper.getFunctionLabel(func)}`).join("\n"); const confirmDeletion = await confirm({ message: @@ -104,7 +156,7 @@ export const command = new Command("functions:delete [filters...]") await reporter.logAndTrackDeployStats(summary); reporter.printErrors(summary); - } catch (err: any) { + } catch (err: unknown) { throw new FirebaseError("Failed to delete functions", { original: err as Error, exit: 1, diff --git a/src/deploy/functions/functionsDeployHelper.spec.ts b/src/deploy/functions/functionsDeployHelper.spec.ts index 07b8922f7c5..4b133b4cb64 100644 --- a/src/deploy/functions/functionsDeployHelper.spec.ts +++ b/src/deploy/functions/functionsDeployHelper.spec.ts @@ -95,19 +95,26 @@ describe("functionsDeployHelper", () => { expect( helper.endpointMatchesFilter(func, { ...BASE_FILTER, - codebase: "my-codebase", + codebase: DEFAULT_CODEBASE, idChunks: ["group", "subgroup", "func"], }), ).to.be.true; expect( helper.endpointMatchesFilter(func, { ...BASE_FILTER, - codebase: "my-codebase", + codebase: DEFAULT_CODEBASE, idChunks: ["group", "subgroup"], }), ).to.be.true; expect(helper.endpointMatchesFilter(func, { ...BASE_FILTER, idChunks: ["group"] })).to.be .true; + expect( + helper.endpointMatchesFilter(func, { + ...BASE_FILTER, + codebase: "non-default-codebase", + idChunks: ["group", "subgroup", "func"], + }), + ).to.be.false; }); it("should match function matching ids given no codebase", () => { @@ -135,6 +142,57 @@ describe("functionsDeployHelper", () => { }), ).to.be.true; }); + + it("should match all functions in a codebase when idChunks is not provided", () => { + const func1 = { ...ENDPOINT, id: "func1", codebase: "my-codebase" }; + const func2 = { ...ENDPOINT, id: "func2", codebase: "my-codebase" }; + const otherFunc = { ...ENDPOINT, id: "func3", codebase: "other-codebase" }; + const undefinedFunc = { ...ENDPOINT, id: "func4", codebase: undefined }; + + const filter: EndpointFilter = { codebase: "my-codebase" }; + expect(helper.endpointMatchesFilter(func1, filter)).to.be.true; + expect(helper.endpointMatchesFilter(func2, filter)).to.be.true; + expect(helper.endpointMatchesFilter(otherFunc, filter)).to.be.false; + expect(helper.endpointMatchesFilter(undefinedFunc, filter)).to.be.false; + }); + + it("should match a specific function in a specific codebase when multiple codebases have functions with the same name", () => { + const funcInCodebaseA = { ...ENDPOINT, id: "foo", codebase: "codebaseA" }; + const funcInCodebaseB = { ...ENDPOINT, id: "foo", codebase: "codebaseB" }; + + const filter: EndpointFilter = { + codebase: "codebaseA", + idChunks: ["foo"], + }; + + expect(helper.endpointMatchesFilter(funcInCodebaseA, filter)).to.be.true; + expect(helper.endpointMatchesFilter(funcInCodebaseB, filter)).to.be.false; + }); + + it("should not match overlapping codebase names", () => { + const instance1Func = { ...ENDPOINT, id: "foo", codebase: "kit-firestore-to-bigquery" }; + const instance2Func = { ...ENDPOINT, id: "foo", codebase: "kit-firestore-to-bigquery-abcd" }; + + const filter: EndpointFilter = { + codebase: "kit-firestore-to-bigquery", + }; + + expect(helper.endpointMatchesFilter(instance1Func, filter)).to.be.true; + expect(helper.endpointMatchesFilter(instance2Func, filter)).to.be.false; + }); + + it("should not match functions with overlapping word prefixes", () => { + const appFunc = { ...ENDPOINT, id: "app-render" }; + const appleFunc = { ...ENDPOINT, id: "apple-pay" }; + + const filter: EndpointFilter = { + codebase: DEFAULT_CODEBASE, + idChunks: ["app"], + }; + + expect(helper.endpointMatchesFilter(appFunc, filter)).to.be.true; + expect(helper.endpointMatchesFilter(appleFunc, filter)).to.be.false; + }); }); describe("endpointMatchesAnyFilters", () => { @@ -230,6 +288,20 @@ describe("functionsDeployHelper", () => { }, ], }, + { + desc: "parses codebase-qualified selector (codebase:func)", + selector: "codebaseA:foo", + config: [ + { source: "functions", codebase: "codebaseA" }, + { source: "other", codebase: "codebaseB" }, + ] as ValidatedConfig, + expected: [ + { + codebase: "codebaseA", + idChunks: ["foo"], + }, + ], + }, ]; for (const tc of testcases) { diff --git a/src/deploy/functions/functionsDeployHelper.ts b/src/deploy/functions/functionsDeployHelper.ts index 77a8ee0fce4..4d10cc8dfb8 100644 --- a/src/deploy/functions/functionsDeployHelper.ts +++ b/src/deploy/functions/functionsDeployHelper.ts @@ -1,5 +1,10 @@ import * as backend from "./backend"; -import { DEFAULT_CODEBASE, ValidatedConfig, isKitConfig } from "../../functions/projectConfig"; +import { + DEFAULT_CODEBASE, + ValidatedConfig, + ValidatedSingle, + isKitConfig, +} from "../../functions/projectConfig"; import { assertExhaustive } from "../../functional"; export interface EndpointFilter { @@ -27,37 +32,33 @@ export function endpointMatchesAnyFilter( /** * Returns true if endpoint matches the given filter. + * Supports filtering by codebase, exact function name, or hierarchical function group. */ export function endpointMatchesFilter(endpoint: backend.Endpoint, filter: EndpointFilter): boolean { - // Only enforce codebase-based filtering when both the endpoint and filter provides them. - // This allows us to filter using idChunks across all codebases. - if (endpoint.codebase && filter.codebase) { - if (endpoint.codebase !== filter.codebase) { + // If the filter targets a specific codebase, verify that the endpoint belongs to it. + // Endpoints without an explicit codebase label default to the default codebase. + if (filter.codebase) { + const endpointCodebase = endpoint.codebase || DEFAULT_CODEBASE; + if (endpointCodebase !== filter.codebase) { return false; } } - if (!filter.idChunks) { - // If idChunks is not provided, we match all functions. + // If idChunks is not provided or empty, the filter matches all functions within the targeted codebase. + if (!filter.idChunks || filter.idChunks.length === 0) { return true; } - const idChunks = endpoint.id.split("-"); - if (idChunks.length < filter.idChunks.length) { - return false; - } - for (let i = 0; i < filter.idChunks.length; i += 1) { - if (idChunks[i] !== filter.idChunks[i]) { - return false; - } - } - return true; + // Exact function match (e.g. 'myFunc') or hierarchical group match (e.g. 'groupA' matches 'groupA-func1'). + // Enforces a strict hyphen boundary so 'app' does not match 'apple-pay'. + const filterPrefix = filter.idChunks.join("-"); + return endpoint.id === filterPrefix || endpoint.id.startsWith(`${filterPrefix}-`); } /** * Returns all codebase names and kit instance IDs defined in the configuration. */ -export function getCodebasesFromConfig(config: ValidatedConfig): string[] { +export function getCodebasesFromConfig(config: ValidatedSingle[] = []): string[] { return [ ...new Set(config.flatMap((c) => (isKitConfig(c) ? Object.keys(c.instances) : [c.codebase]))), ]; @@ -66,7 +67,10 @@ export function getCodebasesFromConfig(config: ValidatedConfig): string[] { /** * Returns list of filters after parsing selector. */ -export function parseFunctionSelector(selector: string, config: ValidatedConfig): EndpointFilter[] { +export function parseFunctionSelector( + selector: string, + config: ValidatedSingle[] = [], +): EndpointFilter[] { const fragments = selector.split(":"); const target = fragments[0]; @@ -241,6 +245,6 @@ export function isCodebaseFiltered(codebase: string, filters: EndpointFilter[]): } /** Checks if a function should be filtered given a list of endpoints. */ -export function isEndpointFiltered(endpoint: backend.Endpoint, filters: EndpointFilter[]) { +export function isEndpointFiltered(endpoint: backend.Endpoint, filters: EndpointFilter[]): boolean { return filters.some((filter) => endpointMatchesFilter(endpoint, filter)); }