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
1 change: 1 addition & 0 deletions src/deploy/functions/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/deploy/functions/checkIam.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
Expand Down
73 changes: 73 additions & 0 deletions src/deploy/functions/functionsDeployHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
64 changes: 43 additions & 21 deletions src/deploy/functions/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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:")) {
Expand Down Expand Up @@ -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;
}

/**
Expand Down
32 changes: 31 additions & 1 deletion src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -57,7 +58,9 @@ describe("prepare", () => {
},
}),
);
sandbox.stub(runtimes, "getRuntimeDelegate").resolves(runtimeDelegateStub);
getRuntimeDelegateStub = sandbox.stub(runtimes, "getRuntimeDelegate").resolves(
runtimeDelegateStub,
);
});

afterEach(() => {
Expand Down Expand Up @@ -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", () => {
Expand Down
19 changes: 15 additions & 4 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Options } from "../../options";
import {
EndpointFilter,
endpointMatchesAnyFilter,
endpointMatchesDeploymentFilters,
getEndpointFilters,
groupEndpointsByCodebase,
targetCodebases,
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -112,6 +121,7 @@ export async function prepare(
firebaseConfig,
runtimeConfig,
context.filters,
context.filtersExcept,
);

// == Phase 1.5 Prepare extensions found in codebases if any
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -446,8 +456,9 @@ export async function loadCodebases(
firebaseConfig: args.FirebaseConfig,
runtimeConfig: Record<string, unknown>,
filters?: EndpointFilter[],
excludeFilters?: EndpointFilter[],
): Promise<Record<string, build.Build>> {
const codebases = targetCodebases(config, filters);
const codebases = targetCodebases(config, filters, excludeFilters);
const projectId = needProjectId(options);

const wantBuilds: Record<string, build.Build> = {};
Expand Down
1 change: 1 addition & 0 deletions src/deploy/functions/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function release(
wantBackend,
haveBackend,
filters: context.filters,
excludeFilters: context.filtersExcept,
}),
};
}
Expand Down
12 changes: 8 additions & 4 deletions src/deploy/functions/release/planner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
EndpointFilter,
endpointMatchesAnyFilter,
endpointMatchesDeploymentFilters,
getFunctionLabel,
} from "../functionsDeployHelper";
import { isFirebaseManaged } from "../../../deploymentTool";
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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([
Expand Down