Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5a134ef
feat: add firebase.json schema support and validation for function kits
wandamora Jul 23, 2026
52d50d3
Support kit instances as codebases
wandamora Jul 24, 2026
0f18b35
Prompt users if specified target matches both kit and instance
wandamora Jul 24, 2026
07888e5
Revert back to simply treating instances as codebases
wandamora Jul 24, 2026
da210db
fix unit test and make instances required
wandamora Jul 27, 2026
baaeeaa
Add validation for kit and instance ids
wandamora Jul 27, 2026
0adcdbc
Regenerate json schema
wandamora Jul 27, 2026
833eda2
Improve type safety in functionsDeployHelper.spec.ts
wandamora Jul 27, 2026
5007fda
Update kit validation checks
wandamora Jul 27, 2026
96c2890
Update requiresLocal to accept kit configs
wandamora Jul 27, 2026
2ae7286
Fix generated json schema with index signature in "instances"
wandamora Jul 28, 2026
d1c1839
Support kit backends in the emulator
wandamora Jul 28, 2026
2937a81
Add prefixes for kit instance function names
wandamora Jul 28, 2026
ae5380b
Resolve instance config directory to top-level configDir
wandamora Jul 29, 2026
9ed45ce
Refactor unwanted properties out of KitFunctionConfig
wandamora Jul 29, 2026
8203892
Refactor kit prefix
wandamora Jul 30, 2026
cd1ee2a
Move codebase/instance name collection to a function
wandamora Jul 30, 2026
e990578
Throw an error if instance id begins or ends with a dash.
wandamora Jul 30, 2026
b31a9e6
set kits experiment to private
wandamora Jul 30, 2026
c95d52b
Merge branch 'main' into morawand-function-kits
wandamora Jul 30, 2026
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
93 changes: 93 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@
},
"FunctionConfig": {
"anyOf": [
{
"$ref": "#/definitions/KitFunctionConfig"
},
{
"$ref": "#/definitions/LocalFunctionConfig"
},
Expand Down Expand Up @@ -909,6 +912,93 @@
},
"type": "object"
},
"KitFunctionConfig": {
"additionalProperties": false,
"properties": {
"ignore": {
"items": {
"type": "string"
},
"type": "array"
},
"instances": {
"additionalProperties": {
"type": "string"
},
"description": "Dictionary mapping instance IDs to their configuration directories",
"type": "object"
},
"kit": {
"description": "Unique identifier for the functions kit (peer to codebase)",
"type": "string"
},
"postdeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"predeploy": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"runtime": {
"enum": [
"dart3",
"nodejs18",
"nodejs20",
"nodejs22",
"nodejs24",
"python310",
"python311",
"python312",
"python313",
"python314"
],
"type": "string"
},
"source": {
"description": "Local directory containing the kit source code.",
"type": "string"
},
"sourcePackage": {
"additionalProperties": false,
"description": "Package details when resolved from a package repository.",
"properties": {
"id": {
"description": "Package identifier (e.g., \"@firebase-functions-kits/firestore-bigquery-export\")",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
}
},
"required": [
"instances",
"kit",
"source"
],
"type": "object"
},
"LocalFunctionConfig": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1647,6 +1737,9 @@
},
"functions": {
"anyOf": [
{
"$ref": "#/definitions/KitFunctionConfig"
},
{
"$ref": "#/definitions/LocalFunctionConfig"
},
Expand Down
34 changes: 34 additions & 0 deletions src/deploy/functions/functionsDeployHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as helper from "./functionsDeployHelper";
import { Options } from "../../options";
import { DEFAULT_CODEBASE, ValidatedConfig } from "../../functions/projectConfig";
import { EndpointFilter, parseFunctionSelector } from "./functionsDeployHelper";
import * as experiments from "../../experiments";

describe("functionsDeployHelper", () => {
const ENDPOINT: backend.Endpoint = {
Expand Down Expand Up @@ -321,6 +322,21 @@ describe("functionsDeployHelper", () => {
.be.undefined;
});

it("should create codebase filter when selector matches kit instance ID", () => {
experiments.setEnabled("kits", true);
const config = [
{
kit: "my-kit",
source: "kits/my-kit",
instances: { "inst-1": "cfg1", "inst-2": "cfg2" },
},
] as ValidatedConfig;

const filters = helper.getEndpointFilters({ only: "functions:inst-1" }, config);
expect(filters).to.deep.equal([{ codebase: "inst-1" }]);
experiments.setEnabled("kits", null);
});

it("should create only codebase filter when selector matches codebase name", () => {
const config: ValidatedConfig = [
{ source: "functions", codebase: DEFAULT_CODEBASE },
Expand Down Expand Up @@ -398,6 +414,24 @@ describe("functionsDeployHelper", () => {
];
expect(helper.targetCodebases(config, filters)).to.have.members(["default", "foobar"]);
});

it("returns kit instance IDs as targeted codebases", () => {
experiments.setEnabled("kits", true);
const kitConfig: ValidatedConfig = [
{
kit: "my-kit",
source: "kits/my-kit",
instances: { "inst-1": "c1", "inst-2": "c2" },
} as ValidatedConfig[number],
{
source: "foo",
codebase: "default",
},
];
const filters: EndpointFilter[] = [{ codebase: "inst-1" }];
expect(helper.targetCodebases(kitConfig, filters)).to.have.members(["inst-1"]);
experiments.setEnabled("kits", null);
});
});

describe("groupEndpointsByCodebase", () => {
Expand Down
49 changes: 28 additions & 21 deletions src/deploy/functions/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as backend from "./backend";
import { DEFAULT_CODEBASE, ValidatedConfig } from "../../functions/projectConfig";
import { DEFAULT_CODEBASE, ValidatedConfig, isKitConfig } from "../../functions/projectConfig";
import { assertExhaustive } from "../../functional";

export interface EndpointFilter {
Expand Down Expand Up @@ -54,23 +54,36 @@
return true;
}

/**
* Returns all codebase names and kit instance IDs defined in the configuration.
*/
export function getCodebasesFromConfig(config: ValidatedConfig): string[] {
return [
...new Set(config.flatMap((c) => (isKitConfig(c) ? Object.keys(c.instances) : [c.codebase]))),
];
}

/**
* Returns list of filters after parsing selector.
*/
export function parseFunctionSelector(selector: string, config: ValidatedConfig): EndpointFilter[] {
const fragments = selector.split(":");
const target = fragments[0];

// Check if target matches a known codebase name or kit instance ID
const codebaseNames = getCodebasesFromConfig(config);

if (codebaseNames.includes(target)) {
return [
{
codebase: target,
...(fragments.length > 1 ? { idChunks: fragments[1].split(/[-.]/) } : {}),
},
];
}

if (fragments.length < 2) {
// This is a plain selector w/o codebase prefix (e.g. "abc" not "abc:efg") .
// This could mean 2 things:
//
// 1. Codebase selector (i.e. "abc" refers to a codebase).
// 2. Id filter for the DEFAULT codebase (i.e. "abc" refers to a function in the default codebase).
const codebaseNames = config.map((c) => c.codebase);
if (codebaseNames.includes(fragments[0])) {
// It's a known codebase name
return [{ codebase: fragments[0] }];
}
// It's not a codebase name, assume it is a function id in default codebase
// It's not a codebase or kit instance name, assume it is a function id in default codebase
return [{ codebase: DEFAULT_CODEBASE, idChunks: fragments[0].split(/[-.]/) }];
}
return [
Expand Down Expand Up @@ -158,26 +171,20 @@
* Returns list of codebases specified in firebase.json filtered by --only filters if present.
*/
export function targetCodebases(config: ValidatedConfig, filters?: EndpointFilter[]): string[] {
const codebasesFromConfig = [...new Set(Object.values(config).map((c) => c.codebase))];
const codebasesFromConfig = getCodebasesFromConfig(config);
if (!filters) {
return [...codebasesFromConfig];
}

const codebasesFromFilters = [
...new Set(filters.map((f) => f.codebase).filter((c) => c !== undefined)),
...new Set(filters.map((f) => f.codebase).filter((c): c is string => c !== undefined)),
];

if (codebasesFromFilters.length === 0) {
return [...codebasesFromConfig];
}

const intersections: string[] = [];
for (const codebase of codebasesFromConfig) {
if (codebasesFromFilters.includes(codebase)) {
intersections.push(codebase);
}
}
return intersections;
return codebasesFromConfig.filter((codebase) => codebasesFromFilters.includes(codebase));
}

/**
Expand Down Expand Up @@ -234,6 +241,6 @@
}

/** Checks if a function should be filtered given a list of endpoints. */
export function isEndpointFiltered(endpoint: backend.Endpoint, filters: EndpointFilter[]) {

Check warning on line 244 in src/deploy/functions/functionsDeployHelper.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
return filters.some((filter) => endpointMatchesFilter(endpoint, filter));
}
47 changes: 47 additions & 0 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as sinon from "sinon";
import * as build from "./build";
import * as prepare from "./prepare";
import * as experiments from "../../experiments";
import * as runtimes from "./runtimes";
import * as backend from "./backend";
import * as ensureApiEnabled from "../../ensureApiEnabled";
Expand Down Expand Up @@ -102,6 +103,52 @@
expect(Object.keys(builds.codebase.endpoints)).to.deep.equal(["my-prefix-test"]);
});

it("should automatically apply the kit instance ID as the prefix for kit function builds", async () => {
experiments.setEnabled("kits", true);
discoverBuildStub.callsFake(() =>
Promise.resolve(
build.of({
test: {
platform: "gcfv2",
entryPoint: "test",
project: "project",
runtime: latest("nodejs"),
httpsTrigger: {},
},
}),
),
);
try {
const config: ValidatedConfig = [
{
kit: "my-kit",
sourcePackage: { id: "@firebase-functions-kits/my-kit" },
source: "source",
instances: {
"inst-alpha": "config/inst-alpha",
"inst-beta": "config/inst-beta",
},
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);

expect(Object.keys(builds["inst-alpha"].endpoints)).to.deep.equal(["kit-inst-alpha-test"]);
expect(Object.keys(builds["inst-beta"].endpoints)).to.deep.equal(["kit-inst-beta-test"]);
} finally {
experiments.setEnabled("kits", null);
}
});

it("should preserve runtime from codebase config", async () => {
const config: ValidatedConfig = [
{ source: "source", codebase: "codebase", runtime: "nodejs20" },
Expand Down Expand Up @@ -142,11 +189,11 @@
.to.be.rejectedWith(FirebaseError)
.then((error) => {
// Should always list latest runtimes
expect(error.message).to.include(latest("nodejs"));

Check warning on line 192 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
expect(error.message).to.include(latest("python"));

Check warning on line 193 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value

// Should never list a decommissioned runtime
expect(error.message).to.not.include("nodejs6");

Check warning on line 196 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value
});
});

Expand Down Expand Up @@ -728,7 +775,7 @@
...ENDPOINT_BASE,
httpsTrigger: {},
};
const have: backend.Endpoint = JSON.parse(JSON.stringify(want));

Check warning on line 778 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
have.timeoutSeconds = 120;

prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false);
Expand Down Expand Up @@ -985,7 +1032,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.empty(),
backend.of(nonGenkitEndpoint),
{} as any,

Check warning on line 1035 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 1035 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand All @@ -994,7 +1041,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.empty(),
backend.of(genkitEndpointWithSecrets),
{} as any,

Check warning on line 1044 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 1044 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand All @@ -1003,7 +1050,7 @@
await prepare.warnIfNewGenkitFunctionIsMissingSecrets(
backend.of(genkitEndpointWithoutSecrets),
backend.of(genkitEndpointWithoutSecrets),
{} as any,

Check warning on line 1053 in src/deploy/functions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
);
expect(confirm).to.not.be.called;
});
Expand Down
20 changes: 17 additions & 3 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
ValidatedConfig,
requireLocal,
shouldUseRuntimeConfig,
isKitConfig,
addKitPrefix,
} from "../../functions/projectConfig";
import { AUTH_BLOCKING_EVENTS } from "../../functions/events/v1";
import { generateServiceIdentity } from "../../gcp/serviceusage";
Expand Down Expand Up @@ -240,7 +242,12 @@ export async function prepare(
// ===Phase 1. Load codebases from source with optional runtime config.
let runtimeConfig: Record<string, unknown> = { firebase: firebaseConfig };

const targetedCodebaseConfigs = context.config.filter((cfg) => codebases.includes(cfg.codebase));
const targetedCodebaseConfigs = context.config.filter((cfg) => {
if (isKitConfig(cfg)) {
return cfg.instances && Object.keys(cfg.instances).some((inst) => codebases.includes(inst));
}
return cfg.codebase && codebases.includes(cfg.codebase);
});

// Load runtime config if API is enabled and at least one targeted codebase uses it
if (checkAPIsEnabled[1] && targetedCodebaseConfigs.some(shouldUseRuntimeConfig)) {
Expand Down Expand Up @@ -282,7 +289,11 @@ export async function prepare(
projectId: projectId,
projectAlias: options.projectAlias,
};
proto.convertIfPresent(userEnvOpt, localCfg, "configDir", (cd) => options.config.path(cd));
if (isKitConfig(localCfg) && codebase in localCfg.instances) {
userEnvOpt.configDir = options.config.path(localCfg.instances[codebase]);
} else {
proto.convertIfPresent(userEnvOpt, localCfg, "configDir", (cd) => options.config.path(cd));
}

const rawUserEnvs = functionsEnv.loadUserEnvs(userEnvOpt);
const { userEnvs: userEnvs, secretRefs: secretRefs } = partitionUserEnvs(rawUserEnvs);
Expand Down Expand Up @@ -780,7 +791,10 @@ export async function loadCodebases(
GOOGLE_CLOUD_QUOTA_PROJECT: projectId,
});
discoveredBuild.runtime = codebaseConfig.runtime;
build.applyPrefix(discoveredBuild, codebaseConfig.prefix || "");
const prefix = isKitConfig(codebaseConfig)
? addKitPrefix(codebase)
: codebaseConfig.prefix || "";
build.applyPrefix(discoveredBuild, prefix);
wantBuilds[codebase] = discoveredBuild;
}
return wantBuilds;
Expand Down
Loading
Loading