diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts index 84ef48a406c..213bb39cb60 100644 --- a/src/deploy/apphosting/util.spec.ts +++ b/src/deploy/apphosting/util.spec.ts @@ -6,6 +6,7 @@ import * as tar from "tar"; import * as util from "./util"; import * as sinon from "sinon"; import { AppHostingSingle } from "../../firebaseConfig"; +import { FirebaseError } from "../../error"; import { logger } from "../../logger"; describe("util", () => { @@ -187,4 +188,75 @@ describe("util", () => { expect(files).to.include("apphosting.yaml"); }); }); + + describe("buildDirectory", () => { + afterEach(() => sinon.restore()); + + /** Pretends that only these files exist. */ + function withFiles(...files: string[]): void { + sinon.stub(fs, "existsSync").callsFake((file) => files.includes(file as string)); + } + + it("builds an app from its own folder if it isn't in a monorepo", () => { + withFiles(path.join("/project", "web", "package.json")); + expect(util.buildDirectory("/project", "web")).to.deep.equal({ + buildFolder: path.join("/project", "web"), + appFolder: "", + }); + expect(util.buildDirectory("/project")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "", + }); + expect(util.buildDirectory("/project", ".")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "", + }); + }); + + it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => { + withFiles(path.join("/project", "turbo.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "apps/web", + }); + + sinon.restore(); + withFiles(path.join("/project", "nx.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project"), + appFolder: "apps/web", + }); + }); + + it("uses the closest folder above the app with nx.json or turbo.json", () => { + withFiles( + path.join("/project", "turbo.json"), + path.join("/project", "monorepo", "turbo.json"), + ); + expect(util.buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "monorepo"), + appFolder: "apps/web", + }); + }); + + it("builds from the app's folder if the app is the monorepo's root", () => { + withFiles(path.join("/project", "apps/web", "nx.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "apps/web"), + appFolder: "", + }); + }); + + it("doesn't look for a monorepo above the project", () => { + withFiles(path.join("/", "turbo.json")); + expect(util.buildDirectory("/project", "apps/web")).to.deep.equal({ + buildFolder: path.join("/project", "apps/web"), + appFolder: "", + }); + }); + + it("throws if rootDir points outside the project", () => { + expect(() => util.buildDirectory("/project", "../web")).to.throw(FirebaseError, /rootDir/); + }); + }); }); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index b409ea5307b..0f5d1573a44 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -150,6 +150,71 @@ export function resolveIgnorePatterns( ignore.push(".local_build_*"); return ignore; } + +/** Files that mark the root of an Nx or Turborepo monorepo, the monorepos App Hosting supports. */ +const MONOREPO_CONFIG_FILES = ["nx.json", "turbo.json"] as const; + +/** + * Where an app builds: + * - The app folder is the backend's or service's rootDir, the folder with the app's package.json. + * - The build folder is the folder the build runs in, and what Cloud Run source deploys upload. For + * an app in an Nx or Turborepo monorepo, it's the monorepo's root, so the build gets the root's + * lockfile and shared packages. For any other app, it's the app folder. + * + * For example: + * + * my-app/ <- build folder and app folder + * firebase.json + * package.json + * app/ + * + * my-project/ + * firebase.json + * functions/ + * web/ <- build folder and app folder + * package.json + * + * my-turborepo/ <- build folder + * firebase.json + * turbo.json + * package.json + * apps/ + * web/ <- app folder + * package.json + */ +export interface BuildDirectory { + /** The build folder's path. */ + buildFolder: string; + /** + * The app folder's path in the build folder, with forward slashes, like "apps/web". Empty if the + * app folder is the build folder. + */ + appFolder: string; +} + +/** + * Finds an app's build folder and app folder (see the examples on BuildDirectory), with the same + * detection logic as Firebase App Hosting: the build folder is the closest folder at or above the + * app folder, up to the project's folder, that has nx.json or turbo.json, like my-turborepo/. If + * there's none, like in my-app/ and my-project/, the app folder is the build folder. + */ +export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory { + const appFolderPath = path.join(projectDir, rootDir); + const appFolderInProject = path.relative(projectDir, appFolderPath); + if (appFolderInProject.startsWith("..")) { + throw new FirebaseError(`rootDir "${rootDir}" must be inside the project directory.`); + } + // Check the app folder, then each folder above it, up to the project's folder. + const segments = appFolderInProject ? appFolderInProject.split(path.sep) : []; + for (let depth = segments.length; depth >= 0; depth--) { + const folder = path.join(projectDir, ...segments.slice(0, depth)); + if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(folder, file)))) { + return { buildFolder: folder, appFolder: segments.slice(depth).join("/") }; + } + } + return { buildFolder: appFolderPath, appFolder: "" }; +} + async function pipeAsync(from: archiver.Archiver, to: fs.WriteStream): Promise { from.pipe(to); await from.finalize(); diff --git a/src/deploy/run/deploy.spec.ts b/src/deploy/run/deploy.spec.ts index db259f7a424..70ab8f90359 100644 --- a/src/deploy/run/deploy.spec.ts +++ b/src/deploy/run/deploy.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import * as fs from "fs"; +import * as path from "path"; import * as sinon from "sinon"; import * as localbuilds from "../../apphosting/localbuilds"; import * as artifactregistry from "../../gcp/artifactregistry"; @@ -58,13 +59,15 @@ describe("run deploy", () => { expect(createArchiveStub).to.have.been.calledWithMatch( { backendId: "web" }, - "/project/apps/web", + path.join("/project", "apps/web"), ); expect(submitBuildStub).to.have.been.calledWithMatch("my-project", "us-central1", { storageSource: { bucket: "source-bucket", object: "web.zip" }, imageUri: image, buildpackBuild: { baseImage: NODEJS22, enableAutomaticUpdates: true }, }); + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).not.to.have.property("GOOGLE_BUILDABLE"); expect(createServiceStub).to.have.been.calledWithMatch("my-project", "us-central1", "web", { template: { containers: [{ name: "web", image, baseImageUri: NODEJS22 }] }, invokerIamDisabled: true, @@ -73,6 +76,29 @@ describe("run deploy", () => { expect(svc.deployed).to.deep.equal({ uri: "https://web.run.app" }); }); + it("builds an app in an Nx or Turborepo monorepo from the monorepo's root", async () => { + sinon.stub(fs, "existsSync").callsFake((file) => file === path.join("/project", "turbo.json")); + + await deployOne({ config: web }); + + // The upload has the monorepo's root files, like its lockfile and shared packages. + expect(createArchiveStub).to.have.been.calledWithMatch( + { backendId: "web" }, + path.join("/project"), + ); + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "apps/web" }); + }); + + it("keeps GOOGLE_BUILDABLE from buildEnv if the user set it", async () => { + sinon.stub(fs, "existsSync").callsFake((file) => file === path.join("/project", "turbo.json")); + + await deployOne({ config: web, buildEnv: { GOOGLE_BUILDABLE: "custom/web" } }); + + const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild; + expect(environmentVariables).to.include({ GOOGLE_BUILDABLE: "custom/web" }); + }); + it("builds without a base image if the service doesn't have one", async () => { await deployOne({ config: web }); diff --git a/src/deploy/run/deploy.ts b/src/deploy/run/deploy.ts index 33706c828e0..710f43bf485 100644 --- a/src/deploy/run/deploy.ts +++ b/src/deploy/run/deploy.ts @@ -14,7 +14,11 @@ import { logger } from "../../logger"; import { Options } from "../../options"; import { cloneDeep, logLabeledBullet } from "../../utils"; import { prepareLocalBuildScratchDirectory } from "../apphosting/prepare"; -import { createLocalBuildTarArchive, createSourceDeployArchive } from "../apphosting/util"; +import { + buildDirectory, + createLocalBuildTarArchive, + createSourceDeployArchive, +} from "../apphosting/util"; import { Context, Payload, ServiceDeploy } from "./args"; import { nonSecretValues, secretNames, toLocalBuildEnv } from "./buildEnv"; import { FIREBASE_APP_ANNOTATION, mainContainer } from "./util"; @@ -57,8 +61,9 @@ async function deployRemoteBuild( svc: ServiceDeploy, ): Promise { const { projectId } = context; - const source = await uploadSource(projectId, options, svc.config); - const image = await buildImage(projectId, svc, source); + const { buildFolder, appFolder } = buildDirectory(options.config.projectDir, svc.config.rootDir); + const source = await uploadSource(projectId, options, svc.config, buildFolder); + const image = await buildImage(projectId, svc, source, appFolder); const template = revisionTemplate(svc, image, options.message as string | undefined); svc.deployed = await rollOut(projectId, svc, template); } @@ -137,15 +142,13 @@ async function uploadSource( projectId: string, options: Options, config: RunSingle, + buildFolder: string, ): Promise { const { serviceId, region } = config; 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( - cfg, - path.join(options.config.projectDir, cfg.rootDir), - ); + const archive = await createSourceDeployArchive(cfg, buildFolder); logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`); const { bucket, object } = await gcs.uploadObject( { file: archive, stream: fs.createReadStream(archive) }, @@ -201,11 +204,16 @@ async function sourceBucket(projectId: string, options: Options, region: string) }); } -/** Builds the uploaded source into an image on Cloud Build and returns the image's URI. */ +/** + * Builds the uploaded build folder into an image on Cloud Build and returns the image's URI. + * `appFolder` is the app folder's path in the build folder, or empty if they're the same folder + * (see BuildDirectory in apphosting/util.ts). + */ async function buildImage( projectId: string, svc: ServiceDeploy, source: runv2.StorageSource, + appFolder: string, ): Promise { const { serviceId, region } = svc.config; await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy"); @@ -213,6 +221,8 @@ async function buildImage( logLabeledBullet("run", `Building service ${serviceId} in ${region}...`); const buildpackBuild: runv2.BuildpacksBuild = { environmentVariables: { + // In a monorepo, tell the buildpacks which folder in the build folder is the app. + ...(appFolder ? { GOOGLE_BUILDABLE: appFolder } : {}), // 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 || {}),