diff --git a/src/deploy/functions/args.ts b/src/deploy/functions/args.ts index ca02fd4874d..91c95f7b268 100644 --- a/src/deploy/functions/args.ts +++ b/src/deploy/functions/args.ts @@ -36,6 +36,7 @@ export interface Payload { export interface Context { projectId: string; filters?: deployHelper.EndpointFilter[]; + filtersExcept?: deployHelper.EndpointFilter[]; // Filled in the "prepare" phase. config?: projectConfig.ValidatedConfig; diff --git a/src/deploy/functions/checkIam.ts b/src/deploy/functions/checkIam.ts index 211aa1a440c..f44d618293b 100644 --- a/src/deploy/functions/checkIam.ts +++ b/src/deploy/functions/checkIam.ts @@ -1,7 +1,10 @@ import { bold } from "colorette"; import { logger } from "../../logger"; -import { getEndpointFilters, endpointMatchesAnyFilter } from "./functionsDeployHelper"; +import { + getEndpointFilters, + endpointMatchesDeploymentFilters, +} from "./functionsDeployHelper"; import { FirebaseError } from "../../error"; import { Options } from "../../options"; import { flattenArray } from "../../functional"; @@ -75,10 +78,12 @@ export async function checkHttpIam( return; } const filters = context.filters || getEndpointFilters(options, context.config!); + const excludeFilters = + context.filtersExcept || getEndpointFilters(options, context.config!, "except"); const wantBackends = Object.values(payload.functions).map(({ wantBackend }) => wantBackend); const httpEndpoints = [...flattenArray(wantBackends.map((b) => backend.allEndpoints(b)))] .filter(backend.isHttpsTriggered) - .filter((f) => endpointMatchesAnyFilter(f, filters)); + .filter((f) => endpointMatchesDeploymentFilters(f, filters, excludeFilters)); const existing = await backend.existingBackend(context); const newHttpsEndpoints = httpEndpoints.filter(backend.missingEndpoint(existing)); diff --git a/src/deploy/functions/functionsDeployHelper.spec.ts b/src/deploy/functions/functionsDeployHelper.spec.ts index 68c33e73d84..7add12d5039 100644 --- a/src/deploy/functions/functionsDeployHelper.spec.ts +++ b/src/deploy/functions/functionsDeployHelper.spec.ts @@ -163,6 +163,38 @@ describe("functionsDeployHelper", () => { }); }); + describe("endpointMatchesDeploymentFilters", () => { + it("should include functions when no filters provided", () => { + const func = { ...ENDPOINT, id: "id" }; + expect(helper.endpointMatchesDeploymentFilters(func)).to.be.true; + }); + + it("should include only functions matching include filters", () => { + const func = { ...ENDPOINT, id: "match-me" }; + expect( + helper.endpointMatchesDeploymentFilters(func, [ + { ...BASE_FILTER, idChunks: ["different"] }, + ]), + ).to.be.false; + expect( + helper.endpointMatchesDeploymentFilters(func, [ + { ...BASE_FILTER, idChunks: ["match", "me"] }, + ]), + ).to.be.true; + }); + + it("should exclude functions matching exclude filters", () => { + const func = { ...ENDPOINT, id: "blocked-func" }; + expect( + helper.endpointMatchesDeploymentFilters( + func, + [{ ...BASE_FILTER }], + [{ ...BASE_FILTER, idChunks: ["blocked"] }], + ), + ).to.be.false; + }); + }); + describe("parseFunctionSelector", () => { interface Testcase { desc: string; @@ -351,6 +383,21 @@ describe("functionsDeployHelper", () => { expect(actual?.length).to.equal(1); expect(actual).to.deep.equal([{ codebase: DEFAULT_CODEBASE, idChunks: ["other"] }]); }); + + it("parses filters from the --except option when requested", () => { + const config: ValidatedConfig = [ + { source: "functions", codebase: DEFAULT_CODEBASE }, + { source: "other-functions", codebase: "other" }, + ] as ValidatedConfig; + + const options = { + except: "functions:other", + } as Options; + + const actual = helper.getEndpointFilters(options, config, "except"); + + expect(actual).to.deep.equal([{ codebase: "other" }]); + }); }); describe("targetCodebases", () => { @@ -398,6 +445,32 @@ describe("functionsDeployHelper", () => { ]; expect(helper.targetCodebases(config, filters)).to.have.members(["default", "foobar"]); }); + + it("excludes codebases specified by exclude filters without id chunks", () => { + const excludeFilters: EndpointFilter[] = [ + { + codebase: "foobar", + }, + ]; + + expect(helper.targetCodebases(config, undefined, excludeFilters)).to.have.members([ + "default", + ]); + }); + + it("does not exclude codebases when exclude filters include id chunks", () => { + const excludeFilters: EndpointFilter[] = [ + { + codebase: "foobar", + idChunks: ["some", "func"], + }, + ]; + + expect(helper.targetCodebases(config, undefined, excludeFilters)).to.have.members([ + "default", + "foobar", + ]); + }); }); describe("groupEndpointsByCodebase", () => { diff --git a/src/deploy/functions/functionsDeployHelper.ts b/src/deploy/functions/functionsDeployHelper.ts index c32d7a1e062..b98c2b93ec6 100644 --- a/src/deploy/functions/functionsDeployHelper.ts +++ b/src/deploy/functions/functionsDeployHelper.ts @@ -25,6 +25,23 @@ export function endpointMatchesAnyFilter( return filters.some((filter) => endpointMatchesFilter(endpoint, filter)); } +/** + * Returns true if an endpoint should be deployed based on include/exclude filters. + * + * - If includeFilters is undefined, all endpoints are included by default. + * - If excludeFilters is provided and the endpoint matches, it is excluded even if included. + */ +export function endpointMatchesDeploymentFilters( + endpoint: backend.Endpoint, + includeFilters?: EndpointFilter[], + excludeFilters?: EndpointFilter[], +): boolean { + if (excludeFilters?.length && endpointMatchesAnyFilter(endpoint, excludeFilters)) { + return false; + } + return endpointMatchesAnyFilter(endpoint, includeFilters); +} + /** * Returns true if endpoint matches the given filter. */ @@ -105,14 +122,16 @@ export function parseFunctionSelector(selector: string, config: ValidatedConfig) * If no filter exists, we return undefined which the caller should interpret as "match all functions". */ export function getEndpointFilters( - options: { only?: string }, + options: { only?: string; except?: string }, config: ValidatedConfig, + filterType: "only" | "except" = "only", ): EndpointFilter[] | undefined { - if (!options.only) { + const rawFilters = filterType === "only" ? options.only : options.except; + if (!rawFilters) { return undefined; } - const selectors = options.only.split(","); + const selectors = rawFilters.split(","); const filters: EndpointFilter[] = []; for (let selector of selectors) { if (selector.startsWith("functions:")) { @@ -155,29 +174,32 @@ export function getFunctionLabel(fn: backend.TargetIds & { codebase?: string }): } /** - * Returns list of codebases specified in firebase.json filtered by --only filters if present. + * Returns list of codebases specified in firebase.json filtered by --only/--except filters if present. */ -export function targetCodebases(config: ValidatedConfig, filters?: EndpointFilter[]): string[] { - const codebasesFromConfig = [...new Set(Object.values(config).map((c) => c.codebase))]; - if (!filters) { - return [...codebasesFromConfig]; - } - - const codebasesFromFilters = [ - ...new Set(filters.map((f) => f.codebase).filter((c) => c !== undefined)), - ]; +export function targetCodebases( + config: ValidatedConfig, + filters?: EndpointFilter[], + excludeFilters?: EndpointFilter[], +): string[] { + let targetedCodebases = [...new Set(Object.values(config).map((c) => c.codebase))]; + if (filters) { + const codebasesFromFilters = [ + ...new Set(filters.map((f) => f.codebase).filter((c) => c !== undefined)), + ]; - if (codebasesFromFilters.length === 0) { - return [...codebasesFromConfig]; + if (codebasesFromFilters.length > 0) { + targetedCodebases = targetedCodebases.filter((codebase) => + codebasesFromFilters.includes(codebase), + ); + } } - const intersections: string[] = []; - for (const codebase of codebasesFromConfig) { - if (codebasesFromFilters.includes(codebase)) { - intersections.push(codebase); - } + if (excludeFilters?.length) { + targetedCodebases = targetedCodebases.filter( + (codebase) => !isCodebaseFiltered(codebase, excludeFilters), + ); } - return intersections; + return targetedCodebases; } /** diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index d0ee5d59f0b..17c79cc5e2c 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -33,6 +33,7 @@ describe("prepare", () => { let sandbox: sinon.SinonSandbox; let runtimeDelegateStub: RuntimeDelegate; let discoverBuildStub: sinon.SinonStub; + let getRuntimeDelegateStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); @@ -57,7 +58,9 @@ describe("prepare", () => { }, }), ); - sandbox.stub(runtimes, "getRuntimeDelegate").resolves(runtimeDelegateStub); + getRuntimeDelegateStub = sandbox.stub(runtimes, "getRuntimeDelegate").resolves( + runtimeDelegateStub, + ); }); afterEach(() => { @@ -151,6 +154,33 @@ describe("prepare", () => { expect(callArgs[0]).to.deep.equal(runtimeConfig); expect(callArgs[0]).to.have.property("customKey", "customValue"); }); + + it("should skip codebases filtered out by exclude filters", async () => { + const config: ValidatedConfig = [ + { source: "source", codebase: "keep", runtime: "nodejs22" }, + { source: "other-source", codebase: "skip-me", 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, + undefined, + [{ codebase: "skip-me" }], + ); + + expect(Object.keys(builds)).to.deep.equal(["keep"]); + expect(getRuntimeDelegateStub.calledOnce).to.be.true; + }); }); describe("inferDetailsFromExisting", () => { diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 1ee2e455cb7..6ce33374ba2 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -25,6 +25,7 @@ import { Options } from "../../options"; import { EndpointFilter, endpointMatchesAnyFilter, + endpointMatchesDeploymentFilters, getEndpointFilters, groupEndpointsByCodebase, targetCodebases, @@ -69,10 +70,18 @@ export async function prepare( context.config = normalizeAndValidate(options.config.src.functions); context.filters = getEndpointFilters(options, context.config); // Parse --only filters for functions. + context.filtersExcept = getEndpointFilters(options, context.config, "except"); - const codebases = targetCodebases(context.config, context.filters); + const codebases = targetCodebases(context.config, context.filters, context.filtersExcept); if (codebases.length === 0) { - throw new FirebaseError("No function matches given --only filters. Aborting deployment."); + if (context.filters?.length) { + throw new FirebaseError("No function matches given --only filters. Aborting deployment."); + } + logLabeledBullet( + "functions", + "No functions codebases to deploy after applying --except filters, skipping.", + ); + return; } for (const codebase of codebases) { logLabeledBullet("functions", `preparing codebase ${clc.bold(codebase)} for deployment`); @@ -112,6 +121,7 @@ export async function prepare( firebaseConfig, runtimeConfig, context.filters, + context.filtersExcept, ); // == Phase 1.5 Prepare extensions found in codebases if any @@ -266,7 +276,7 @@ export async function prepare( // ===Phase 6. Ask for user prompts for things might warrant user attentions. // We limit the scope endpoints being deployed. const matchingBackend = backend.matchingBackend(wantBackend, (endpoint) => { - return endpointMatchesAnyFilter(endpoint, context.filters); + return endpointMatchesDeploymentFilters(endpoint, context.filters, context.filtersExcept); }); await promptForFailurePolicies(options, matchingBackend, haveBackend); await promptForMinInstances(options, matchingBackend, haveBackend); @@ -446,8 +456,9 @@ export async function loadCodebases( firebaseConfig: args.FirebaseConfig, runtimeConfig: Record, filters?: EndpointFilter[], + excludeFilters?: EndpointFilter[], ): Promise> { - const codebases = targetCodebases(config, filters); + const codebases = targetCodebases(config, filters, excludeFilters); const projectId = needProjectId(options); const wantBuilds: Record = {}; diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 97705a8cdb2..203ff75fb70 100644 --- a/src/deploy/functions/release/index.ts +++ b/src/deploy/functions/release/index.ts @@ -48,6 +48,7 @@ export async function release( wantBackend, haveBackend, filters: context.filters, + excludeFilters: context.filtersExcept, }), }; } diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index c125c183bec..2ca2d680de4 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -1,6 +1,6 @@ import { EndpointFilter, - endpointMatchesAnyFilter, + endpointMatchesDeploymentFilters, getFunctionLabel, } from "../functionsDeployHelper"; import { isFirebaseManaged } from "../../../deploymentTool"; @@ -29,6 +29,7 @@ export interface PlanArgs { haveBackend: backend.Backend; // the current state codebase: string; // target codebase of the deployment filters?: EndpointFilter[]; // filters to apply to backend, passed from users by --only flag + excludeFilters?: EndpointFilter[]; // filters to exclude from deploy, passed from users by --except flag deleteAll?: boolean; // deletes all functions if set } @@ -130,14 +131,17 @@ export function calculateUpdate(want: backend.Endpoint, have: backend.Endpoint): * Create a plan for deploying all functions in one region. */ export function createDeploymentPlan(args: PlanArgs): DeploymentPlan { - let { wantBackend, haveBackend, codebase, filters, deleteAll } = args; + let { wantBackend, haveBackend, codebase, filters, excludeFilters, deleteAll } = args; let deployment: DeploymentPlan = {}; wantBackend = backend.matchingBackend(wantBackend, (endpoint) => { - return endpointMatchesAnyFilter(endpoint, filters); + return endpointMatchesDeploymentFilters(endpoint, filters, excludeFilters); }); const wantedEndpoint = backend.hasEndpoint(wantBackend); haveBackend = backend.matchingBackend(haveBackend, (endpoint) => { - return wantedEndpoint(endpoint) || endpointMatchesAnyFilter(endpoint, filters); + return ( + wantedEndpoint(endpoint) || + endpointMatchesDeploymentFilters(endpoint, filters, excludeFilters) + ); }); const regions = new Set([