diff --git a/src/commands/run-services-update.ts b/src/commands/run-services-update.ts index d32021e47f2..d741b9f5ef2 100644 --- a/src/commands/run-services-update.ts +++ b/src/commands/run-services-update.ts @@ -16,6 +16,8 @@ export const command = new Command("run:services:update ") "set the base image (e.g. nodejs22) and enable automatic base image updates", ) .option("--clear-base-image", "clear the base image and disable automatic base image updates") + .option("--app ", "associate a Firebase Web App ID for SDK auto-initialization") + .option("--clear-app", "disassociate the Firebase Web App and disable SDK auto-initialization") .before(requireConfig) .before(requirePermissions, TARGET_PERMISSIONS.run) .action((serviceId: string, options: Options) => diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index b0f76ebb6a2..4bc9abb8b9b 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -318,6 +318,8 @@ export async function injectAutoInitEnvVars( )) as WebConfig; // We inject autoinit env vars into the build and runtime env vars. + // TODO: Match FAH remote builds by only injecting FIREBASE_CONFIG (and not + // FIREBASE_WEBAPP_CONFIG) into runtimeEnv. const autoinitVars = getAutoinitEnvVars(webappConfig); for (const [envVarName, envVarValue] of Object.entries(autoinitVars)) { buildEnv[cfg.backendId][envVarName] ??= { value: envVarValue }; diff --git a/src/deploy/run/args.ts b/src/deploy/run/args.ts index aff79ee8333..1fea2a2b376 100644 --- a/src/deploy/run/args.ts +++ b/src/deploy/run/args.ts @@ -16,6 +16,12 @@ 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 linked Firebase Web App, if any. Deploy saves `appId` on the service so + * later deploys reuse it, and sets `firebaseConfig` as the FIREBASE_CONFIG env var, which the + * Admin SDK reads at runtime. + */ + autoInit?: { appId: string; firebaseConfig: 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. @@ -33,4 +39,6 @@ export interface Context { projectId: string; /** Overrides the service's base image. null clears it; undefined keeps the current one. */ baseImage?: string | null; + /** Overrides the service's Firebase Web App ID. null clears it; undefined keeps the current one. */ + appId?: string | null; } diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index fc57a27898b..db259f7a424 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -14,6 +14,7 @@ import { deploy, localBuildTemplate, revisionTemplate } from "./deploy"; const NODEJS22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const DEPLOY_MESSAGE = "firebase.google.com/deploy-message"; +const APP_ID = "firebase.google.com/app-id"; describe("run deploy", () => { const options = { config: { projectDir: "/project" } } as unknown as Options; @@ -112,16 +113,65 @@ describe("run deploy", () => { expect(updateServiceStub).to.have.been.calledOnceWithExactly( { name: "projects/my-project/locations/us-central1/services/web", + annotations: {}, template: { containers: [{ name: "web", image }] }, traffic: [ { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, { type: "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", revision: "web-000", tag: "preview" }, ], }, - { updateMask: ["template", "traffic"], pollTimeoutMs: 10 * 60 * 1000 }, + { updateMask: ["template", "traffic", "annotations"], pollTimeoutMs: 10 * 60 * 1000 }, ); }); + describe("linked Firebase Web App", () => { + const linkedWeb = { + name: "projects/my-project/locations/us-central1/services/web", + annotations: { team: "frontend", [APP_ID]: "1:123:web:abc" }, + template: { containers: [{ name: "web", image: "old-image" }] }, + } as unknown as runv2.Service; + + it("is saved on a new service", async () => { + await deployOne({ config: web, autoInit: { appId: "1:123:web:abc", firebaseConfig: "{}" } }); + + expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { + annotations: { [APP_ID]: "1:123:web:abc" }, + }); + }); + + it("replaces the app saved on an existing service", async () => { + await deployOne({ + config: web, + existing: linkedWeb, + autoInit: { appId: "1:123:web:def", firebaseConfig: "{}" }, + }); + + expect(updateServiceStub).to.have.been.calledWithMatch( + { annotations: { team: "frontend", [APP_ID]: "1:123:web:def" } }, + { updateMask: ["template", "traffic", "annotations"] }, + ); + }); + + it("is removed when the service is unlinked", async () => { + await deployOne({ config: web, existing: linkedWeb }); + + expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ team: "frontend" }); + }); + + it("keeps the service's annotations as they are if the app didn't change", async () => { + await deployOne({ + config: web, + existing: linkedWeb, + autoInit: { appId: "1:123:web:abc", firebaseConfig: "{}" }, + }); + + expect(updateServiceStub.firstCall.args[0].annotations).to.deep.equal({ + team: "frontend", + [APP_ID]: "1:123:web:abc", + }); + }); + }); + describe("local builds", () => { const localWeb = { ...web, localBuild: true }; let rmSyncStub: sinon.SinonStub; @@ -308,6 +358,54 @@ describe("run deploy", () => { expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); }); + describe("FIREBASE_CONFIG", () => { + /** The live web service, with FIREBASE_CONFIG set to `value`. */ + function withConfig(value: string): runv2.Service { + return webRunning({ env: [{ name: "FIREBASE_CONFIG", value }] }); + } + const linked = { ...withConfig("old"), annotations: { [APP_ID]: "1:123:web:abc" } }; + + it("is set from the linked app's current config", () => { + const svc = { + config: web, + existing: linked, + autoInit: { appId: "1:123:web:abc", firebaseConfig: "current" }, + }; + + const template = revisionTemplate(svc, image); + + expect(template.containers?.[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: "current" }, + ]); + }); + + it("is removed when the app is unlinked, and the other env vars stay", () => { + const existing = { + ...webRunning({ + env: [ + { name: "MODE", value: "prod" }, + { name: "FIREBASE_CONFIG", value: "old" }, + ], + }), + annotations: { [APP_ID]: "1:123:web:abc" }, + }; + + const template = revisionTemplate({ config: web, existing }, image); + + expect(template.containers?.[0].env).to.deep.equal([{ name: "MODE", value: "prod" }]); + }); + + it("is left alone if the user set it on a service that was never linked", () => { + const svc = { config: web, existing: withConfig("mine") }; + + const template = revisionTemplate(svc, image); + + expect(template.containers?.[0].env).to.deep.equal([ + { name: "FIREBASE_CONFIG", value: "mine" }, + ]); + }); + }); + describe("localBuildTemplate", () => { it("runs a local build's output with the build's start command", () => { // The command is split on whitespace, so stray spaces don't become empty arguments. diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 874cd63bed3..33706c828e0 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -17,7 +17,7 @@ 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"; +import { FIREBASE_APP_ANNOTATION, mainContainer } from "./util"; const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ @@ -265,6 +265,21 @@ export function revisionTemplate( } else { delete container.baseImageUri; } + // FIREBASE_CONFIG is how the Admin SDK auto-initializes at runtime. The client SDK's + // FIREBASE_WEBAPP_CONFIG is only needed at build time, so it isn't set on the container. + // TODO: Stop setting FIREBASE_CONFIG once Cloud Run injects it from the app-id annotation. + const wasLinked = svc.existing?.annotations?.[FIREBASE_APP_ANNOTATION] !== undefined; + const otherEnv = (container.env || []).filter((e) => e.name !== "FIREBASE_CONFIG"); + if (svc.autoInit) { + // An app is linked. Always rewrite FIREBASE_CONFIG from the app's current config, so that + // changes like enabling Storage reach the service. + container.env = [...otherEnv, { name: "FIREBASE_CONFIG", value: svc.autoInit.firebaseConfig }]; + } else if (wasLinked) { + // This deploy unlinks the app (--clear-app): the service had an app, and the deploy has none. + // Remove the FIREBASE_CONFIG that was set for the app. + container.env = otherEnv; + } + // Otherwise the service was never linked, so a FIREBASE_CONFIG on it is the user's. It stays. // The message describes this deploy only, so don't carry over the last one. if (message) { template.annotations = { ...template.annotations, [DEPLOY_MESSAGE_ANNOTATION]: message }; @@ -309,16 +324,31 @@ export function localBuildTemplate( return template; } +/** + * Returns the service's annotations after this deploy: its current ones, with the linked Firebase + * Web App's ID set, or removed if no app is linked. Later deploys read the ID back to reuse the app. + */ +function serviceAnnotationsToDeploy(svc: ServiceDeploy): Record { + const annotations = { ...svc.existing?.annotations }; + if (svc.autoInit) { + annotations[FIREBASE_APP_ANNOTATION] = svc.autoInit.appId; + } else { + delete annotations[FIREBASE_APP_ANNOTATION]; + } + return annotations; +} + /** Rolls out the new revision: creates the service if it's new, or updates it. */ function rollOut( projectId: string, svc: ServiceDeploy, template: runv2.RevisionTemplate, ): Promise { + const annotations = serviceAnnotationsToDeploy(svc); if (svc.existing) { - return deployRevision(svc.existing, template); + return deployRevision(svc.existing, template, annotations); } - return createNewService(projectId, svc.config, template); + return createNewService(projectId, svc.config, template, annotations); } /** Creates a new service with its first revision. */ @@ -326,41 +356,43 @@ function createNewService( projectId: string, config: RunSingle, template: runv2.RevisionTemplate, + annotations: Record, ): Promise { const { serviceId, region } = config; - return runv2.createService( - projectId, - region, - serviceId, - { - name: `projects/${projectId}/locations/${region}/services/${serviceId}`, - template, - client: "cli-firebase", - // Like an App Hosting backend, the service is public. - invokerIamDisabled: true, - ingress: "INGRESS_TRAFFIC_ALL", - }, - { pollTimeoutMs: ROLLOUT_TIMEOUT_MS }, - ); + const service: Omit = { + name: `projects/${projectId}/locations/${region}/services/${serviceId}`, + annotations, + template, + client: "cli-firebase", + // Like an App Hosting backend, the service is public. + invokerIamDisabled: true, + ingress: "INGRESS_TRAFFIC_ALL", + }; + return runv2.createService(projectId, region, serviceId, service, { + pollTimeoutMs: ROLLOUT_TIMEOUT_MS, + }); } /** Rolls out a new revision of an existing service and sends it all traffic. */ function deployRevision( service: runv2.Service, template: runv2.RevisionTemplate, + annotations: Record, ): Promise { // Tagged revisions keep their tags (Hosting's pinned rewrites use them), but no traffic. const tags = (service.traffic || []) .filter((t) => t.tag) .map((t) => ({ type: t.type, revision: t.revision, tag: t.tag })); - return runv2.updateService( - { - name: service.name, - template, - traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], - }, - { updateMask: ["template", "traffic"], pollTimeoutMs: ROLLOUT_TIMEOUT_MS }, - ); + const update: Omit = { + name: service.name, + annotations, + template, + traffic: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }, ...tags], + }; + return runv2.updateService(update, { + updateMask: ["template", "traffic", "annotations"], + pollTimeoutMs: ROLLOUT_TIMEOUT_MS, + }); } /** Adapts a Cloud Run config so that it can reuse App Hosting's local build and archive helpers. */ diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index da59c58fd38..6d2368ccd22 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -1,11 +1,19 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import { webApps } from "../../apphosting/app"; +import { FirebaseError } from "../../error"; +import * as computeEngine from "../../gcp/computeEngine"; +import * as resourceManager from "../../gcp/resourceManager"; import * as runv2 from "../../gcp/runv2"; +import * as getProjectNumber from "../../getProjectNumber"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; +import * as utils from "../../utils"; import { Context, Payload, ServiceDeploy } from "./args"; import { BUILD_ENV_ANNOTATION, BuildEnv } from "./buildEnv"; import { prepare } from "./prepare"; import * as prereqs from "./prereqs"; +import { FIREBASE_APP_ANNOTATION } from "./util"; describe("run prepare", () => { const nodejs22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; @@ -118,6 +126,165 @@ describe("run prepare", () => { }); }); + describe("sdk autoinit", () => { + const appId = "1:123:web:abc"; + const webConfig = { + projectId: "my-project", + appId, + apiKey: "api-key", + storageBucket: "my-project.appspot.com", + }; + const firebaseConfig = '{"storageBucket":"my-project.appspot.com","projectId":"my-project"}'; + const defaultSa = "123-compute@developer.gserviceaccount.com"; + const adminSdkRole = "roles/firebase.sdkAdminServiceAgent"; + /** The service, linked to the app. */ + const linked = (service: runv2.Service): runv2.Service => ({ + ...service, + annotations: { ...service.annotations, [FIREBASE_APP_ANNOTATION]: appId }, + }); + let getAppConfigStub: sinon.SinonStub; + let getWebAppStub: sinon.SinonStub; + let hasRolesStub: sinon.SinonStub; + let addRolesStub: sinon.SinonStub; + + beforeEach(() => { + getAppConfigStub = sinon.stub(managementApps, "getAppConfig").resolves(webConfig); + getWebAppStub = sinon.stub(webApps, "getOrCreateWebApp").resolves({ name: "web", id: appId }); + sinon.stub(getProjectNumber, "getProjectNumber").resolves("123"); + sinon.stub(computeEngine, "getDefaultServiceAccount").resolves(defaultSa); + hasRolesStub = sinon.stub(resourceManager, "serviceAccountHasRoles").resolves(true); + addRolesStub = sinon.stub(resourceManager, "addServiceAccountToRoles").resolves(); + }); + + it("reuses the service's linked app and resolves its build and runtime config", async () => { + getServiceStub.resolves(linked(liveWeb)); + + const [svc] = await prepareRun(web); + + expect(getWebAppStub).to.have.been.calledWith("my-project", appId, "web"); + expect(getAppConfigStub).to.have.been.calledWith(appId, managementApps.AppPlatform.WEB); + expect(svc.autoInit).to.deep.equal({ appId, firebaseConfig }); + expect(svc.buildEnv).to.deep.equal({ + FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), + FIREBASE_CONFIG: firebaseConfig, + }); + // The app was already linked, so this deploy doesn't check IAM again. + expect(hasRolesStub).not.to.have.been.called; + }); + + it("says how to grant the Admin SDK role when it links an app, without granting it", async () => { + const warnStub = sinon.stub(utils, "logLabeledWarning"); + hasRolesStub.resolves(false); + + await prepareRun(web, { context: { appId } }); + + expect(hasRolesStub).to.have.been.calledWith("my-project", defaultSa, [adminSdkRole]); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `${defaultSa} doesn't have ${adminSdkRole}. Service web runs as this account`, + ); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `gcloud projects add-iam-policy-binding my-project --member=serviceAccount:${defaultSa} ` + + `--role=${adminSdkRole} --condition=None`, + ); + expect(addRolesStub).not.to.have.been.called; + }); + + it("checks the service's own service account, and never changes its roles", async () => { + const customSa = "web@my-project.iam.gserviceaccount.com"; + getServiceStub.resolves({ + ...liveWeb, + template: { ...liveWeb.template, serviceAccount: customSa }, + }); + hasRolesStub.resolves(false); + + await prepareRun(web, { context: { appId } }); + + expect(hasRolesStub).to.have.been.calledWith("my-project", customSa, [adminSdkRole]); + expect(addRolesStub).not.to.have.been.called; + }); + + it("says how to grant the role, and keeps deploying, if it can't check the role", async () => { + const warnStub = sinon.stub(utils, "logLabeledWarning"); + hasRolesStub.rejects( + new FirebaseError("The caller does not have permission", { status: 403 }), + ); + + const [svc] = await prepareRun(web, { context: { appId } }); + + expect(svc.autoInit?.appId).to.equal(appId); + expect(warnStub).to.have.been.calledWithMatch( + "run", + `Couldn't check whether ${defaultSa} has ${adminSdkRole} (The caller does not have permission)`, + ); + expect(warnStub).to.have.been.calledWithMatch( + "run", + "gcloud projects add-iam-policy-binding", + ); + }); + + it("lets the context set or clear the linked app", async () => { + getServiceStub.resolves(linked(liveWeb)); + + const [withNewApp] = await prepareRun(web, { context: { appId: "1:123:web:def" } }); + expect(withNewApp.autoInit?.appId).to.equal("1:123:web:def"); + + const [cleared] = await prepareRun(web, { context: { appId: null } }); + expect(cleared).not.to.have.any.keys("autoInit", "buildEnv"); + }); + + it("uses the app's config at build time even if the build env annotation sets it", async () => { + getServiceStub.resolves( + linked(withBuildEnv({ FIREBASE_CONFIG: "mine", FIREBASE_WEBAPP_CONFIG: "mine" })), + ); + + const [svc] = await prepareRun(web); + + expect(svc.buildEnv).to.deep.equal({ + FIREBASE_WEBAPP_CONFIG: JSON.stringify(webConfig), + FIREBASE_CONFIG: firebaseConfig, + }); + }); + + it("fails if the linked app can't be looked up", async () => { + getServiceStub.resolves(linked(liveWeb)); + getAppConfigStub.rejects(new Error("boom")); + + await expect(prepareRun(web)).to.be.rejectedWith( + "Unable to look up Firebase Web App 1:123:web:abc for service web in us-central1: boom\n" + + 'To use a different app, run "firebase run:services:update web --region us-central1 --app ". ' + + 'To deploy without Firebase SDK auto-initialization, run "firebase run:services:update web --region us-central1 --clear-app".', + ); + expect(hasRolesStub).not.to.have.been.called; + }); + + it("fails, before building or saving anything, if the project has no app with that ID", async () => { + getServiceStub.resolves(linked(liveWeb)); + getWebAppStub.rejects( + new FirebaseError("The web app '1:123:web:typo' does not exist in project my-project"), + ); + + await expect(prepareRun(web, { context: { appId: "1:123:web:typo" } })).to.be.rejectedWith( + "Unable to look up Firebase Web App 1:123:web:typo for service web in us-central1: " + + "The web app '1:123:web:typo' does not exist in project my-project\n" + + 'To use a different app, run "firebase run:services:update web --region us-central1 --app ". ' + + 'To deploy without Firebase SDK auto-initialization, run "firebase run:services:update web --region us-central1 --clear-app".', + ); + expect(getAppConfigStub).not.to.have.been.called; + expect(hasRolesStub).not.to.have.been.called; + }); + + it("rejects a service it can't build before looking up the app or touching IAM", async () => { + getServiceStub.resolves(linked(withBuildEnv({ NPM_TOKEN: { secret: "npm-token" } }))); + + await expect(prepareRun(web)).to.be.rejectedWith("has build secrets (NPM_TOKEN)"); + expect(getAppConfigStub).not.to.have.been.called; + expect(hasRolesStub).not.to.have.been.called; + expect(addRolesStub).not.to.have.been.called; + }); + }); + 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 f5ed11d8a10..f2900413c2f 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,14 +1,30 @@ -import { FirebaseError } from "../../error"; +import { webApps } from "../../apphosting/app"; +import { getAutoinitEnvVars } from "../../apphosting/utils"; +import { FirebaseError, getErrMsg, getError } from "../../error"; +import { WebConfig } from "../../fetchWebSetup"; import { RunSingle } from "../../firebaseConfig"; +import { getDefaultServiceAccount } from "../../gcp/computeEngine"; +import * as resourceManager from "../../gcp/resourceManager"; +import * as runv2 from "../../gcp/runv2"; +import { getProjectNumber } from "../../getProjectNumber"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; -import { logLabeledBullet } from "../../utils"; +import { logLabeledBullet, logLabeledWarning } 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"; +import { + FIREBASE_APP_ANNOTATION, + getExistingService, + getServiceConfigs, + mainContainer, +} from "./util"; + +const ADMIN_SDK_ROLE = "roles/firebase.sdkAdminServiceAgent"; /** - * Reads each service's current state from Cloud Run and resolves its base image and build env. + * Reads each service's current state from Cloud Run and resolves its base image, build env, and + * linked Firebase Web App. */ export async function prepare(context: Context, options: Options, payload: Payload): Promise { const configs = getServiceConfigs(options); @@ -31,6 +47,11 @@ async function prepareService(context: Context, config: RunSingle): Promise { + try { + // Check that the project has this app, the same way App Hosting's --app does (given an app ID, + // getOrCreateWebApp only looks it up). The config lookup below doesn't catch a bad ID: for an + // ID with no app, it returns the project's config, without an appId. + await webApps.getOrCreateWebApp(context.projectId, appId, config.serviceId); + return (await managementApps.getAppConfig(appId, managementApps.AppPlatform.WEB)) as WebConfig; + } catch (err: unknown) { + const update = `firebase run:services:update ${config.serviceId} --region ${config.region}`; + throw new FirebaseError( + `Unable to look up Firebase Web App ${appId} for service ${config.serviceId} in ` + + `${config.region}: ${getErrMsg(err)}\n` + + `To use a different app, run "${update} --app ". ` + + `To deploy without Firebase SDK auto-initialization, run "${update} --clear-app".`, + { original: getError(err) }, + ); + } +} + +/** + * Warns, with the command to fix it, if the service's runtime service account might not be allowed + * to make Firebase Admin SDK calls. It only reads IAM: the role is project-wide, and the account + * (by default, the Compute Engine default service account) may be shared by other workloads, so + * granting it is left to the user. + */ +async function checkAdminSdkRole( + context: Context, + config: RunSingle, + existing: runv2.Service | undefined, +): Promise { + const serviceAccount = + existing?.template?.serviceAccount || + (await getDefaultServiceAccount(await getProjectNumber(context))); + let problem = ""; + try { + const hasRole = await resourceManager.serviceAccountHasRoles( + context.projectId, + serviceAccount, + [ADMIN_SDK_ROLE], + /* skipAccountLookup= */ true, + ); + if (!hasRole) { + problem = `${serviceAccount} doesn't have ${ADMIN_SDK_ROLE}`; + } + } catch (err: unknown) { + problem = `Couldn't check whether ${serviceAccount} has ${ADMIN_SDK_ROLE} (${getErrMsg(err)})`; + } + if (problem) { + logLabeledWarning( + "run", + `${problem}. Service ${config.serviceId} runs as this account, so if its Firebase Admin SDK ` + + `calls fail with permission errors, grant the role with:\n` + + ` gcloud projects add-iam-policy-binding ${context.projectId} ` + + `--member=serviceAccount:${serviceAccount} --role=${ADMIN_SDK_ROLE} --condition=None`, + ); + } +} diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 3227d51da72..c8a61d95db0 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -43,13 +43,16 @@ describe("updateService", () => { ); } - it("requires --base-image or --clear-base-image, but not both", async () => { + it("requires a setting, but not both -- and --clear-", async () => { await expect(update("web", {})).to.be.rejectedWith( - "--base-image or --clear-base-image", + "--base-image , --clear-base-image, --app , or --clear-app", ); await expect(update("web", { baseImage: "nodejs22", clearBaseImage: true })).to.be.rejectedWith( "Use either --base-image or --clear-base-image, not both.", ); + await expect(update("web", { app: "1:123:web:abc", clearApp: true })).to.be.rejectedWith( + "Use either --app or --clear-app, not both.", + ); }); it("requires the service to be in firebase.json", async () => { @@ -82,6 +85,24 @@ describe("updateService", () => { expectDeployed("run:web:us-central1", { baseImage: null }); }); + it("links a Firebase Web App", async () => { + await update("web", { app: "1:123:web:abc" }); + + expectDeployed("run:web:us-central1", { appId: "1:123:web:abc", baseImage: undefined }); + }); + + it("unlinks the Firebase Web App", async () => { + await update("web", { clearApp: true }); + + expectDeployed("run:web:us-central1", { appId: null, baseImage: undefined }); + }); + + it("updates the base image and the app together", async () => { + await update("web", { baseImage: "nodejs22", clearApp: true }); + + expectDeployed("run:web:us-central1", { baseImage: "nodejs22", appId: null }); + }); + describe("for a locally built service", () => { const localWeb = { ...web, localBuild: true }; diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index b3559e03c3d..c57aafe610f 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -21,10 +21,11 @@ export async function updateService( // the service's current value. const settings = { baseImage: readFlagPair(options.baseImage, options.clearBaseImage, "base-image"), + appId: readFlagPair(options.app, options.clearApp, "app"), }; if (Object.values(settings).every((value) => value === undefined)) { throw new FirebaseError( - "Specify a setting to update: --base-image or --clear-base-image.", + "Specify a setting to update: --base-image , --clear-base-image, --app , or --clear-app.", ); } diff --git a/src/deploy/run/util.ts b/src/deploy/run/util.ts index 024ad820f0a..90ab8850c2d 100644 --- a/src/deploy/run/util.ts +++ b/src/deploy/run/util.ts @@ -4,6 +4,9 @@ import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import { logLabeledBullet } from "../../utils"; +/** Annotation on the Cloud Run Service storing the linked Firebase Web App ID (go/crff-autoinit). */ +export const FIREBASE_APP_ANNOTATION = "firebase.google.com/app-id"; + /** * Returns every Cloud Run service in firebase.json. Throws if one has no region or is listed twice. */ diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 098dc3b63a9..d9c7a00a581 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -1,11 +1,13 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { Setup } from ".."; +import { webApps } from "../../apphosting/app"; import { Config } from "../../config"; import * as deploy from "../../deploy"; import * as prereqs from "../../deploy/run/prereqs"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; +import * as managementApps from "../../management/apps"; import { Options } from "../../options"; import * as prompt from "../../prompt"; import * as requirePermissions from "../../requirePermissions"; @@ -18,6 +20,8 @@ describe("init run", () => { let config: Config; let selectStub: sinon.SinonStub; let inputStub: sinon.SinonStub; + let getOrCreateWebAppStub: sinon.SinonStub; + let listAppsStub: sinon.SinonStub; function setup(): Setup { return { @@ -34,6 +38,10 @@ describe("init run", () => { sinon.stub(prereqs, "prereqs").resolves(); sinon.stub(run, "listLocations").resolves(["us-central1", "us-east1"]); sinon.stub(runv2, "getService").rejects({ status: 404 }); + getOrCreateWebAppStub = sinon + .stub(webApps, "getOrCreateWebApp") + .resolves({ name: "my-service", id: "1:123:web:abc" }); + listAppsStub = sinon.stub(managementApps, "listFirebaseApps").resolves([]); selectStub = sinon.stub(prompt, "select"); inputStub = sinon.stub(prompt, "input"); }); @@ -66,6 +74,9 @@ describe("init run", () => { choices: ["us-central1", "us-east1"], default: "us-central1", }); + // The project has no web apps, so init doesn't ask which one to link. Actuate creates one. + expect(selectStub).to.have.been.calledThrice; + expect(getOrCreateWebAppStub).not.to.have.been.called; expect(inputStub.secondCall.args[0].default).to.be.undefined; expect(inputStub.thirdCall.args[0].default).to.equal("/"); const validateDir = inputStub.thirdCall.args[0].validate; @@ -77,6 +88,7 @@ describe("init run", () => { region: "us-east1", baseImage: "", rootDir: "/", + createWebApp: true, }); }); @@ -94,9 +106,10 @@ describe("init run", () => { ); }); - it("updates an existing service", async () => { + it("updates an existing service, keeping the Firebase Web App it's linked to", async () => { const existing = { name: "projects/p/locations/europe-west1/services/web", + annotations: { "firebase.google.com/app-id": "1:123:web:def" }, template: { containers: [{ name: "web", image: "i", baseImageUri: "nodejs20" }] }, }; const managed = { @@ -114,14 +127,49 @@ describe("init run", () => { expect(selectStub.secondCall.args[0].choices).to.deep.equal([ { name: "web (europe-west1)", value: existing }, ]); + expect(getOrCreateWebAppStub).not.to.have.been.called; + expect(listAppsStub).not.to.have.been.called; expect(s.featureInfo?.run).to.deep.equal({ serviceId: "web", region: "europe-west1", baseImage: "nodejs20", rootDir: "/", + appId: "1:123:web:def", }); }); + it("asks which web app to link, with creating a new one as the default", async () => { + listAppsStub.resolves([ + { appId: "1:123:web:abc", displayName: "web", platform: managementApps.AppPlatform.WEB }, + { appId: "1:123:web:def", platform: managementApps.AppPlatform.WEB }, + ]); + answerPrompts("create", "us-central1", "my-service", "1:123:web:def", false, "", "/"); + const s = setup(); + + await askQuestions(s, config, options); + + const appPrompt = selectStub.thirdCall.args[0]; + expect(appPrompt.default).to.equal(webApps.CREATE_NEW_FIREBASE_WEB_APP); + expect(appPrompt.choices).to.deep.equal([ + { name: "Create a new web app", value: webApps.CREATE_NEW_FIREBASE_WEB_APP }, + { name: "web (1:123:web:abc)", value: "1:123:web:abc" }, + { name: "1:123:web:def", value: "1:123:web:def" }, + ]); + expect(s.featureInfo?.run).to.include({ appId: "1:123:web:def" }); + expect(s.featureInfo?.run).not.to.have.property("createWebApp"); + }); + + it("doesn't create a web app while asking, so quitting init leaves none behind", async () => { + listAppsStub.resolves([{ appId: "1:123:web:abc", platform: managementApps.AppPlatform.WEB }]); + const createNew = webApps.CREATE_NEW_FIREBASE_WEB_APP; + answerPrompts("create", "us-central1", "my-service", createNew, false, ""); + inputStub.onThirdCall().rejects(new Error("User force closed the prompt")); + + await expect(askQuestions(setup(), config, options)).to.be.rejectedWith("force closed"); + + expect(getOrCreateWebAppStub).not.to.have.been.called; + }); + it("defaults the root directory to the one saved for the same service and region", async () => { const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); @@ -243,6 +291,35 @@ describe("init run", () => { ); }); + it("deploys with the Firebase Web App that init linked", async () => { + await actuate(webSetup({ appId: "1:123:web:abc" }), config, options); + + expect(getOrCreateWebAppStub).not.to.have.been.called; + expect(deployStub).to.have.been.calledWithMatch(["run"], sinon.match.any, { + appId: "1:123:web:abc", + }); + }); + + it("creates the new Firebase Web App, then deploys with it", async () => { + await actuate(webSetup({ createWebApp: true }), config, options); + + expect(getOrCreateWebAppStub).to.have.been.calledOnceWith("p", null, "web"); + expect(deployStub).to.have.been.calledWithMatch(["run"], sinon.match.any, { + appId: "1:123:web:abc", + }); + }); + + it("fails without deploying if it can't create the web app", async () => { + // getOrCreateWebApp returns nothing, after a warning, when the project is out of web apps. + getOrCreateWebAppStub.resolves(undefined); + + await expect(actuate(webSetup({ createWebApp: true }), config, options)).to.be.rejectedWith( + "Couldn't create a Firebase web app for service web", + ); + + expect(deployStub).to.not.have.been.called; + }); + it("doesn't write firebase.json if the deploy fails", async () => { deployStub.rejects(new Error("build failed")); diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 60861911f00..7935ee3227e 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -1,14 +1,22 @@ import * as path from "path"; import { Setup } from ".."; +import { webApps } from "../../apphosting/app"; import { Config } from "../../config"; import { deploy } from "../../deploy"; +import { Context } from "../../deploy/run/args"; import { prereqs, RUN_PERMISSIONS } from "../../deploy/run/prereqs"; -import { fullServiceName, getExistingService, mainContainer } from "../../deploy/run/util"; +import { + FIREBASE_APP_ANNOTATION, + fullServiceName, + getExistingService, + mainContainer, +} from "../../deploy/run/util"; import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { dirExistsSync } from "../../fsutils"; import * as run from "../../gcp/run"; import * as runv2 from "../../gcp/runv2"; +import { AppPlatform, listFirebaseApps } from "../../management/apps"; import { Options } from "../../options"; import { input, select } from "../../prompt"; import { requirePermissions } from "../../requirePermissions"; @@ -23,6 +31,10 @@ export interface RunInfo { baseImage: string; rootDir: string; localBuild?: boolean; + /** The Firebase Web App to link. */ + appId?: string; + /** Link a new Firebase Web App. Actuate creates it, so quitting init partway leaves none behind. */ + createWebApp?: boolean; } /** @@ -62,6 +74,10 @@ export async function askQuestions(setup: Setup, config: Config, options: Option serviceId = await promptNewServiceId(projectId, region); } + // Firebase SDKs auto-initialize from a linked Firebase Web App. Keep the service's app, or ask + // which app to link. A new app is created in actuate, so quitting init partway leaves none behind. + const appId = existing?.annotations?.[FIREBASE_APP_ANNOTATION] ?? (await promptWebApp(projectId)); + // If firebase.json already has this service, its saved settings are the defaults. const savedEntry = findRunEntry(getRunEntries(config), serviceId, region); @@ -92,9 +108,37 @@ export async function askQuestions(setup: Setup, config: Config, options: Option if (localBuild) { runInfo.localBuild = true; } + if (appId) { + runInfo.appId = appId; + } else { + runInfo.createWebApp = true; + } setup.featureInfo = { ...setup.featureInfo, run: runInfo }; } +/** + * Asks which of the project's Firebase Web Apps to link. Returns undefined to create a new app, + * which is the default, and what happens without asking if the project has no web apps. + */ +async function promptWebApp(projectId: string): Promise { + const apps = await listFirebaseApps(projectId, AppPlatform.WEB); + if (!apps.length) { + return undefined; + } + const choice = await select({ + message: "Which Firebase web app should this service use for Firebase SDK auto-initialization?", + choices: [ + { name: "Create a new web app", value: webApps.CREATE_NEW_FIREBASE_WEB_APP }, + ...apps.map((app) => ({ + name: app.displayName ? `${app.displayName} (${app.appId})` : app.appId, + value: app.appId, + })), + ], + default: webApps.CREATE_NEW_FIREBASE_WEB_APP, + }); + return choice === webApps.CREATE_NEW_FIREBASE_WEB_APP ? undefined : choice; +} + async function promptExistingService(projectId: string): Promise { const allServices = await runv2.listServices(projectId, /* functionsOnly= */ false); // Skip services that another product (e.g. Cloud Functions or App Hosting) manages. @@ -168,11 +212,29 @@ export async function actuate(setup: Setup, config: Config, options: Options): P } else { upsertRunConfig(service, config); } + // Deploy with the base image that init asked for, and the Firebase Web App to link. A new app is + // created only now, after every question has been answered. + const settings: Partial = { baseImage: info.baseImage || null }; + let appId = info.appId; + if (info.createWebApp) { + // App Hosting's helper only warns, and returns nothing, when the project is out of web apps: + // App Hosting backends work without one. Fail instead of deploying an unlinked service. + appId = (await webApps.getOrCreateWebApp(setup.projectId as string, null, info.serviceId))?.id; + if (!appId) { + throw new FirebaseError( + `Couldn't create a Firebase web app for service ${info.serviceId}. Delete an unused web ` + + `app in the Firebase console, or run "firebase init run" again and pick an existing app.`, + ); + } + } + if (appId) { + settings.appId = appId; + } // Name the region too, so that only this service deploys, not ones with the same ID elsewhere. await deploy( ["run"], { ...options, projectId: setup.projectId, config, only: `run:${fullServiceName(info)}` }, - { baseImage: info.baseImage || null }, + settings, ); }