Skip to content

Commit 4349832

Browse files
authored
Add support for BUILD-available secrets for Local Builds (#10229)
* Enable secret resolution during local App Hosting builds * fix build error * Handle env vars (especially secrets) with a Promise.all so it can be parallelized
1 parent e28281f commit 4349832

6 files changed

Lines changed: 212 additions & 61 deletions

File tree

src/apphosting/localbuilds.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as sinon from "sinon";
22
import { expect } from "chai";
33
import * as localBuildModule from "@apphosting/build";
44
import { localBuild } from "./localbuilds";
5+
import * as secrets from "./secrets";
6+
import { EnvMap } from "./yaml";
57

68
describe("localBuild", () => {
79
afterEach(() => {
@@ -38,10 +40,81 @@ describe("localBuild", () => {
3840
const localApphostingBuildStub: sinon.SinonStub = sinon
3941
.stub(localBuildModule, "localBuild")
4042
.resolves(bundleConfig);
41-
const { outputFiles, annotations, buildConfig } = await localBuild("./", "nextjs");
43+
const { outputFiles, annotations, buildConfig } = await localBuild(
44+
"test-project",
45+
"./",
46+
"nextjs",
47+
);
4248
expect(annotations).to.deep.equal(expectedAnnotations);
4349
expect(buildConfig).to.deep.equal(expectedBuildConfig);
4450
expect(outputFiles).to.deep.equal(expectedOutputFiles);
4551
sinon.assert.calledWith(localApphostingBuildStub, "./", "nextjs");
4652
});
53+
54+
it("resolves BUILD-available secrets passed in the environment map and ignores RUNTIME-only ones", async () => {
55+
const bundleConfig = {
56+
version: "v1" as const,
57+
runConfig: { runCommand: "npm run build:prod" },
58+
metadata: {
59+
adapterPackageName: "@apphosting/angular-adapter",
60+
adapterVersion: "14.1",
61+
framework: "nextjs",
62+
},
63+
outputFiles: { serverApp: { include: ["./next/standalone"] } },
64+
};
65+
sinon.stub(localBuildModule, "localBuild").callsFake(async () => {
66+
expect(process.env.MY_BUILD_SECRET).to.equal("secret-value");
67+
expect(process.env.MY_RUNTIME_SECRET).to.be.undefined;
68+
expect(process.env.MY_PLAIN_VAR).to.equal("plain-value");
69+
return bundleConfig;
70+
});
71+
const loadSecretStub = sinon.stub(secrets, "loadSecret").resolves("secret-value");
72+
73+
const envMap: EnvMap = {
74+
MY_BUILD_SECRET: { secret: "my-secret-id", availability: ["BUILD"] },
75+
MY_RUNTIME_SECRET: { secret: "runtime-only-id", availability: ["RUNTIME"] },
76+
MY_PLAIN_VAR: { value: "plain-value" },
77+
};
78+
79+
await localBuild("test-project", "./", "nextjs", envMap);
80+
81+
expect(loadSecretStub).to.have.been.calledWith("test-project", "my-secret-id");
82+
// Confirm RUNTIME-only secret was ignored
83+
expect(loadSecretStub).to.have.been.calledOnce;
84+
// Confirm injected envs were cleaned up from the global scope after the build finishes
85+
expect(process.env.MY_BUILD_SECRET).to.be.undefined;
86+
expect(process.env.MY_RUNTIME_SECRET).to.be.undefined;
87+
});
88+
89+
it("handles environment variables that do not contain secrets", async () => {
90+
const bundleConfig = {
91+
version: "v1" as const,
92+
runConfig: { runCommand: "npm run build:prod" },
93+
metadata: {
94+
adapterPackageName: "@apphosting/angular-adapter",
95+
adapterVersion: "14.1",
96+
framework: "nextjs",
97+
},
98+
outputFiles: { serverApp: { include: ["./next/standalone"] } },
99+
};
100+
sinon.stub(localBuildModule, "localBuild").callsFake(async () => {
101+
expect(process.env.MY_PLAIN_VAR).to.equal("plain-value");
102+
expect(process.env.ANOTHER_VAR).to.equal("another-value");
103+
return bundleConfig;
104+
});
105+
const loadSecretStub = sinon.stub(secrets, "loadSecret").resolves("secret-value");
106+
107+
const envMap: EnvMap = {
108+
MY_PLAIN_VAR: { value: "plain-value" },
109+
ANOTHER_VAR: { value: "another-value" },
110+
};
111+
112+
await localBuild("test-project", "./", "nextjs", envMap);
113+
114+
expect(loadSecretStub).to.not.have.been.called;
115+
// We expect the original process.env to not have these injected globally after run completes,
116+
// as localBuild cleans up.
117+
expect(process.env.MY_PLAIN_VAR).to.be.undefined;
118+
expect(process.env.ANOTHER_VAR).to.be.undefined;
119+
});
47120
});

src/apphosting/localbuilds.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import { BuildConfig, Env } from "../gcp/apphosting";
22
import { localBuild as localAppHostingBuild } from "@apphosting/build";
33
import { EnvMap } from "./yaml";
4+
import { loadSecret } from "./secrets";
45

56
/**
67
* Triggers a local build of your App Hosting codebase.
78
*
89
* This function orchestrates the build process using the App Hosting build adapter.
910
* It detects the framework (though currently defaults/assumes 'nextjs' in some contexts),
1011
* generates the necessary build artifacts, and returns metadata about the build.
12+
* @param projectId - The project ID to use for resolving secrets.
1113
* @param projectRoot - The root directory of the project to build.
1214
* @param framework - The framework to use for the build (e.g., 'nextjs').
15+
* @param env - The environment configuration map to resolve and inject into the build.
1316
* @return A promise that resolves to the build output, including:
1417
* - `outputFiles`: Paths to the generated build artifacts.
1518
* - `annotations`: Metadata annotations relating to the build.
1619
* - `buildConfig`: Configuration derived from the build process (e.g. run commands, environment variables).
1720
*/
1821
export async function localBuild(
22+
projectId: string,
1923
projectRoot: string,
2024
framework: string,
2125
env: EnvMap = {},
@@ -29,7 +33,7 @@ export async function localBuild(
2933
// We'll restore the original process.env after the build is done.
3034
const originalEnv = { ...process.env };
3135

32-
const addedEnv = toProcessEnv(env);
36+
const addedEnv = await toProcessEnv(projectId, env);
3337
for (const [key, value] of Object.entries(addedEnv)) {
3438
process.env[key] = value;
3539
}
@@ -71,8 +75,22 @@ export async function localBuild(
7175
};
7276
}
7377

74-
function toProcessEnv(env: EnvMap): NodeJS.ProcessEnv {
75-
return Object.fromEntries(
76-
Object.entries(env).map(([key, value]) => [key, value.value || ""]),
77-
) as NodeJS.ProcessEnv;
78+
async function toProcessEnv(projectId: string, env: EnvMap): Promise<NodeJS.ProcessEnv> {
79+
const entries = await Promise.all(
80+
Object.entries(env).map(async ([key, value]) => {
81+
if (value.availability && !value.availability.includes("BUILD")) {
82+
return null;
83+
}
84+
85+
if (value.secret) {
86+
const resolvedValue = await loadSecret(projectId, value.secret);
87+
return [key, resolvedValue];
88+
} else {
89+
return [key, value.value || ""];
90+
}
91+
}),
92+
);
93+
94+
const filteredEntries = entries.filter((entry): entry is [string, string] => entry !== null);
95+
return Object.fromEntries(filteredEntries) as NodeJS.ProcessEnv;
7896
}

src/apphosting/secrets/index.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function grantEmailsSecretAccess(
186186
* If a secret exists, we verify the user is not trying to change the region and verifies a secret
187187
* is not being used for both functions and app hosting as their garbage collection is incompatible
188188
* (client vs server-side).
189-
* @returns true if a secret was created, false if a secret already existed, and null if a user aborts.
189+
* @return true if a secret was created, false if a secret already existed, and null if a user aborts.
190190
*/
191191
export async function upsertSecret(
192192
project: string,
@@ -235,6 +235,65 @@ export async function upsertSecret(
235235
return false;
236236
}
237237

238+
/**
239+
* Matches a fully qualified secret or version name, e.g.
240+
* projects/my-project/secrets/my-secret/versions/1
241+
* projects/my-project/secrets/my-secret/versions/latest
242+
* projects/my-project/secrets/my-secret
243+
*/
244+
const secretResourceRegex =
245+
/^projects\/([^/]+)\/secrets\/([^/]+)(?:\/versions\/((?:latest)|\d+))?$/;
246+
247+
/**
248+
* Matches a shorthand for a project-relative secret, with optional version, e.g.
249+
* my-secret
250+
* my-secret@1
251+
* my-secret@latest
252+
*/
253+
const secretShorthandRegex = /^([^/@]+)(?:@((?:latest)|\d+))?$/;
254+
255+
/**
256+
* Resolves a secret name into its plaintext value using the Secret Manager access API.
257+
* Supports both fully qualified resource names and shorthand strings (e.g. `secret@version`).
258+
*/
259+
export async function loadSecret(project: string | undefined, name: string): Promise<string> {
260+
let projectId: string;
261+
let secretId: string;
262+
let version: string;
263+
const match = secretResourceRegex.exec(name);
264+
if (match) {
265+
projectId = match[1];
266+
secretId = match[2];
267+
version = match[3] || "latest";
268+
} else {
269+
const match = secretShorthandRegex.exec(name);
270+
if (!match) {
271+
throw new FirebaseError(`Invalid secret name: ${name}`);
272+
}
273+
if (!project) {
274+
throw new FirebaseError(
275+
`Cannot load secret ${match[1]} without a project. ` +
276+
`Please use ${clc.bold("firebase use")} or pass the --project flag.`,
277+
);
278+
}
279+
projectId = project;
280+
secretId = match[1];
281+
version = match[2] || "latest";
282+
}
283+
try {
284+
return await gcsm.accessSecretVersion(projectId, secretId, version);
285+
} catch (err: any) {
286+
if (err?.original?.code === 403 || err?.original?.context?.response?.statusCode === 403) {
287+
utils.logLabeledError(
288+
"apphosting",
289+
`Permission denied to access secret ${secretId}. Use ` +
290+
`${clc.bold("firebase apphosting:secrets:grantaccess")} to get permissions.`,
291+
);
292+
}
293+
throw err;
294+
}
295+
}
296+
238297
/**
239298
* Fetches secrets from Google Secret Manager and returns their values in plain text.
240299
*/

src/deploy/apphosting/prepare.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ describe("apphosting", () => {
197197
await prepare(context, optsWithLocalBuild);
198198

199199
expect(localBuildStub).to.be.calledWithMatch(
200+
"my-project",
200201
sinon.match.any,
201202
"nextjs",
202203
sinon.match({
@@ -218,6 +219,57 @@ describe("apphosting", () => {
218219
);
219220
});
220221

222+
it("does not attempt to resolve RUNTIME-only secrets, but passes BUILD-available secrets", async () => {
223+
const optsWithLocalBuild = {
224+
...opts,
225+
config: new Config({
226+
apphosting: {
227+
backendId: "foo",
228+
rootDir: "/",
229+
ignore: [],
230+
localBuild: true,
231+
},
232+
}),
233+
};
234+
const context = initializeContext();
235+
236+
const yamlConfig = AppHostingYamlConfig.empty();
237+
yamlConfig.env = {
238+
BUILD_VAR: { secret: "build-secret", availability: ["BUILD"] },
239+
RUNTIME_VAR: { secret: "runtime-secret", availability: ["RUNTIME"] },
240+
SHARED_VAR: { secret: "shared-secret", availability: ["BUILD", "RUNTIME"] },
241+
};
242+
sinon.stub(apphostingConfig, "getAppHostingConfiguration").resolves(yamlConfig);
243+
244+
const localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({
245+
outputFiles: ["./next/standalone"],
246+
buildConfig: { runCommand: "npm run build", env: [] },
247+
annotations: {},
248+
});
249+
250+
listBackendsStub.onFirstCall().resolves({
251+
backends: [
252+
{
253+
name: "projects/my-project/locations/us-central1/backends/foo",
254+
},
255+
],
256+
});
257+
258+
await prepare(context, optsWithLocalBuild);
259+
260+
expect(localBuildStub).to.have.been.calledWithMatch(
261+
"my-project",
262+
sinon.match.any,
263+
"nextjs",
264+
sinon.match({
265+
BUILD_VAR: { secret: "build-secret", availability: ["BUILD"] },
266+
SHARED_VAR: { secret: "shared-secret", availability: ["BUILD", "RUNTIME"] },
267+
}),
268+
);
269+
// RUNTIME_VAR should definitely NOT be present in match
270+
expect(localBuildStub.firstCall.args[3]).to.not.have.property("RUNTIME_VAR");
271+
});
272+
221273
it("should fail if localBuild is specified but experiment is disabled", async () => {
222274
const optsWithLocalBuild = {
223275
...opts,

src/deploy/apphosting/prepare.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export default async function (context: Context, options: Options): Promise<void
189189

190190
try {
191191
const { outputFiles, annotations, buildConfig } = await localBuild(
192+
projectId,
192193
options.projectRoot || "./",
193194
"nextjs",
194195
buildEnv[cfg.backendId] || {},

src/emulator/apphosting/serve.ts

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import { isIPv4 } from "net";
7-
import * as clc from "colorette";
87
import { checkListenable } from "../portUtils";
98
import { detectPackageManager, detectPackageManagerStartCommand } from "./developmentServer";
109
import { DEFAULT_HOST, DEFAULT_PORTS } from "../constants";
@@ -16,8 +15,8 @@ import { resolveProjectPath } from "../../projectPath";
1615
import { EmulatorRegistry } from "../registry";
1716
import { setEnvVarsForEmulators } from "../env";
1817
import { FirebaseError } from "../../error";
19-
import * as secrets from "../../gcp/secretManager";
20-
import { logLabeledError, logLabeledWarning } from "../../utils";
18+
import { loadSecret } from "../../apphosting/secrets/index";
19+
import { logLabeledWarning } from "../../utils";
2120
import * as apphosting from "../../gcp/apphosting";
2221
import { Constants } from "../constants";
2322
import { constructDefaultWebSetup, WebConfig } from "../../fetchWebSetup";
@@ -34,57 +33,6 @@ interface StartOptions {
3433
rootDirectory?: string;
3534
}
3635

37-
// Matches a fully qualified secret or version name, e.g.
38-
// projects/my-project/secrets/my-secret/versions/1
39-
// projects/my-project/secrets/my-secret/versions/latest
40-
// projects/my-project/secrets/my-secret
41-
const secretResourceRegex =
42-
/^projects\/([^/]+)\/secrets\/([^/]+)(?:\/versions\/((?:latest)|\d+))?$/;
43-
44-
// Matches a shorthand for a project-relative secret, with optional version, e.g.
45-
// my-secret
46-
// my-secret@1
47-
// my-secret@latest
48-
const secretShorthandRegex = /^([^/@]+)(?:@((?:latest)|\d+))?$/;
49-
50-
async function loadSecret(project: string | undefined, name: string): Promise<string> {
51-
let projectId: string;
52-
let secretId: string;
53-
let version: string;
54-
const match = secretResourceRegex.exec(name);
55-
if (match) {
56-
projectId = match[1];
57-
secretId = match[2];
58-
version = match[3] || "latest";
59-
} else {
60-
const match = secretShorthandRegex.exec(name);
61-
if (!match) {
62-
throw new FirebaseError(`Invalid secret name: ${name}`);
63-
}
64-
if (!project) {
65-
throw new FirebaseError(
66-
`Cannot load secret ${match[1]} without a project. ` +
67-
`Please use ${clc.bold("firebase use")} or pass the --project flag.`,
68-
);
69-
}
70-
projectId = project;
71-
secretId = match[1];
72-
version = match[2] || "latest";
73-
}
74-
try {
75-
return await secrets.accessSecretVersion(projectId, secretId, version);
76-
} catch (err: any) {
77-
if (err?.original?.code === 403 || err?.original?.context?.response?.statusCode === 403) {
78-
logLabeledError(
79-
Emulators.APPHOSTING,
80-
`Permission denied to access secret ${secretId}. Use ` +
81-
`${clc.bold("firebase apphosting:secrets:grantaccess")} to get permissions.`,
82-
);
83-
}
84-
throw err;
85-
}
86-
}
87-
8836
/**
8937
* Spins up a project locally by running the project's dev command.
9038
*

0 commit comments

Comments
 (0)