Skip to content
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
66 changes: 59 additions & 7 deletions src/commands/functions-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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";
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 <region>",
"Specify region of the function to be deleted. " +
Expand All @@ -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,
Expand All @@ -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 '<codebase>:<name>' 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:
Expand Down Expand Up @@ -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,
Expand Down
76 changes: 74 additions & 2 deletions src/deploy/functions/functionsDeployHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
});
Comment thread
shettyvarun268 marked this conversation as resolved.

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", () => {
Comment thread
shettyvarun268 marked this conversation as resolved.
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", () => {
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 24 additions & 20 deletions src/deploy/functions/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
Comment thread
shettyvarun268 marked this conversation as resolved.

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]))),
];
Expand All @@ -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];

Expand Down Expand Up @@ -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));
}
Loading