diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index dc17783a5ad..70ad079519d 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -116,6 +116,8 @@ function parseBundleYaml( outputFiles?: { serverApp?: { include?: string[] } }; }; + // TODO: Also merge bundleData.runConfig.environmentVariables (and resource limits) from + // bundle.yaml to match the App Hosting publisher step on remote builds. const runCommand = bundleData?.runConfig?.runCommand ?? defaultRunCommand; const outputFiles = bundleData?.outputFiles?.serverApp?.include ?? []; return { runCommand, outputFiles }; diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 55650cf50e2..b0f76ebb6a2 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -432,7 +432,7 @@ async function ensureAppHostingServiceAgentRoles( * isolated scratch workspace in the `.local_build_` folder that contains exactly the same * source files that would be uploaded to Cloud Build. */ -async function prepareLocalBuildScratchDirectory( +export async function prepareLocalBuildScratchDirectory( rootDir: string, localBuildScratchDir: string, cfg: AppHostingSingle, diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index 97bdb5fa530..a56aa7fda5f 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,14 +1,16 @@ import { expect } from "chai"; import * as fs from "fs"; import * as sinon from "sinon"; +import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import * as getProjectNumber from "../../getProjectNumber"; import { Options } from "../../options"; +import * as apphostingPrepare from "../apphosting/prepare"; import * as apphostingUtil from "../apphosting/util"; import { ServiceDeploy } from "./args"; -import { deploy, revisionTemplate } from "./deploy"; +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"; @@ -18,6 +20,8 @@ describe("run deploy", () => { const web = { serviceId: "web", region: "us-central1", rootDir: "apps/web" }; // Date.now() is stubbed to return 42, which tags the image. const image = "us-central1-docker.pkg.dev/my-project/cloud-run-source-deploy/web:42"; + const scratchDir = "/tmp/run-local-build-web-abc123"; + const builtApp = { bucket: "source-bucket", object: "web.tar.gz" }; let createArchiveStub: sinon.SinonStub; let submitBuildStub: sinon.SinonStub; let createServiceStub: sinon.SinonStub; @@ -108,6 +112,97 @@ describe("run deploy", () => { ); }); + describe("local builds", () => { + const localWeb = { ...web, localBuild: true }; + let rmSyncStub: sinon.SinonStub; + let validateNodeStub: sinon.SinonStub; + let localBuildStub: sinon.SinonStub; + let tarArchiveStub: sinon.SinonStub; + + beforeEach(() => { + sinon.stub(fs, "mkdtempSync").returns(scratchDir); + rmSyncStub = sinon.stub(fs, "rmSync"); + validateNodeStub = sinon.stub(localbuilds, "validateLocalBuildNodeVersion"); + sinon.stub(apphostingPrepare, "prepareLocalBuildScratchDirectory").resolves(); + localBuildStub = sinon.stub(localbuilds, "localBuild").resolves({ + outputFiles: [".next"], + buildConfig: { runCommand: "npm run start" }, + }); + tarArchiveStub = sinon + .stub(apphostingUtil, "createLocalBuildTarArchive") + .resolves("/tmp/web.tar.gz"); + (gcs.uploadObject as sinon.SinonStub) + .withArgs(sinon.match({ file: "/tmp/web.tar.gz" })) + .resolves(builtApp); + }); + + /** Deploys web with "localBuild": true and returns the container that it rolled out. */ + async function deployLocalBuild(): Promise { + await deployOne({ config: localWeb, baseImage: NODEJS22 }); + return createServiceStub.firstCall.args[3].template.containers[0]; + } + + it("builds on this machine, in a scratch folder that it deletes afterwards", async () => { + await deployLocalBuild(); + + expect(validateNodeStub).to.have.been.calledWith( + { runtime: { value: "nodejs22" } }, + "/project/apps/web", + ); + expect(localBuildStub).to.have.been.calledWithMatch( + "my-project", + scratchDir, + {}, + { rootDir: "apps/web" }, + ); + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + }); + + it("uploads the build's output and runs it as is, without Cloud Build", async () => { + const container = await deployLocalBuild(); + + expect(tarArchiveStub).to.have.been.calledWithMatch(sinon.match.any, scratchDir, [".next"]); + expect(submitBuildStub).not.to.have.been.called; + expect(container).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); + }); + + it("keeps the env vars the app needs at runtime", async () => { + localBuildStub.resolves({ + outputFiles: [".next"], + buildConfig: { + env: [ + { variable: "NODE_ENV", value: "production", availability: ["RUNTIME"] }, + { variable: "NEXT_TELEMETRY_DISABLED", value: "1", availability: ["BUILD"] }, + ], + }, + }); + + const container = await deployLocalBuild(); + + expect(container.env).to.deep.equal([{ name: "NODE_ENV", value: "production" }]); + }); + + it("cleans up and deploys nothing if the local build fails", async () => { + localBuildStub.rejects(new Error("next build failed")); + + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); + + expect(rmSyncStub).to.have.been.calledWith(scratchDir); + expect(createServiceStub).not.to.have.been.called; + }); + + it("reports the local build error, not the cleanup error, when both fail", async () => { + localBuildStub.rejects(new Error("next build failed")); + rmSyncStub.throws(new Error("EBUSY: resource busy or locked")); + + await expect(deployLocalBuild()).to.be.rejectedWith("next build failed"); + }); + }); + describe("revisionTemplate", () => { const existing = { name: "projects/my-project/locations/us-central1/services/web", @@ -126,6 +221,13 @@ describe("run deploy", () => { }, } as unknown as runv2.Service; + /** The live web service, running a container with these fields. */ + function webRunning(container: Partial): runv2.Service { + return { + template: { containers: [{ name: "web", image: "old-image", ...container }] }, + } as runv2.Service; + } + it("starts a new service with just the image", () => { expect(revisionTemplate({ config: web }, image)).to.deep.equal({ containers: [{ name: "web", image }], @@ -167,5 +269,57 @@ describe("run deploy", () => { const withoutMessage = revisionTemplate({ config: web, existing }, image); expect(withoutMessage.annotations).to.deep.equal({ team: "frontend" }); }); + + it("switches a service from a local build back to an image", () => { + const fromLocalBuild = webRunning({ + image: "scratch", + command: ["npm", "run", "start"], + sourceCode: { cloudStorageSource: builtApp }, + }); + + const template = revisionTemplate({ config: web, existing: fromLocalBuild }, image); + + expect(template).to.deep.equal({ containers: [{ name: "web", image }] }); + }); + + 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. + const build = { outputFiles: [".next"], runCommand: " npm run start ", env: [] }; + + const template = localBuildTemplate({ config: web, existing }, build, builtApp); + + expect(template.containers?.[0]).to.deep.include({ + image: "scratch", + sourceCode: { cloudStorageSource: builtApp }, + command: ["npm", "run", "start"], + }); + }); + + it("sets the env vars the build reports, replacing the service's values for the same names", () => { + const service = webRunning({ + env: [ + { name: "MODE", value: "prod" }, + { name: "API_URL", value: "https://api.example.com" }, + ], + }); + const build = { + outputFiles: [".next"], + env: [ + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ], + }; + + const template = localBuildTemplate({ config: web, existing: service }, build, builtApp); + + // API_URL isn't from the build, so it's kept. + expect(template.containers?.[0].env).to.deep.equal([ + { name: "API_URL", value: "https://api.example.com" }, + { name: "MODE", value: "development" }, + { name: "NODE_ENV", value: "production" }, + ]); + }); + }); }); }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 02161c66ff8..812c7cceb80 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -1,14 +1,20 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; -import { FirebaseError } from "../../error"; +import { CLOUD_RUN_SIZE_LIMIT_BYTES } from "../../apphosting/constants"; +import { localBuild, validateLocalBuildNodeVersion } from "../../apphosting/localbuilds"; +import { FirebaseError, getErrMsg } from "../../error"; import { AppHostingSingle, RunSingle } from "../../firebaseConfig"; +import { Backend } from "../../gcp/apphosting"; import * as artifactregistry from "../../gcp/artifactregistry"; import * as runv2 from "../../gcp/runv2"; import * as gcs from "../../gcp/storage"; import { getProjectNumber } from "../../getProjectNumber"; +import { logger } from "../../logger"; import { Options } from "../../options"; import { cloneDeep, logLabeledBullet } from "../../utils"; -import { createSourceDeployArchive } from "../apphosting/util"; +import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; +import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; import { mainContainer } from "./util"; @@ -16,19 +22,106 @@ const DEPLOY_MESSAGE_ANNOTATION = "firebase.google.com/deploy-message"; /** Rolling out a revision can take longer than the operation poller's default timeout. */ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000; -/** Builds each service from source on Cloud Build, then rolls it out. */ +/** + * The image of a container that runs a local build's output. Cloud Run runs the output from Cloud + * Storage on the service's base image, so the container doesn't need an image of its own. + */ +const NO_IMAGE = "scratch"; + +/** What a local build produced. */ +export interface LocalBuildOutput { + /** The built files to upload, relative to the scratch directory the build ran in. */ + outputFiles: string[]; + /** The command that starts the app, e.g. "npm run start". */ + runCommand?: string; + /** The env vars the app needs at runtime. */ + env: NonNullable; +} + +/** Builds each service, locally or on Cloud Build, then rolls it out. */ export async function deploy(context: Context, options: Options, payload: Payload): Promise { - const { projectId } = context; for (const svc of payload.run?.services || []) { - const source = await uploadSource(projectId, options, svc.config); - const image = await buildImage(projectId, svc, source); - const template = revisionTemplate(svc, image, options.message as string | undefined); - svc.deployed = svc.existing - ? await deployRevision(svc.existing, template) - : await createNewService(projectId, svc.config, template); + if (svc.config.localBuild) { + await deployLocalBuild(context, options, svc); + } else { + await deployRemoteBuild(context, options, svc); + } + } +} + +/** Uploads the service's source, builds it into an image on Cloud Build, then rolls it out. */ +async function deployRemoteBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + const source = await uploadSource(projectId, options, svc.config); + const image = await buildImage(projectId, svc, source); + const template = revisionTemplate(svc, image, options.message as string | undefined); + svc.deployed = await rollOut(projectId, svc, template); +} + +/** + * Builds the service on this machine, uploads the output, then rolls it out. Cloud Run runs the + * output as is, so nothing is built on Cloud Build. + */ +async function deployLocalBuild( + context: Context, + options: Options, + svc: ServiceDeploy, +): Promise { + const { projectId } = context; + // The build runs in a scratch copy of the source, which is deleted afterwards. + const scratchDir = fs.mkdtempSync( + path.join(os.tmpdir(), `run-local-build-${svc.config.serviceId}-`), + ); + try { + const build = await buildLocally(context, options, svc, scratchDir); + const builtApp = await uploadBuiltApp(projectId, options, svc.config, scratchDir, build); + const message = options.message as string | undefined; + const template = localBuildTemplate(svc, build, builtApp, message); + svc.deployed = await rollOut(projectId, svc, template); + } finally { + removeTempPath(scratchDir); } } +/** Builds the service on this machine, in a scratch copy of its source. */ +async function buildLocally( + context: Context, + options: Options, + svc: ServiceDeploy, + scratchDir: string, +): Promise { + const { config } = svc; + const { serviceId, region } = config; + const cfg = toAppHostingConfig(config); + // App Hosting's check reads the Node.js version from a backend's runtime, e.g. "nodejs22". + validateLocalBuildNodeVersion( + { runtime: { value: svc.baseImage?.split("/").pop() } } as Backend, + path.join(options.config.projectDir, cfg.rootDir), + ); + logLabeledBullet("run", `Starting local build for service ${serviceId} in ${region}`); + await prepareLocalBuildScratchDirectory(options.config.projectDir, scratchDir, cfg); + const { outputFiles, buildConfig } = await localBuild( + context.projectId, + scratchDir, + {}, + { rootDir: config.rootDir }, + ); + // Keep the env vars that the app needs at runtime, not just during the build. + const env = (buildConfig.env || []) + .filter( + (e) => + e.variable && + e.value !== undefined && + (!e.availability || e.availability.includes("RUNTIME")), + ) + .map((e) => ({ name: e.variable, value: e.value! })); + return { outputFiles, runCommand: buildConfig.runCommand, env }; +} + /** Zips the service's source and uploads it to the region's source bucket. */ async function uploadSource( projectId: string, @@ -36,20 +129,7 @@ async function uploadSource( config: RunSingle, ): Promise { const { serviceId, region } = config; - // Like App Hosting, each region has one source bucket, which deletes uploads after 30 days. - const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; - const bucketName = await gcs.upsertBucket({ - product: "run", - createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, - projectId, - req: { - baseName, - purposeLabel: `run-source-${region.toLowerCase()}`, - location: region, - lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, - }, - }); - + const bucketName = await sourceBucket(projectId, options, region); const cfg = toAppHostingConfig(config); // The zip is a tmp file, so it's deleted when the CLI exits. const archive = await createSourceDeployArchive( @@ -65,6 +145,52 @@ async function uploadSource( return { bucket, object }; } +/** Tars a local build's output and uploads it to the region's source bucket. */ +async function uploadBuiltApp( + projectId: string, + options: Options, + config: RunSingle, + scratchDir: string, + build: LocalBuildOutput, +): Promise { + const { serviceId, region } = config; + const bucketName = await sourceBucket(projectId, options, region); + // The tarball is a tmp file, so it's deleted when the CLI exits. + const archive = await createLocalBuildTarArchive( + toAppHostingConfig(config), + scratchDir, + build.outputFiles, + ); + logLabeledBullet("run", `Uploading built app for service ${serviceId} in ${region}...`); + const { bucket, object } = await gcs.uploadObject( + { file: archive, stream: fs.createReadStream(archive) }, + bucketName, + gcs.ContentType.TAR, + // Cloud Run runs the tarball as is, so it has to fit Cloud Run's size limit. + CLOUD_RUN_SIZE_LIMIT_BYTES, + ); + return { bucket, object }; +} + +/** + * Returns the region's source bucket, creating it if needed. Like App Hosting, each region has one + * source bucket, which deletes uploads after 30 days. + */ +async function sourceBucket(projectId: string, options: Options, region: string): Promise { + const baseName = `firebase-run-src-${await getProjectNumber(options)}-${region.toLowerCase()}`; + return gcs.upsertBucket({ + product: "run", + createMessage: `Creating Cloud Storage bucket in ${region} to store Cloud Run source code uploads at ${baseName}...`, + projectId, + req: { + baseName, + purposeLabel: `run-source-${region.toLowerCase()}`, + location: region, + lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 30 } }] }, + }, + }); +} + /** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ async function buildImage( projectId: string, @@ -112,6 +238,12 @@ export function revisionTemplate( throw new FirebaseError(`Service ${serviceId} in ${region} has no containers.`); } container.image = image; + if (container.sourceCode) { + // The live revision ran a local build's output, with the build's start command (see + // localBuildTemplate). Drop both, so that the new revision runs the new image as built. + delete container.sourceCode; + delete container.command; + } // Prepare already resolved svc.baseImage: the service's current base image, unless this deploy // sets or clears it. A new service has none unless init sets one, so automatic base image // updates are off by default. @@ -129,6 +261,53 @@ export function revisionTemplate( return template; } +/** + * Returns the revision to roll out for a local build: revisionTemplate's, but running the build's + * output from Cloud Storage, on the service's base image, with the build's start command. + * Exported for testing. + */ +export function localBuildTemplate( + svc: ServiceDeploy, + build: LocalBuildOutput, + builtApp: runv2.StorageSource, + message?: string, +): runv2.RevisionTemplate { + const template = revisionTemplate(svc, NO_IMAGE, message); + // revisionTemplate has already checked that there's a container. + const container = mainContainer(template)!; + container.sourceCode = { cloudStorageSource: builtApp }; + // Split on any whitespace, so that stray spaces don't become empty arguments. + const command = build.runCommand?.trim(); + if (command) { + container.command = command.split(/\s+/); + } else { + // The build didn't name a start command, so don't keep one from an earlier revision. + delete container.command; + } + // Set the runtime env vars that the build reports. Like the start command, they describe this + // build's output, so the build wins: each one replaces any env var of the same name on the + // service, even one set in the console or with gcloud. The service's other env vars are kept. + // A var that an earlier build set stays on the service if this build stops reporting it. + if (build.env.length) { + const buildNames = new Set(build.env.map((e) => e.name)); + const otherEnv = (container.env || []).filter((e) => !buildNames.has(e.name)); + container.env = [...otherEnv, ...build.env]; + } + return template; +} + +/** 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 { + if (svc.existing) { + return deployRevision(svc.existing, template); + } + return createNewService(projectId, svc.config, template); +} + /** Creates a new service with its first revision. */ function createNewService( projectId: string, @@ -171,12 +350,25 @@ function deployRevision( ); } -/** Adapts a Cloud Run config so that it can reuse App Hosting's archive helper. */ +/** Adapts a Cloud Run config so that it can reuse App Hosting's local build and archive helpers. */ function toAppHostingConfig(config: RunSingle): AppHostingSingle { return { backendId: config.serviceId, rootDir: config.rootDir || "", // Leave this undefined (not []) so App Hosting applies its default ignore list. ignore: config.ignore as string[], + localBuild: config.localBuild, }; } + +/** + * Deletes a temporary directory. Failures are only logged: a leftover temp directory shouldn't + * fail a deploy that succeeded or hide the error from one that didn't. + */ +function removeTempPath(tempPath: string): void { + try { + fs.rmSync(tempPath, { recursive: true, force: true }); + } catch (err: unknown) { + logger.debug(`Failed to clean up ${tempPath}: ${getErrMsg(err)}`); + } +} diff --git a/src/deploy/run/index.ts b/src/deploy/run/index.ts index a2db3dbbdc5..462e7dce1e0 100644 --- a/src/deploy/run/index.ts +++ b/src/deploy/run/index.ts @@ -9,7 +9,7 @@ export const help = " --only run:serviceId (in every region firebase.json lists it in)\n" + " --only run:serviceId:region (in one region)"; export const detailedHelp = - "Deploys Cloud Run services from local source.\n\n" + + "Deploys Cloud Run services from local source, or from a local build if localBuild is set.\n\n" + "Configuration in firebase.json:\n" + "{\n" + ' "run": {\n' + diff --git a/src/deploy/run/prepare.spec.ts b/src/deploy/run/prepare.spec.ts index d974c8f6548..48f597c28ce 100644 --- a/src/deploy/run/prepare.spec.ts +++ b/src/deploy/run/prepare.spec.ts @@ -9,6 +9,7 @@ import * as prereqs from "./prereqs"; describe("run prepare", () => { const nodejs22 = "us-central1-docker.pkg.dev/serverless-runtimes/google-22/runtimes/nodejs22"; const web = { serviceId: "web", region: "us-central1" }; + const localWeb = { ...web, localBuild: true }; const liveWeb = { name: "projects/my-project/locations/us-central1/services/web", template: { containers: [{ name: "web", image: "old-image", baseImageUri: nodejs22 }] }, @@ -65,6 +66,26 @@ describe("run prepare", () => { expect(cleared.baseImage).to.be.undefined; }); + describe("local builds", () => { + it("require a base image", async () => { + const webWithoutBaseImage = { + ...liveWeb, + template: { containers: [{ name: "web", image: "old-image" }] }, + }; + getServiceStub.resolves(webWithoutBaseImage); + + await expect(prepareRun(localWeb)).to.be.rejectedWith( + 'Local builds require a base image. Set one for service web in us-central1 with "firebase run:services:update web --region us-central1 --base-image ".', + ); + }); + + it("point new services to init, which sets a base image", async () => { + await expect(prepareRun(localWeb)).to.be.rejectedWith( + 'Local builds require a base image. Set one for service web in us-central1 with "firebase init run".', + ); + }); + }); + 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 7e5e8062855..a713f5ca2d4 100644 --- a/src/deploy/run/prepare.ts +++ b/src/deploy/run/prepare.ts @@ -1,3 +1,4 @@ +import { FirebaseError } from "../../error"; import { RunSingle } from "../../firebaseConfig"; import { Options } from "../../options"; import { Context, Payload, ServiceDeploy } from "./args"; @@ -20,12 +21,22 @@ export async function prepare(context: Context, options: Options, payload: Paylo } async function prepareService(context: Context, config: RunSingle): Promise { - const existing = await getExistingService(context.projectId, config.region, config.serviceId); + const { serviceId, region } = config; + const existing = await getExistingService(context.projectId, region, serviceId); // Base images are sticky: keep the service's current one unless the context sets a new one or // clears it (null). let baseImage = mainContainer(existing?.template)?.baseImageUri; if (context.baseImage !== undefined) { baseImage = context.baseImage ?? undefined; } + // Local builds run on top of the base image, so they need one. + if (config.localBuild && !baseImage) { + const fix = existing + ? `firebase run:services:update ${serviceId} --region ${region} --base-image ` + : "firebase init run"; + throw new FirebaseError( + `Local builds require a base image. Set one for service ${serviceId} in ${region} with "${fix}".`, + ); + } return { config, existing, baseImage }; } diff --git a/src/deploy/run/update.spec.ts b/src/deploy/run/update.spec.ts index 62a3155b968..3227d51da72 100644 --- a/src/deploy/run/update.spec.ts +++ b/src/deploy/run/update.spec.ts @@ -82,6 +82,24 @@ describe("updateService", () => { expectDeployed("run:web:us-central1", { baseImage: null }); }); + describe("for a locally built service", () => { + const localWeb = { ...web, localBuild: true }; + + it("refuses to clear its base image, which local builds need", async () => { + await expect(update("web", { clearBaseImage: true }, localWeb)).to.be.rejectedWith( + "Cannot clear the base image of web: local builds need one.", + ); + }); + + it("says to create it with init, which sets a base image", async () => { + getServiceStub.rejects({ status: 404 }); + + await expect(update("web", { baseImage: "nodejs22" }, localWeb)).to.be.rejectedWith( + /doesn't exist in us-central1 yet\. Create it with \S*firebase init run\S*, then update it\./, + ); + }); + }); + describe("when firebase.json lists the service in two regions", () => { it("asks which one to update", async () => { await expect(update("web", { baseImage: "nodejs22" }, [web, webEurope])).to.be.rejectedWith( diff --git a/src/deploy/run/update.ts b/src/deploy/run/update.ts index df81e9176a5..b3559e03c3d 100644 --- a/src/deploy/run/update.ts +++ b/src/deploy/run/update.ts @@ -30,10 +30,17 @@ export async function updateService( const projectId = needProjectId(options); const config = findServiceToUpdate(serviceId, region, options); + if (settings.baseImage === null && config.localBuild) { + throw new FirebaseError( + `Cannot clear the base image of ${config.serviceId}: local builds need one.`, + ); + } if (!(await getExistingService(projectId, config.region, config.serviceId))) { - const howToCreate = - `${clc.bold("firebase init run")} or ` + - clc.bold(`firebase deploy --only run:${fullServiceName(config)}`); + // A first deploy can't create a locally built service: it needs the base image that init sets. + const howToCreate = config.localBuild + ? clc.bold("firebase init run") + : `${clc.bold("firebase init run")} or ` + + clc.bold(`firebase deploy --only run:${fullServiceName(config)}`); throw new FirebaseError( `Cloud Run service ${config.serviceId} doesn't exist in ${config.region} yet. ` + `Create it with ${howToCreate}, then update it.`, diff --git a/src/init/features/run.spec.ts b/src/init/features/run.spec.ts index 8ff53959e1c..098dc3b63a9 100644 --- a/src/init/features/run.spec.ts +++ b/src/init/features/run.spec.ts @@ -9,7 +9,7 @@ import * as runv2 from "../../gcp/runv2"; import { Options } from "../../options"; import * as prompt from "../../prompt"; import * as requirePermissions from "../../requirePermissions"; -import { actuate, askQuestions, upsertRunConfig } from "./run"; +import { actuate, askQuestions, RunInfo, upsertRunConfig } from "./run"; const DEFAULT_IGNORE = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"]; @@ -41,6 +41,13 @@ describe("init run", () => { afterEach(() => sinon.restore()); describe("askQuestions", () => { + /** Answers init's prompts, select or input, in the order it asks them. */ + function answerPrompts(...answers: unknown[]): void { + const nextAnswer = (): Promise => Promise.resolve(answers.shift()); + selectStub.callsFake(nextAnswer); + inputStub.callsFake(nextAnswer); + } + it("throws if no project is selected", async () => { await expect( askQuestions({ ...setup(), projectId: undefined }, config, options), @@ -143,11 +150,45 @@ describe("init run", () => { expect(s.featureInfo?.run?.serviceId).to.equal("my-service"); }); + + it("builds a new service remotely by default", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ false, "", "/"); + + await askQuestions(setup(), config, options); + + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.false; + }); + + it("defaults to building locally if firebase.json builds the service locally", async () => { + const existing = { name: "projects/p/locations/europe-west1/services/web", template: {} }; + sinon.stub(runv2, "listServices").resolves([existing] as unknown as runv2.Service[]); + config.set("run", { serviceId: "web", region: "europe-west1", localBuild: true }); + answerPrompts("update", existing, /* localBuild= */ true, "nodejs22", "/"); + + await askQuestions(setup(), config, options); + + const localBuildPrompt = selectStub.thirdCall.args[0]; + expect(localBuildPrompt.default).to.be.true; + }); + + it("saves the choice to build locally, which requires a base image", async () => { + answerPrompts("create", "us-central1", "web", /* localBuild= */ true, "nodejs22", "/"); + const initSetup = setup(); + + await askQuestions(initSetup, config, options); + + const baseImagePrompt = inputStub.secondCall.args[0]; + expect(baseImagePrompt.default).to.be.undefined; + expect(baseImagePrompt.validate(" ")).to.equal("Local builds require a base image."); + expect(baseImagePrompt.validate("nodejs22")).to.be.true; + expect(initSetup.featureInfo?.run).to.include({ localBuild: true }); + }); }); describe("actuate", () => { /** Init's answers for a new service. */ - const webSetup = (): Setup => ({ + const webSetup = (answers: Partial = {}): Setup => ({ ...setup(), featureInfo: { run: { @@ -155,6 +196,7 @@ describe("init run", () => { region: "us-central1", baseImage: "nodejs22", rootDir: "apps/web", + ...answers, }, }, }); @@ -185,6 +227,12 @@ describe("init run", () => { expect(writeStub).to.not.have.been.called; }); + it("saves the choice to build locally", async () => { + await actuate(webSetup({ localBuild: true }), config, options); + + expect(config.src.run).to.include({ localBuild: true }); + }); + it("deploys only that service and region, with its base image", async () => { await actuate(webSetup(), config, options); @@ -226,31 +274,22 @@ describe("init run", () => { ]); }); - it("updates an existing service in place while preserving existing ignore and localBuild settings", () => { - config.set("run", { - serviceId: "web", - region: "us-central1", - rootDir: "/", - ignore: ["custom-ignore"], - localBuild: true, - }); + it("turns localBuild on for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"] }); - upsertRunConfig( - { - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - }, - config, - ); + upsertRunConfig({ ...web, localBuild: true }, config); - expect(config.src.run).to.deep.equal({ - serviceId: "web", - region: "us-central1", - rootDir: "apps/web", - ignore: ["custom-ignore"], - localBuild: true, - }); + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"], localBuild: true }); + }); + + it("turns localBuild off for an existing service, keeping its custom ignore list", () => { + const web = { serviceId: "web", region: "us-central1", rootDir: "/" }; + config.set("run", { ...web, ignore: ["custom-ignore"], localBuild: true }); + + upsertRunConfig(web, config); + + expect(config.src.run).to.deep.equal({ ...web, ignore: ["custom-ignore"] }); }); it("doesn't add an ignore list to an existing service", () => { diff --git a/src/init/features/run.ts b/src/init/features/run.ts index 33f285fa564..60861911f00 100644 --- a/src/init/features/run.ts +++ b/src/init/features/run.ts @@ -22,6 +22,7 @@ export interface RunInfo { region: string; baseImage: string; rootDir: string; + localBuild?: boolean; } /** @@ -64,17 +65,34 @@ export async function askQuestions(setup: Setup, config: Config, options: Option // If firebase.json already has this service, its saved settings are the defaults. const savedEntry = findRunEntry(getRunEntries(config), serviceId, region); + const localBuild = await select({ + message: "Would you like to build your app locally or remotely?", + choices: [ + { name: "Build remotely on Cloud Build", value: false }, + { name: "Build locally", value: true }, + ], + default: savedEntry?.localBuild === true, + }); + // A base image turns on automatic base image updates, which are off by default. So there's no - // default base image, except that an existing service keeps its own. + // default base image, except that an existing service keeps its own. Local builds need one. const rawBaseImage = await input({ - message: "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", + message: localBuild + ? "Which base image should your app use? (e.g. nodejs20, nodejs22)" + : "Which base image should your app use, if any? (e.g. nodejs20, nodejs22)", default: mainContainer(existing?.template)?.baseImageUri, + validate: (img: string) => + localBuild && !img.trim() ? "Local builds require a base image." : true, }); const baseImage = (rawBaseImage || "").trim(); const rootDir = await promptRootDir(config.projectDir, savedEntry?.rootDir ?? "/"); - setup.featureInfo = { ...setup.featureInfo, run: { serviceId, region, baseImage, rootDir } }; + const runInfo: RunInfo = { serviceId, region, baseImage, rootDir }; + if (localBuild) { + runInfo.localBuild = true; + } + setup.featureInfo = { ...setup.featureInfo, run: runInfo }; } async function promptExistingService(projectId: string): Promise { @@ -144,14 +162,12 @@ export async function actuate(setup: Setup, config: Config, options: Options): P if (!info) { return; } - upsertRunConfig( - { - serviceId: info.serviceId, - rootDir: info.rootDir, - region: info.region, - }, - config, - ); + const service = { serviceId: info.serviceId, rootDir: info.rootDir, region: info.region }; + if (info.localBuild) { + upsertRunConfig({ ...service, localBuild: true }, config); + } else { + upsertRunConfig(service, config); + } // Name the region too, so that only this service deploys, not ones with the same ID elsewhere. await deploy( ["run"], @@ -164,13 +180,13 @@ export async function actuate(setup: Setup, config: Config, options: Options): P * Adds or updates the service in `config`, the in-memory copy of firebase.json. It doesn't save * the file; init does that at the end. * - New service: adds an entry with the default ignore list. - * - Service already listed (same service ID and region): updates rootDir, the only init answer - * saved in firebase.json that can change. The rest of the entry, like a custom ignore list, - * stays as the user left it. + * - Service already listed (same service ID and region): updates rootDir and localBuild, the only + * init answers saved in firebase.json that can change. The rest of the entry, like a custom + * ignore list, stays as the user left it. * Exported for unit testing. */ export function upsertRunConfig( - service: { serviceId: string; rootDir: string; region: string }, + service: { serviceId: string; rootDir: string; region: string; localBuild?: boolean }, config: Config, ): void { const entries = getRunEntries(config); @@ -178,6 +194,11 @@ export function upsertRunConfig( const existing = findRunEntry(entries, service.serviceId, service.region); if (existing) { existing.rootDir = service.rootDir; + if (service.localBuild) { + existing.localBuild = true; + } else { + delete existing.localBuild; + } } else { entries.push({ ...service, ignore: DEFAULT_IGNORE }); }