diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index ea01f146716..aff79ee8333 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -1,5 +1,6 @@ import { RunSingle } from "../../firebaseConfig"; import * as runv2 from "../../gcp/runv2"; +import { BuildEnv } from "./buildEnv"; /** A service being deployed. Each field says which step sets it: prepare or deploy. */ export interface ServiceDeploy { @@ -15,6 +16,11 @@ export interface ServiceDeploy { * this deploy sets or clears it. Undefined means none, so automatic base image updates are off. */ baseImage?: string; + /** + * Set by prepare: the build-time env vars from the service's annotation, each a plain value or + * a secret. Only local builds can use secrets: builds on Cloud Build only take plain values. + */ + buildEnv?: BuildEnv; /** Set by deploy: the service after the rollout. */ deployed?: runv2.Service; } diff --git a/src/deploy/run/buildEnv.spec.ts b/src/deploy/run/buildEnv.spec.ts new file mode 100644 index 00000000000..8a8e580bce9 --- /dev/null +++ b/src/deploy/run/buildEnv.spec.ts @@ -0,0 +1,104 @@ +import { expect } from "chai"; +import * as runv2 from "../../gcp/runv2"; +import { + BUILD_ENV_ANNOTATION, + getBuildEnv, + nonSecretValues, + secretNames, + toLocalBuildEnv, +} from "./buildEnv"; + +describe("run buildEnv", () => { + const web = { name: "projects/my-project/locations/us-central1/services/web" } as runv2.Service; + /** The web service, with this build env annotation. */ + const withAnnotation = (value: string): runv2.Service => ({ + ...web, + annotations: { [BUILD_ENV_ANNOTATION]: value }, + }); + + describe("getBuildEnv", () => { + it("returns {} without a service or annotation", () => { + expect(getBuildEnv(undefined)).to.deep.equal({}); + expect(getBuildEnv({ ...web, annotations: { team: "frontend" } })).to.deep.equal({}); + }); + + it("parses a plain map like Cloud Run's buildConfig.environmentVariables", () => { + const env = { API_URL: "https://api.example.com", BASE_PATH: "" }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); + }); + + it("parses secrets written the way apphosting.yaml writes them", () => { + const env = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token/versions/3" }, + }; + expect(getBuildEnv(withAnnotation(JSON.stringify(env)))).to.deep.equal(env); + }); + + it("rejects invalid JSON and non-objects", () => { + expect(() => getBuildEnv(withAnnotation("API_URL=https://api.example.com"))).to.throw( + `Invalid ${BUILD_ENV_ANNOTATION} annotation on projects/my-project/locations/us-central1/services/web: it isn't valid JSON.`, + ); + for (const value of ["[]", "null", '"https://api.example.com"', "1"]) { + expect(() => getBuildEnv(withAnnotation(value))).to.throw("it must be a JSON object."); + } + }); + + it("converts numbers and booleans to strings, as apphosting.yaml does", () => { + const annotation = + '{"NEXT_TELEMETRY_DISABLED": 1, "CI": true, "DEBUG": false, "RATIO": 1.50}'; + expect(getBuildEnv(withAnnotation(annotation))).to.deep.equal({ + NEXT_TELEMETRY_DISABLED: "1", + CI: "true", + DEBUG: "false", + // JSON doesn't keep how a number was written. + RATIO: "1.5", + }); + }); + + it("rejects invalid values", () => { + const invalid = [ + null, + ["npm-token"], + {}, + { secret: 1 }, + // The version goes in the secret string, e.g. "npm-token@3". + { secret: "npm-token", version: "3" }, + ]; + for (const value of invalid) { + expect(() => getBuildEnv(withAnnotation(JSON.stringify({ NPM_TOKEN: value })))).to.throw( + 'NPM_TOKEN must be a string, number, boolean, or {"secret": ""}.', + ); + } + }); + }); + + it("splits secrets from plain values", () => { + const env = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + SENTRY_AUTH_TOKEN: { secret: "sentry-auth-token@2" }, + }; + expect(secretNames(env)).to.deep.equal(["NPM_TOKEN", "SENTRY_AUTH_TOKEN"]); + expect(nonSecretValues(env)).to.deep.equal({ API_URL: "https://api.example.com" }); + }); + + it("converts to local build env, available only to the build, with secrets unchanged", () => { + expect( + toLocalBuildEnv({ + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token" }, + GITHUB_TOKEN: { secret: "projects/shared-secrets/secrets/github-token/versions/3" }, + }), + ).to.deep.equal({ + API_URL: { value: "https://api.example.com", availability: ["BUILD"] }, + NPM_TOKEN: { secret: "npm-token", availability: ["BUILD"] }, + GITHUB_TOKEN: { + secret: "projects/shared-secrets/secrets/github-token/versions/3", + availability: ["BUILD"], + }, + }); + }); +}); diff --git a/src/deploy/run/buildEnv.ts b/src/deploy/run/buildEnv.ts new file mode 100644 index 00000000000..6c1eab6a979 --- /dev/null +++ b/src/deploy/run/buildEnv.ts @@ -0,0 +1,114 @@ +import { FirebaseError } from "../../error"; +import * as runv2 from "../../gcp/runv2"; +import { EnvMap } from "../../apphosting/yaml"; + +/** + * Prototype: build-time env vars and secrets, stored on the Cloud Run service as a JSON object. + * Each value is a string, a number or boolean (which becomes a string, as in apphosting.yaml), or a + * Secret Manager secret written the way apphosting.yaml writes one: {"secret": ""}, + * {"secret": "@"}, or {"secret": ""}. + * For example: + * { + * "API_URL": "https://api.example.com", + * "NEXT_TELEMETRY_DISABLED": 1, + * "NPM_TOKEN": {"secret": "npm-token"}, + * "SENTRY_AUTH_TOKEN": {"secret": "sentry-auth-token@2"}, + * "GITHUB_TOKEN": {"secret": "projects/shared-secrets/secrets/github-token/versions/3"} + * } + * The annotation only names secrets. Their values stay in Secret Manager. + */ +export const BUILD_ENV_ANNOTATION = + "firebase.google.com/buildConfig.environmentVariablesAndSecrets"; + +/** + * A Secret Manager secret, in App Hosting's format. A version is optional and defaults to the + * latest. Local builds read the secret with App Hosting's loadSecret, which checks the format. + */ +export interface SecretRef { + secret: string; +} + +export type BuildEnv = Record; + +/** + * Reads and validates the service's build environment. Returns {} if it has none. Numbers and + * booleans come back as strings, since env vars are strings. + */ +export function getBuildEnv(service: runv2.Service | undefined): BuildEnv { + const raw = service?.annotations?.[BUILD_ENV_ANNOTATION]; + if (!service || !raw) { + return {}; + } + const invalid = (reason: string): FirebaseError => + new FirebaseError(`Invalid ${BUILD_ENV_ANNOTATION} annotation on ${service.name}: ${reason}`); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw invalid("it isn't valid JSON."); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw invalid("it must be a JSON object."); + } + const env: BuildEnv = {}; + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string" || isSecretRef(value)) { + env[name] = value; + } else if (typeof value === "number" || typeof value === "boolean") { + // apphosting.yaml takes unquoted numbers and booleans too. JSON doesn't keep how a number was + // written, so 1.50 becomes "1.5". Quote a value to keep it exactly as written. + env[name] = String(value); + } else { + throw invalid(`${name} must be a string, number, boolean, or {"secret": ""}.`); + } + } + return env; +} + +/** Whether a value is {"secret": ""}, with no other fields. */ +function isSecretRef(value: unknown): value is SecretRef { + const ref = value as Partial | null; + return ( + typeof ref === "object" && + ref !== null && + Object.keys(ref).length === 1 && + typeof ref.secret === "string" + ); +} + +/** Names of the variables that are secrets. */ +export function secretNames(env: BuildEnv): string[] { + return Object.entries(env) + .filter(([, value]) => isSecretRef(value)) + .map(([name]) => name); +} + +/** + * The env vars that aren't secrets, with their values. Secrets are left out entirely, so the result + * never names a secret or holds a secret's value. + */ +export function nonSecretValues(env: BuildEnv): Record { + const values: Record = {}; + for (const [name, value] of Object.entries(env)) { + if (!isSecretRef(value)) { + values[name] = value; + } + } + return values; +} + +/** + * Converts the build environment to the form local builds take. Secrets pass through unchanged: + * the local build reads them with your credentials. + */ +export function toLocalBuildEnv(env: BuildEnv): EnvMap { + const result: EnvMap = {}; + for (const [name, value] of Object.entries(env)) { + if (isSecretRef(value)) { + result[name] = { secret: value.secret, availability: ["BUILD"] }; + } else { + result[name] = { value, availability: ["BUILD"] }; + } + } + return result; +} diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index a56aa7fda5f..fc57a27898b 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -81,6 +81,16 @@ describe("run deploy", () => { expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates"); }); + it("passes the service's plain build env vars to Cloud Build, but never secrets", async () => { + const buildEnv = { API_URL: "https://api.example.com", NPM_TOKEN: { secret: "npm-token" } }; + + await deployOne({ config: web, buildEnv }); + + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ API_URL: "https://api.example.com" }); + expect(environmentVariables).not.to.have.property("NPM_TOKEN"); + }); + it("rolls out a new revision of an existing service with all traffic, keeping its tags", async () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", @@ -186,6 +196,22 @@ describe("run deploy", () => { expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); }); + it("passes the build env, secrets included, to the local build", async () => { + const buildEnv = { + API_URL: "https://api.example.com", + NPM_TOKEN: { secret: "npm-token@2" }, + }; + + await deployOne({ config: localWeb, baseImage: NODEJS22, buildEnv }); + + expect(localBuildStub).to.have.been.calledWithMatch( + "my-project", + scratchDir, + { API_URL: { value: "https://api.example.com" }, NPM_TOKEN: { secret: "npm-token@2" } }, + { allowLocalBuildSecrets: true }, + ); + }); + it("cleans up and deploys nothing if the local build fails", async () => { localBuildStub.rejects(new Error("next build failed")); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 812c7cceb80..874cd63bed3 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -16,6 +16,7 @@ import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; +import { nonSecretValues, secretNames, toLocalBuildEnv } from "./buildEnv"; import { mainContainer } from "./util"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; @@ -103,12 +104,21 @@ async function buildLocally( path.join(options.config.projectDir, cfg.rootDir), ); logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); + const secrets = secretNames(svc.buildEnv || {}); + if (secrets.length) { + logLabeledBullet( + "run", + `Reading build secrets ${secrets.join(", ")} with your credentials. Their values may be ` + + `included in the build output.`, + ); + } await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); const { outputFiles, buildConfig } = await localBuild( context.projectId, scratchDir, - {}, - { rootDir: config.rootDir }, + toLocalBuildEnv(svc.buildEnv || {}), + // Unlike App Hosting, Cloud Run doesn't ask users to confirm build secrets in local builds. + { rootDir: config.rootDir, allowLocalBuildSecrets: true }, ); // Keep the env vars that the app needs at runtime, not just during the build. const env = (buildConfig.env || []) @@ -203,6 +213,9 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { + // Cloud Build only takes plain values, so pass only the non-secret env vars. Prepare already + // rejects secrets for builds on Cloud Build. + ...nonSecretValues(svc.buildEnv || {}), // Use App Hosting's buildpacks, which need to know where to write the output bundle. X_GOOGLE_TARGET_PLATFORM: "fah", FIREBASE_OUTPUT_BUNDLE_DIR: "/workspace/.apphosting", diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index 48f597c28ce..da59c58fd38 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION, BuildEnv } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; @@ -14,6 +15,11 @@ describe("run prepare", () => { name: "projects/my-project/locations/us-central1/services/web", template: { containers: [{ name: "web", image: "old-image", baseImageUri: nodejs22 }] }, } as unknown as runv2.Service; + /** The live web service, with this build env annotation. */ + const withBuildEnv = (env: BuildEnv): runv2.Service => ({ + ...liveWeb, + annotations: { [BUILD_ENV_ANNOTATION]: JSON.stringify(env) }, + }); let prereqsStub: sinon.SinonStub; let getServiceStub: sinon.SinonStub; @@ -86,6 +92,32 @@ describe("run prepare", () => { }); }); + describe("build env", () => { + it("reads the build env from the service's annotation", async () => { + getServiceStub.resolves(withBuildEnv({ API_URL: "https://api.example.com" })); + + const [svc] = await prepareRun(web); + + expect(svc.buildEnv).to.deep.equal({ API_URL: "https://api.example.com" }); + }); + + it("rejects build secrets on Cloud Build", async () => { + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + + await expect(prepareRun(web)).to.be.rejectedWith( + /Service web in us-central1 has build secrets \(NPM_TOKEN\).*"localBuild": true/, + ); + }); + + it("keeps build secrets for local builds", async () => { + getServiceStub.resolves(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } })); + + const [svc] = await prepareRun(localWeb); + + expect(svc.buildEnv).to.deep.equal({ NPM_TOKEN: { secret: "npm-token" } }); + }); + }); + it("only prepares the services that --only selects", async () => { const webEurope = { serviceId: "web", region: "europe-west1" }; const services = await prepareRun([web, webEurope], { only: "run:web:europe-west1" }); diff --git a/src/deploy/run/prepare.ts b/src/deploy/run/prepare.ts index a713f5ca2d4..f5ed11d8a10 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,12 +1,14 @@ import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; +import { logLabeledBullet } from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; +import { BUILD_ENV_ANNOTATION, getBuildEnv, secretNames } from "./buildEnv"; import { prereqs } from "./prereqs"; import { getExistingService, getServiceConfigs, mainContainer } from "./util"; /** - * Reads each service's current state from Cloud Run and resolves its base image. + * Reads each service's current state from Cloud Run and resolves its base image and build env. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -38,5 +40,28 @@ async function prepareService(context: Context, config: RunSingle): Promise