Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions src/deploy/run/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,7 +11,7 @@
import * as apphostingPrepare from "../apphosting/prepare";
import * as apphostingUtil from "../apphosting/util";
import { ServiceDeploy } from "./args";
import { deploy, localBuildTemplate, revisionTemplate } from "./deploy";
import { buildDirectory, 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";
Expand Down Expand Up @@ -58,13 +59,15 @@

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;

Check warning on line 69 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .buildpackBuild on an `any` value

Check warning on line 69 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
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,
Expand All @@ -73,11 +76,34 @@
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;

Check warning on line 89 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .buildpackBuild on an `any` value

Check warning on line 89 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
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;

Check warning on line 98 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .buildpackBuild on an `any` value

Check warning on line 98 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
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 });

// Without a base image, Cloud Build can't turn on automatic base image updates.
const { buildpackBuild } = submitBuildStub.firstCall.args[2];

Check warning on line 106 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
expect(buildpackBuild).not.to.have.property("baseImage");
expect(buildpackBuild).not.to.have.property("enableAutomaticUpdates");
});
Expand All @@ -87,7 +113,7 @@

await deployOne({ config: web, buildEnv });

const { environmentVariables } = submitBuildStub.firstCall.args[2].buildpackBuild;

Check warning on line 116 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .buildpackBuild on an `any` value

Check warning on line 116 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
expect(environmentVariables).to.include({ API_URL: "https://api.example.com" });
expect(environmentVariables).not.to.have.property("NPM_TOKEN");
});
Expand Down Expand Up @@ -155,7 +181,7 @@
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" });

Check warning on line 184 in src/deploy/run/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .annotations on an `any` value
});

it("keeps the service's annotations as they are if the app didn't change", async () => {
Expand Down Expand Up @@ -279,6 +305,72 @@
});
});

describe("buildDirectory", () => {
/** 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(buildDirectory("/project", "web")).to.deep.equal({
dir: path.join("/project", "web"),
app: "",
});
expect(buildDirectory("/project")).to.deep.equal({ dir: path.join("/project"), app: "" });
expect(buildDirectory("/project", ".")).to.deep.equal({
dir: path.join("/project"),
app: "",
});
});

it("builds an app in a Turborepo or Nx monorepo from the monorepo's root", () => {
withFiles(path.join("/project", "turbo.json"));
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: path.join("/project"),
app: "apps/web",
});

sinon.restore();
withFiles(path.join("/project", "nx.json"));
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: path.join("/project"),
app: "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(buildDirectory("/project", "monorepo/apps/web")).to.deep.equal({
dir: path.join("/project", "monorepo"),
app: "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(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: path.join("/project", "apps/web"),
app: "",
});
});

it("doesn't look for a monorepo outside the project", () => {
withFiles(path.join("/", "turbo.json"));
expect(buildDirectory("/project", "apps/web")).to.deep.equal({
dir: path.join("/project", "apps/web"),
app: "",
});
expect(buildDirectory("/project", "../other")).to.deep.equal({
dir: path.join("/project", "../other"),
app: "",
});
});
});

describe("revisionTemplate", () => {
const existing = {
name: "projects/my-project/locations/us-central1/services/web",
Expand Down
57 changes: 48 additions & 9 deletions src/deploy/run/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const ROLLOUT_TIMEOUT_MS = 10 * 60 * 1000;
*/
const NO_IMAGE = "scratch";

/** 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;

/** What a local build produced. */
export interface LocalBuildOutput {
/** The built files to upload, relative to the scratch directory the build ran in. */
Expand All @@ -39,6 +42,14 @@ export interface LocalBuildOutput {
env: NonNullable<runv2.Container["env"]>;
}

/** Where a build on Cloud Build runs. */
export interface BuildDirectory {
/** The directory to upload and build from. */
dir: string;
/** The app's folder in `dir`, with forward slashes. Empty if the app is `dir` itself. */
app: string;
}

/** Builds each service, locally or on Cloud Build, then rolls it out. */
export async function deploy(context: Context, options: Options, payload: Payload): Promise<void> {
for (const svc of payload.run?.services || []) {
Expand All @@ -57,12 +68,37 @@ async function deployRemoteBuild(
svc: ServiceDeploy,
): Promise<void> {
const { projectId } = context;
const source = await uploadSource(projectId, options, svc.config);
const image = await buildImage(projectId, svc, source);
const { dir, app } = buildDirectory(options.config.projectDir, svc.config.rootDir);
const source = await uploadSource(projectId, options, svc.config, dir);
const image = await buildImage(projectId, svc, source, app);
const template = revisionTemplate(svc, image, options.message as string | undefined);
svc.deployed = await rollOut(projectId, svc, template);
}

/**
* Returns where to build the service on Cloud Build, the way App Hosting decides it. An app in an
* Nx or Turborepo monorepo builds from the monorepo's root, so that it has the root's lockfile and
* shared packages. Any other app builds from its own folder. Only folders in the project count.
* Exported for testing.
*/
export function buildDirectory(projectDir: string, rootDir = ""): BuildDirectory {
const appDir = path.join(projectDir, rootDir);
const appPath = path.relative(projectDir, appDir);
if (appPath === ".." || appPath.startsWith(`..${path.sep}`)) {
// The app is outside the project, so don't look for a monorepo above it.
return { dir: appDir, app: "" };
}
// Check the app's folder, then each folder above it, up to the project's root.
const folders = appPath ? appPath.split(path.sep) : [];
for (let depth = folders.length; depth >= 0; depth--) {
const dir = path.join(projectDir, ...folders.slice(0, depth));
if (MONOREPO_CONFIG_FILES.some((file) => fs.existsSync(path.join(dir, file)))) {
return { dir, app: folders.slice(depth).join("/") };
}
}
return { dir: appDir, app: "" };
}

/**
* 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.
Expand Down Expand Up @@ -132,20 +168,17 @@ async function buildLocally(
return { outputFiles, runCommand: buildConfig.runCommand, env };
}

/** Zips the service's source and uploads it to the region's source bucket. */
/** Zips `dir`, the directory to build from, and uploads it to the region's source bucket. */
async function uploadSource(
projectId: string,
options: Options,
config: RunSingle,
dir: string,
): Promise<runv2.StorageSource> {
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(toAppHostingConfig(config), dir);
logLabeledBullet("run", `Uploading source for service ${serviceId} in ${region}...`);
const { bucket, object } = await gcs.uploadObject(
{ file: archive, stream: fs.createReadStream(archive) },
Expand Down Expand Up @@ -201,18 +234,24 @@ 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 source into an image on Cloud Build and returns the image's URI. `app` is the
* app's folder in the upload, or empty if the upload is just the app (see buildDirectory).
*/
async function buildImage(
projectId: string,
svc: ServiceDeploy,
source: runv2.StorageSource,
app: string,
): Promise<string> {
const { serviceId, region } = svc.config;
await artifactregistry.ensureDockerRepository(projectId, region, "cloud-run-source-deploy");
const imageUri = `${region}-docker.pkg.dev/${projectId}/cloud-run-source-deploy/${serviceId}:${Date.now()}`;
logLabeledBullet("run", `Building service ${serviceId} in ${region}...`);
const buildpackBuild: runv2.BuildpacksBuild = {
environmentVariables: {
// If the upload is a monorepo, tell the buildpacks which folder is the app.
...(app ? { GOOGLE_BUILDABLE: app } : {}),
// 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 || {}),
Expand Down
Loading