Skip to content
75 changes: 74 additions & 1 deletion src/apphosting/localbuilds.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
});
});
28 changes: 23 additions & 5 deletions src/apphosting/localbuilds.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
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.
*
* 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 = {},
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<NodeJS.ProcessEnv> {
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;
}
61 changes: 60 additions & 1 deletion src/apphosting/secrets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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) {
Comment thread
falahat marked this conversation as resolved.
Comment thread
falahat marked this conversation as resolved.
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.
*/
Expand Down
52 changes: 52 additions & 0 deletions src/deploy/apphosting/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ describe("apphosting", () => {
await prepare(context, optsWithLocalBuild);

expect(localBuildStub).to.be.calledWithMatch(
"my-project",
sinon.match.any,
"nextjs",
sinon.match({
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/deploy/apphosting/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export default async function (context: Context, options: Options): Promise<void

try {
const { outputFiles, annotations, buildConfig } = await localBuild(
projectId,
options.projectRoot || "./",
"nextjs",
buildEnv[cfg.backendId] || {},
Expand Down
56 changes: 2 additions & 54 deletions src/emulator/apphosting/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

import { isIPv4 } from "net";
import * as clc from "colorette";
import { checkListenable } from "../portUtils";
import { detectPackageManager, detectPackageManagerStartCommand } from "./developmentServer";
import { DEFAULT_HOST, DEFAULT_PORTS } from "../constants";
Expand All @@ -16,8 +15,8 @@ import { resolveProjectPath } from "../../projectPath";
import { EmulatorRegistry } from "../registry";
import { setEnvVarsForEmulators } from "../env";
import { FirebaseError } from "../../error";
import * as secrets from "../../gcp/secretManager";
import { logLabeledError, logLabeledWarning } from "../../utils";
import { loadSecret } from "../../apphosting/secrets/index";
import { logLabeledWarning } from "../../utils";
import * as apphosting from "../../gcp/apphosting";
import { Constants } from "../constants";
import { constructDefaultWebSetup, WebConfig } from "../../fetchWebSetup";
Expand All @@ -34,57 +33,6 @@ interface StartOptions {
rootDirectory?: string;
}

// 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+))?$/;

async function loadSecret(project: string | undefined, name: string): Promise<string> {
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.
*
Expand Down
Loading