diff --git a/src/apphosting/localbuilds.spec.ts b/src/apphosting/localbuilds.spec.ts index 7fff66afcde..ae0a9bd2bc0 100644 --- a/src/apphosting/localbuilds.spec.ts +++ b/src/apphosting/localbuilds.spec.ts @@ -2,6 +2,8 @@ import * as sinon from "sinon"; import { expect } from "chai"; import * as localBuildModule from "@apphosting/build"; import { localBuild } from "./localbuilds"; +import * as secrets from "./secrets"; +import { EnvMap } from "./yaml"; describe("localBuild", () => { afterEach(() => { @@ -38,10 +40,81 @@ describe("localBuild", () => { const localApphostingBuildStub: sinon.SinonStub = sinon .stub(localBuildModule, "localBuild") .resolves(bundleConfig); - const { outputFiles, annotations, buildConfig } = await localBuild("./", "nextjs"); + const { outputFiles, annotations, buildConfig } = await localBuild( + "test-project", + "./", + "nextjs", + ); expect(annotations).to.deep.equal(expectedAnnotations); expect(buildConfig).to.deep.equal(expectedBuildConfig); expect(outputFiles).to.deep.equal(expectedOutputFiles); sinon.assert.calledWith(localApphostingBuildStub, "./", "nextjs"); }); + + it("resolves BUILD-available secrets passed in the environment map and ignores RUNTIME-only ones", async () => { + const bundleConfig = { + version: "v1" as const, + runConfig: { runCommand: "npm run build:prod" }, + metadata: { + adapterPackageName: "@apphosting/angular-adapter", + adapterVersion: "14.1", + framework: "nextjs", + }, + outputFiles: { serverApp: { include: ["./next/standalone"] } }, + }; + sinon.stub(localBuildModule, "localBuild").callsFake(async () => { + expect(process.env.MY_BUILD_SECRET).to.equal("secret-value"); + expect(process.env.MY_RUNTIME_SECRET).to.be.undefined; + expect(process.env.MY_PLAIN_VAR).to.equal("plain-value"); + return bundleConfig; + }); + const loadSecretStub = sinon.stub(secrets, "loadSecret").resolves("secret-value"); + + const envMap: EnvMap = { + MY_BUILD_SECRET: { secret: "my-secret-id", availability: ["BUILD"] }, + MY_RUNTIME_SECRET: { secret: "runtime-only-id", availability: ["RUNTIME"] }, + MY_PLAIN_VAR: { value: "plain-value" }, + }; + + await localBuild("test-project", "./", "nextjs", envMap); + + expect(loadSecretStub).to.have.been.calledWith("test-project", "my-secret-id"); + // Confirm RUNTIME-only secret was ignored + expect(loadSecretStub).to.have.been.calledOnce; + // Confirm injected envs were cleaned up from the global scope after the build finishes + expect(process.env.MY_BUILD_SECRET).to.be.undefined; + expect(process.env.MY_RUNTIME_SECRET).to.be.undefined; + }); + + it("handles environment variables that do not contain secrets", async () => { + const bundleConfig = { + version: "v1" as const, + runConfig: { runCommand: "npm run build:prod" }, + metadata: { + adapterPackageName: "@apphosting/angular-adapter", + adapterVersion: "14.1", + framework: "nextjs", + }, + outputFiles: { serverApp: { include: ["./next/standalone"] } }, + }; + sinon.stub(localBuildModule, "localBuild").callsFake(async () => { + expect(process.env.MY_PLAIN_VAR).to.equal("plain-value"); + expect(process.env.ANOTHER_VAR).to.equal("another-value"); + return bundleConfig; + }); + const loadSecretStub = sinon.stub(secrets, "loadSecret").resolves("secret-value"); + + const envMap: EnvMap = { + MY_PLAIN_VAR: { value: "plain-value" }, + ANOTHER_VAR: { value: "another-value" }, + }; + + await localBuild("test-project", "./", "nextjs", envMap); + + expect(loadSecretStub).to.not.have.been.called; + // We expect the original process.env to not have these injected globally after run completes, + // as localBuild cleans up. + expect(process.env.MY_PLAIN_VAR).to.be.undefined; + expect(process.env.ANOTHER_VAR).to.be.undefined; + }); }); diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index 413cb3e1ef4..4c54237a814 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -1,6 +1,7 @@ import { BuildConfig, Env } from "../gcp/apphosting"; import { localBuild as localAppHostingBuild } from "@apphosting/build"; import { EnvMap } from "./yaml"; +import { loadSecret } from "./secrets"; /** * Triggers a local build of your App Hosting codebase. @@ -8,14 +9,17 @@ import { EnvMap } from "./yaml"; * This function orchestrates the build process using the App Hosting build adapter. * It detects the framework (though currently defaults/assumes 'nextjs' in some contexts), * generates the necessary build artifacts, and returns metadata about the build. + * @param projectId - The project ID to use for resolving secrets. * @param projectRoot - The root directory of the project to build. * @param framework - The framework to use for the build (e.g., 'nextjs'). + * @param env - The environment configuration map to resolve and inject into the build. * @return A promise that resolves to the build output, including: * - `outputFiles`: Paths to the generated build artifacts. * - `annotations`: Metadata annotations relating to the build. * - `buildConfig`: Configuration derived from the build process (e.g. run commands, environment variables). */ export async function localBuild( + projectId: string, projectRoot: string, framework: string, env: EnvMap = {}, @@ -29,7 +33,7 @@ export async function localBuild( // We'll restore the original process.env after the build is done. const originalEnv = { ...process.env }; - const addedEnv = toProcessEnv(env); + const addedEnv = await toProcessEnv(projectId, env); for (const [key, value] of Object.entries(addedEnv)) { process.env[key] = value; } @@ -71,8 +75,22 @@ export async function localBuild( }; } -function toProcessEnv(env: EnvMap): NodeJS.ProcessEnv { - return Object.fromEntries( - Object.entries(env).map(([key, value]) => [key, value.value || ""]), - ) as NodeJS.ProcessEnv; +async function toProcessEnv(projectId: string, env: EnvMap): Promise { + const entries = await Promise.all( + Object.entries(env).map(async ([key, value]) => { + if (value.availability && !value.availability.includes("BUILD")) { + return null; + } + + if (value.secret) { + const resolvedValue = await loadSecret(projectId, value.secret); + return [key, resolvedValue]; + } else { + return [key, value.value || ""]; + } + }), + ); + + const filteredEntries = entries.filter((entry): entry is [string, string] => entry !== null); + return Object.fromEntries(filteredEntries) as NodeJS.ProcessEnv; } diff --git a/src/apphosting/secrets/index.ts b/src/apphosting/secrets/index.ts index 4df6ac0a878..b9c8f64d8af 100644 --- a/src/apphosting/secrets/index.ts +++ b/src/apphosting/secrets/index.ts @@ -186,7 +186,7 @@ export async function grantEmailsSecretAccess( * If a secret exists, we verify the user is not trying to change the region and verifies a secret * is not being used for both functions and app hosting as their garbage collection is incompatible * (client vs server-side). - * @returns true if a secret was created, false if a secret already existed, and null if a user aborts. + * @return true if a secret was created, false if a secret already existed, and null if a user aborts. */ export async function upsertSecret( project: string, @@ -235,6 +235,65 @@ export async function upsertSecret( return false; } +/** + * Matches a fully qualified secret or version name, e.g. + * projects/my-project/secrets/my-secret/versions/1 + * projects/my-project/secrets/my-secret/versions/latest + * projects/my-project/secrets/my-secret + */ +const secretResourceRegex = + /^projects\/([^/]+)\/secrets\/([^/]+)(?:\/versions\/((?:latest)|\d+))?$/; + +/** + * Matches a shorthand for a project-relative secret, with optional version, e.g. + * my-secret + * my-secret@1 + * my-secret@latest + */ +const secretShorthandRegex = /^([^/@]+)(?:@((?:latest)|\d+))?$/; + +/** + * Resolves a secret name into its plaintext value using the Secret Manager access API. + * Supports both fully qualified resource names and shorthand strings (e.g. `secret@version`). + */ +export async function loadSecret(project: string | undefined, name: string): Promise { + let projectId: string; + let secretId: string; + let version: string; + const match = secretResourceRegex.exec(name); + if (match) { + projectId = match[1]; + secretId = match[2]; + version = match[3] || "latest"; + } else { + const match = secretShorthandRegex.exec(name); + if (!match) { + throw new FirebaseError(`Invalid secret name: ${name}`); + } + if (!project) { + throw new FirebaseError( + `Cannot load secret ${match[1]} without a project. ` + + `Please use ${clc.bold("firebase use")} or pass the --project flag.`, + ); + } + projectId = project; + secretId = match[1]; + version = match[2] || "latest"; + } + try { + return await gcsm.accessSecretVersion(projectId, secretId, version); + } catch (err: any) { + if (err?.original?.code === 403 || err?.original?.context?.response?.statusCode === 403) { + utils.logLabeledError( + "apphosting", + `Permission denied to access secret ${secretId}. Use ` + + `${clc.bold("firebase apphosting:secrets:grantaccess")} to get permissions.`, + ); + } + throw err; + } +} + /** * Fetches secrets from Google Secret Manager and returns their values in plain text. */ diff --git a/src/deploy/apphosting/prepare.spec.ts b/src/deploy/apphosting/prepare.spec.ts index 33b606e3d87..f4614408b22 100644 --- a/src/deploy/apphosting/prepare.spec.ts +++ b/src/deploy/apphosting/prepare.spec.ts @@ -197,6 +197,7 @@ describe("apphosting", () => { await prepare(context, optsWithLocalBuild); expect(localBuildStub).to.be.calledWithMatch( + "my-project", sinon.match.any, "nextjs", sinon.match({ @@ -218,6 +219,57 @@ describe("apphosting", () => { ); }); + it("does not attempt to resolve RUNTIME-only secrets, but passes BUILD-available secrets", async () => { + const optsWithLocalBuild = { + ...opts, + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + localBuild: true, + }, + }), + }; + const context = initializeContext(); + + const yamlConfig = AppHostingYamlConfig.empty(); + yamlConfig.env = { + BUILD_VAR: { secret: "build-secret", availability: ["BUILD"] }, + RUNTIME_VAR: { secret: "runtime-secret", availability: ["RUNTIME"] }, + SHARED_VAR: { secret: "shared-secret", availability: ["BUILD", "RUNTIME"] }, + }; + sinon.stub(apphostingConfig, "getAppHostingConfiguration").resolves(yamlConfig); + + const localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ + outputFiles: ["./next/standalone"], + buildConfig: { runCommand: "npm run build", env: [] }, + annotations: {}, + }); + + listBackendsStub.onFirstCall().resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + }, + ], + }); + + await prepare(context, optsWithLocalBuild); + + expect(localBuildStub).to.have.been.calledWithMatch( + "my-project", + sinon.match.any, + "nextjs", + sinon.match({ + BUILD_VAR: { secret: "build-secret", availability: ["BUILD"] }, + SHARED_VAR: { secret: "shared-secret", availability: ["BUILD", "RUNTIME"] }, + }), + ); + // RUNTIME_VAR should definitely NOT be present in match + expect(localBuildStub.firstCall.args[3]).to.not.have.property("RUNTIME_VAR"); + }); + it("should fail if localBuild is specified but experiment is disabled", async () => { const optsWithLocalBuild = { ...opts, diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index bf3bd23aea3..6b18779b4ff 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -189,6 +189,7 @@ export default async function (context: Context, options: Options): Promise { - let projectId: string; - let secretId: string; - let version: string; - const match = secretResourceRegex.exec(name); - if (match) { - projectId = match[1]; - secretId = match[2]; - version = match[3] || "latest"; - } else { - const match = secretShorthandRegex.exec(name); - if (!match) { - throw new FirebaseError(`Invalid secret name: ${name}`); - } - if (!project) { - throw new FirebaseError( - `Cannot load secret ${match[1]} without a project. ` + - `Please use ${clc.bold("firebase use")} or pass the --project flag.`, - ); - } - projectId = project; - secretId = match[1]; - version = match[2] || "latest"; - } - try { - return await secrets.accessSecretVersion(projectId, secretId, version); - } catch (err: any) { - if (err?.original?.code === 403 || err?.original?.context?.response?.statusCode === 403) { - logLabeledError( - Emulators.APPHOSTING, - `Permission denied to access secret ${secretId}. Use ` + - `${clc.bold("firebase apphosting:secrets:grantaccess")} to get permissions.`, - ); - } - throw err; - } -} - /** * Spins up a project locally by running the project's dev command. *