diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 8c319563d4b..f97648ddeb1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9,7 +9,7 @@ "version": "15.11.0", "license": "MIT", "dependencies": { - "@apphosting/build": "^0.1.6", + "@apphosting/build": "^0.1.7", "@apphosting/common": "^0.0.8", "@electric-sql/pglite": "^0.3.3", "@electric-sql/pglite-tools": "^0.2.8", @@ -72,6 +72,7 @@ "stream-chain": "^2.2.4", "stream-json": "^1.7.3", "superstatic": "^10.0.0", + "tar": "^7.5.11", "tcp-port-used": "^1.0.2", "tmp": "^0.2.3", "triple-beam": "^1.3.0", @@ -301,12 +302,12 @@ } }, "node_modules/@apphosting/build": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@apphosting/build/-/build-0.1.6.tgz", - "integrity": "sha512-nXK1wsR1tehaq9uSRDCGQmN+Dp0xbyGohssYd7g4W8ZbzHfUiab+Pabv34pHVTS03VaSVkjdNcR1g9hezi6s8g==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@apphosting/build/-/build-0.1.7.tgz", + "integrity": "sha512-zNgQGiAWDOj6c+4ylv5ej3nLGXzMAVmzCGMqlbSarHe4bvBmZ2C5GfBRdJksedP7C9pqlwTWpxU5+GSzhJ+nKA==", "license": "Apache-2.0", "dependencies": { - "@apphosting/common": "^0.0.8", + "@apphosting/common": "^0.0.9", "@npmcli/promise-spawn": "^3.0.0", "colorette": "^2.0.20", "commander": "^11.1.0", @@ -317,6 +318,12 @@ "apphosting-local-build": "dist/bin/localbuild.js" } }, + "node_modules/@apphosting/build/node_modules/@apphosting/common": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@apphosting/common/-/common-0.0.9.tgz", + "integrity": "sha512-ZbPZDcVhEN+8m0sf90PmQN4xWaKmmySnBSKKPaIOD0JvcDsRr509WenFEFlojP++VSxwFZDGG/TYsHs1FMMqpw==", + "license": "Apache-2.0" + }, "node_modules/@apphosting/build/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -3570,6 +3577,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -12363,9 +12391,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", "peer": true, "engines": { @@ -20314,6 +20342,22 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, + "node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -20352,6 +20396,45 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/tcp-port-used": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz", @@ -22387,11 +22470,11 @@ } }, "@apphosting/build": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@apphosting/build/-/build-0.1.6.tgz", - "integrity": "sha512-nXK1wsR1tehaq9uSRDCGQmN+Dp0xbyGohssYd7g4W8ZbzHfUiab+Pabv34pHVTS03VaSVkjdNcR1g9hezi6s8g==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@apphosting/build/-/build-0.1.7.tgz", + "integrity": "sha512-zNgQGiAWDOj6c+4ylv5ej3nLGXzMAVmzCGMqlbSarHe4bvBmZ2C5GfBRdJksedP7C9pqlwTWpxU5+GSzhJ+nKA==", "requires": { - "@apphosting/common": "^0.0.8", + "@apphosting/common": "^0.0.9", "@npmcli/promise-spawn": "^3.0.0", "colorette": "^2.0.20", "commander": "^11.1.0", @@ -22399,6 +22482,11 @@ "ts-node": "^10.9.1" }, "dependencies": { + "@apphosting/common": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@apphosting/common/-/common-0.0.9.tgz", + "integrity": "sha512-ZbPZDcVhEN+8m0sf90PmQN4xWaKmmySnBSKKPaIOD0JvcDsRr509WenFEFlojP++VSxwFZDGG/TYsHs1FMMqpw==" + }, "commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -24772,6 +24860,21 @@ } } }, + "@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "requires": { + "minipass": "^7.0.4" + }, + "dependencies": { + "minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + } + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -31241,9 +31344,9 @@ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" }, "hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "peer": true }, "hosted-git-info": { @@ -37074,6 +37177,43 @@ } } }, + "tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "requires": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "dependencies": { + "chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" + }, + "minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "requires": { + "minipass": "^7.1.2" + } + }, + "yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" + } + } + }, "tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", diff --git a/package.json b/package.json index 24a0a936bdb..0989a615563 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ ] }, "dependencies": { - "@apphosting/build": "^0.1.6", + "@apphosting/build": "^0.1.7", "@apphosting/common": "^0.0.8", "@electric-sql/pglite": "^0.3.3", "@electric-sql/pglite-tools": "^0.2.8", @@ -167,6 +167,7 @@ "stream-chain": "^2.2.4", "stream-json": "^1.7.3", "superstatic": "^10.0.0", + "tar": "^7.5.11", "tcp-port-used": "^1.0.2", "tmp": "^0.2.3", "triple-beam": "^1.3.0", diff --git a/src/apphosting/localbuilds.ts b/src/apphosting/localbuilds.ts index ed8bef0d077..3de7a136cbc 100644 --- a/src/apphosting/localbuilds.ts +++ b/src/apphosting/localbuilds.ts @@ -2,7 +2,17 @@ import { BuildConfig, Env } from "../gcp/apphosting"; import { localBuild as localAppHostingBuild } from "@apphosting/build"; /** - * Triggers a local apphosting build. + * Triggers a local build of your App Hosting codebase. + * + * This function orchestrates the build process using the App Hosting build adapter. + * It detects the framework (though currently defaults/assumes 'nextjs' in some contexts), + * generates the necessary build artifacts, and returns metadata about the build. + * @param projectRoot - The root directory of the project to build. + * @param framework - The framework to use for the build (e.g., 'nextjs'). + * @return A promise that resolves to the build output, including: + * - `outputFiles`: Paths to the generated build artifacts. + * - `annotations`: Metadata annotations relating to the build. + * - `buildConfig`: Configuration derived from the build process (e.g. run commands, environment variables). */ export async function localBuild( projectRoot: string, diff --git a/src/deploy/apphosting/deploy.spec.ts b/src/deploy/apphosting/deploy.spec.ts index 14f943e2758..01b1f9a38cf 100644 --- a/src/deploy/apphosting/deploy.spec.ts +++ b/src/deploy/apphosting/deploy.spec.ts @@ -8,6 +8,8 @@ import deploy from "./deploy"; import * as util from "./util"; import * as fs from "fs"; import * as getProjectNumber from "../../getProjectNumber"; +import * as experiments from "../../experiments"; +import { FirebaseError } from "../../error"; const BASE_OPTS = { cwd: "/", @@ -51,6 +53,7 @@ describe("apphosting", () => { let upsertBucketStub: sinon.SinonStub; let uploadObjectStub: sinon.SinonStub; let createArchiveStub: sinon.SinonStub; + let createTarArchiveStub: sinon.SinonStub; let createReadStreamStub: sinon.SinonStub; let getProjectNumberStub: sinon.SinonStub; @@ -60,10 +63,17 @@ describe("apphosting", () => { .throws("Unexpected getProjectNumber call"); upsertBucketStub = sinon.stub(gcs, "upsertBucket").throws("Unexpected upsertBucket call"); uploadObjectStub = sinon.stub(gcs, "uploadObject").throws("Unexpected uploadObject call"); - createArchiveStub = sinon.stub(util, "createArchive").throws("Unexpected createArchive call"); + createArchiveStub = sinon + .stub(util, "createSourceDeployArchive") + .throws("Unexpected createSourceDeployArchive call"); + createTarArchiveStub = sinon + .stub(util, "createLocalBuildTarArchive") + .throws("Unexpected createLocalBuildTarArchive call"); createReadStreamStub = sinon .stub(fs, "createReadStream") .throws("Unexpected createReadStream call"); + sinon.stub(experiments, "isEnabled").returns(true); + sinon.stub(experiments, "assertEnabled"); }); afterEach(() => { @@ -100,7 +110,7 @@ describe("apphosting", () => { getProjectNumberStub.resolves(projectNumber); upsertBucketStub.resolves(bucketName); createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip"); - createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip"); + createTarArchiveStub.onFirstCall().resolves("path/to/foo-local-build-1234.tar.gz"); uploadObjectStub.onFirstCall().resolves({ bucket: bucketName, @@ -144,7 +154,7 @@ describe("apphosting", () => { projectId: "my-project", req: { baseName: "firebaseapphosting-sources-000000000000-us-central1", - purposeLabel: `apphosting-source-${location}`, + purposeLabel: `apphosting-source-${location.toLowerCase()}`, location: "us-central1", lifecycle: { rule: [ @@ -157,6 +167,10 @@ describe("apphosting", () => { }, }); expect(createArchiveStub).to.be.calledWithExactly( + context.backendConfigs["foo"], + process.cwd(), + ); + expect(createTarArchiveStub).to.be.calledWithExactly( context.backendConfigs["fooLocalBuild"], process.cwd(), "./nextjs/standalone", @@ -164,6 +178,12 @@ describe("apphosting", () => { expect(uploadObjectStub).to.be.calledWithMatch( sinon.match.any, "firebaseapphosting-sources-000000000000-us-central1", + gcs.ContentType.ZIP, + ); + expect(uploadObjectStub).to.be.calledWithMatch( + sinon.match.any, + "firebaseapphosting-sources-000000000000-us-central1", + gcs.ContentType.TAR, ); }); @@ -175,7 +195,7 @@ describe("apphosting", () => { getProjectNumberStub.resolves(projectNumber); upsertBucketStub.resolves(bucketName); createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip"); - createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip"); + createTarArchiveStub.onFirstCall().resolves("path/to/foo-local-build-1234.tar.gz"); uploadObjectStub.onFirstCall().resolves({ bucket: bucketName, @@ -189,10 +209,49 @@ describe("apphosting", () => { createReadStreamStub.returns("stream" as any); await deploy(context, opts); + expect(createArchiveStub).to.be.calledWithExactly( + context.backendConfigs["foo"], + process.cwd(), + ); + expect(createTarArchiveStub).to.be.calledWithExactly( + context.backendConfigs["fooLocalBuild"], + process.cwd(), + "./nextjs/standalone", + ); + expect(uploadObjectStub).to.be.calledWithMatch( + sinon.match.any, + "firebaseapphosting-sources-000000000000-us-central1", + gcs.ContentType.ZIP, + ); + expect(uploadObjectStub).to.be.calledWithMatch( + sinon.match.any, + "firebaseapphosting-sources-000000000000-us-central1", + gcs.ContentType.TAR, + ); expect(context.backendStorageUris["foo"]).to.equal(`gs://${bucketName}/foo-1234.zip`); expect(context.backendStorageUris["fooLocalBuild"]).to.equal( - `gs://${bucketName}/foo-local-build-1234.zip`, + `gs://${bucketName}/foo-local-build-1234.tar.gz`, + ); + }); + + it("should throw error if localBuild is true but experiment is disabled", async () => { + const context = initializeContext(); + context.backendConfigs = { + fooLocalBuild: context.backendConfigs["fooLocalBuild"], + }; + context.backendLocations = { fooLocalBuild: "us-central1" }; + + getProjectNumberStub.resolves("000000000000"); + upsertBucketStub.resolves("some-bucket"); + (experiments.assertEnabled as sinon.SinonStub).restore(); + sinon + .stub(experiments, "assertEnabled") + .throws(new FirebaseError("App Hosting local builds experiment is not enabled")); + + await expect(deploy(context, opts)).to.be.rejectedWith( + FirebaseError, + "App Hosting local builds experiment is not enabled", ); }); }); diff --git a/src/deploy/apphosting/deploy.ts b/src/deploy/apphosting/deploy.ts index fa7eaf2bf7a..cc77e46dc3d 100644 --- a/src/deploy/apphosting/deploy.ts +++ b/src/deploy/apphosting/deploy.ts @@ -7,11 +7,20 @@ import { Options } from "../../options"; import { needProjectId } from "../../projectUtils"; import { logLabeledBullet } from "../../utils"; import { Context } from "./args"; -import { createArchive } from "./util"; +import * as util from "./util"; +import * as experiments from "../../experiments"; /** - * Zips and uploads App Hosting source code to Google Cloud Storage in preparation for - * build and deployment. Creates storage buckets if necessary. + * Uploads App Hosting source code or local build output to Google Cloud Storage. + * + * This step ensures that a GCS bucket exists for the target region and then + * archives the contents. Source deployments are zipped using the "createArchive" + * method, while local build deployments are tar-balled using the "createTarArchive" + * method. The resulting archive is uploaded to the bucket, and the URI is stored in + * the context for the subsequent release phase. + * + * @param context - The deployment context containing backend configs and locations. + * @param options - CLI options providing project ID and root directory. */ export default async function (context: Context, options: Options): Promise { if (Object.entries(context.backendConfigs).length === 0) { @@ -65,16 +74,22 @@ export default async function (context: Context, options: Options): Promise { const rootDir = options.projectRoot ?? process.cwd(); let builtAppDir; - if (cfg.localBuild) { + const isLocalBuild = !!cfg.localBuild; + if (isLocalBuild) { + experiments.assertEnabled("apphostinglocalbuilds", "App Hosting local builds"); builtAppDir = context.backendLocalBuilds[cfg.backendId].buildDir; if (!builtAppDir) { throw new FirebaseError(`No local build dir found for ${cfg.backendId}`); } } - const zippedSourcePath = await createArchive(cfg, rootDir, builtAppDir); + + const zippedSourcePath = isLocalBuild + ? await util.createLocalBuildTarArchive(cfg, rootDir, builtAppDir) + : await util.createSourceDeployArchive(cfg, rootDir); + logLabeledBullet( "apphosting", - `Zipped ${cfg.localBuild ? "built app" : "source"} for backend ${cfg.backendId}`, + `Zipped ${isLocalBuild ? "built app" : "source"} for backend ${cfg.backendId}`, ); const backendLocation = context.backendLocations[cfg.backendId]; @@ -85,7 +100,7 @@ export default async function (context: Context, options: Options): Promise { buildConfig, annotations, }); + sinon.stub(experiments, "assertEnabled").returns(); listBackendsStub.onFirstCall().resolves({ backends: [ { @@ -125,6 +127,43 @@ describe("apphosting", () => { }); }); + it("should fail if localBuild is specified but experiment is disabled", async () => { + const optsWithLocalBuild = { + ...opts, + config: new Config({ + apphosting: { + backendId: "foo", + rootDir: "/", + ignore: [], + localBuild: true, + }, + }), + }; + const context = initializeContext(); + + sinon + .stub(experiments, "assertEnabled") + .throws(new Error("Experiment 'apphostinglocalbuilds' is not enabled.")); + listBackendsStub.onFirstCall().resolves({ + backends: [ + { + name: "projects/my-project/locations/us-central1/backends/foo", + }, + ], + }); + + try { + await prepare(context, optsWithLocalBuild); + expect.fail("Should have thrown an error"); + } catch (e: unknown) { + if (e instanceof Error) { + expect(e.message).to.include("Experiment 'apphostinglocalbuilds' is not enabled."); + } else { + expect.fail("Expected Error instance"); + } + } + }); + it("links to existing backend if it already exists", async () => { const context = initializeContext(); listBackendsStub.onFirstCall().resolves({ diff --git a/src/deploy/apphosting/prepare.ts b/src/deploy/apphosting/prepare.ts index 87086e2f2ab..ffce25945ec 100644 --- a/src/deploy/apphosting/prepare.ts +++ b/src/deploy/apphosting/prepare.ts @@ -12,6 +12,7 @@ import { needProjectId } from "../../projectUtils"; import { checkbox, confirm } from "../../prompt"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { localBuild } from "../../apphosting/localbuilds"; +import * as experiments from "../../experiments"; import { Context } from "./args"; import { FirebaseError } from "../../error"; @@ -152,6 +153,7 @@ export default async function (context: Context, options: Options): Promise { await expect(release(context, opts)).to.eventually.not.rejected; }); + + it("correctly passes buildInput for local builds", async () => { + const context: Context = { + backendConfigs: { + fooLocalBuild: { + backendId: "fooLocalBuild", + rootDir: "/root", + ignore: [], + localBuild: true, + }, + }, + backendLocations: { fooLocalBuild: "us-central1" }, + backendStorageUris: { + fooLocalBuild: "gs://bucket/foo-local-build.tar.gz", + }, + backendLocalBuilds: { + fooLocalBuild: { + buildDir: "./dist", + buildConfig: { + runCommand: "npm run build", + env: [{ variable: "VAR1", value: "VALUE1" }], + }, + annotations: {}, + }, + }, + }; + + const orchestrateRolloutStub = sinon.stub(rollout, "orchestrateRollout").resolves(); + sinon.stub(backend, "getBackend").resolves({ + name: "projects/my-project/locations/us-central1/backends/fooLocalBuild", + servingLocality: "GLOBAL_ACCESS", + labels: {}, + createTime: "2023-01-01T00:00:00Z", + updateTime: "2023-01-01T00:00:00Z", + uri: "foo.apphosting.com", + }); + + await release(context, opts); + + expect(orchestrateRolloutStub).to.be.calledWith({ + projectId: "my-project", + backendId: "fooLocalBuild", + location: "us-central1", + buildInput: { + config: { + runCommand: "npm run build", + env: [{ variable: "VAR1", value: "VALUE1" }], + }, + source: { + archive: { + userStorageUri: "gs://bucket/foo-local-build.tar.gz", + rootDirectory: "/root", + locallyBuiltSource: true, + }, + }, + }, + }); + }); }); }); diff --git a/src/deploy/apphosting/release.ts b/src/deploy/apphosting/release.ts index 33056fd06f5..b23cf949739 100644 --- a/src/deploy/apphosting/release.ts +++ b/src/deploy/apphosting/release.ts @@ -15,6 +15,13 @@ import { FirebaseError } from "../../error"; /** * Orchestrates rollouts for the backends targeted for deployment. + * + * This step executes the actual "release" phase of the deployment. It takes the + * potentially uploaded source code (or linked repository commits) and triggers + * the App Hosting rollout API. It tracks the progress of the rollouts and reports + * success or failure to the user. + * @param context - The deployment context containing backend configs, locations, and storage URIs. + * @param options - CLI options. */ export default async function (context: Context, options: Options): Promise { let backendIds = Object.keys(context.backendConfigs); @@ -30,15 +37,6 @@ export default async function (context: Context, options: Options): Promise !missingBackends.includes(id)); } - const localBuildBackends = backendIds.filter((id) => context.backendLocalBuilds[id]); - if (localBuildBackends.length > 0) { - logLabeledWarning( - "apphosting", - `Skipping backend(s) ${localBuildBackends.join(", ")}. Local Builds are not supported yet.`, - ); - backendIds = backendIds.filter((id) => !localBuildBackends.includes(id)); - } - if (backendIds.length === 0) { return; } @@ -47,16 +45,17 @@ export default async function (context: Context, options: Options): Promise // TODO(9114): Add run_command // TODO(914): Set the buildConfig. - // TODO(914): Set locallyBuiltSource. orchestrateRollout({ projectId, backendId, location: context.backendLocations[backendId], buildInput: { + config: context.backendLocalBuilds[backendId]?.buildConfig, source: { archive: { userStorageUri: context.backendStorageUris[backendId], rootDirectory: context.backendConfigs[backendId].rootDir, + locallyBuiltSource: !!context.backendLocalBuilds[backendId], }, }, }, diff --git a/src/deploy/apphosting/util.spec.ts b/src/deploy/apphosting/util.spec.ts new file mode 100644 index 00000000000..9ade32530bd --- /dev/null +++ b/src/deploy/apphosting/util.spec.ts @@ -0,0 +1,82 @@ +import { expect } from "chai"; +import * as fs from "fs"; +import * as path from "path"; +import * as tmp from "tmp"; +import * as tar from "tar"; +import * as util from "./util"; + +describe("util", () => { + let tmpDir: tmp.DirResult; + let rootDir: string; + let distDir: string; + + beforeEach(() => { + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + rootDir = tmpDir.name; + distDir = path.join(rootDir, "dist"); + fs.mkdirSync(distDir); + }); + + afterEach(() => { + tmpDir.removeCallback(); + }); + + describe("createLocalBuildTarArchive", () => { + it("should include apphosting.yaml from root when archiving a subdirectory", async () => { + // Setup: Create apphosting.yaml in root and some files in dist + fs.writeFileSync(path.join(rootDir, "apphosting.yaml"), "env: []"); + fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')"); + + const config = { + backendId: "test-backend", + rootDir: "", + ignore: [], + }; + + const tarballPath: string = await util.createLocalBuildTarArchive( + config, + rootDir, + path.relative(rootDir, distDir), + ); + + // Verify: List files in tarball + const files: string[] = []; + tar.list({ + file: tarballPath, + sync: true, + onentry: (entry: { path: string }) => files.push(entry.path), + }); + + expect(files).to.include("dist/index.js"); + expect(files).to.include("apphosting.yaml"); + }); + + it("should not fail if apphosting.yaml does not exist", async () => { + // Setup: No apphosting.yaml, only files in dist + fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')"); + + const config = { + backendId: "test-backend", + rootDir: "", + ignore: [], + }; + + const tarballPath: string = await util.createLocalBuildTarArchive( + config, + rootDir, + path.relative(rootDir, distDir), + ); + + // Verify: List files in tarball + const files: string[] = []; + tar.list({ + file: tarballPath, + sync: true, + onentry: (entry: { path: string }) => files.push(entry.path), + }); + + expect(files).to.include("dist/index.js"); + expect(files).to.not.include("apphosting.yaml"); + }); + }); +}); diff --git a/src/deploy/apphosting/util.ts b/src/deploy/apphosting/util.ts index 6fcededc311..b96d76e42e2 100644 --- a/src/deploy/apphosting/util.ts +++ b/src/deploy/apphosting/util.ts @@ -1,16 +1,82 @@ import * as archiver from "archiver"; import * as fs from "fs"; import * as path from "path"; +import * as tar from "tar"; import * as tmp from "tmp"; import { FirebaseError } from "../../error"; import { AppHostingSingle } from "../../firebaseConfig"; import * as fsAsync from "../../fsAsync"; +import { APPHOSTING_YAML_FILE_REGEX } from "../../apphosting/config"; + +/** + * Creates a temporary tarball of the project source or build artifacts. + * + * This function packages the specified directory into a `.tar.gz` file, respecting + * ignore patterns (like `.git`, `firebase-debug.log`, etc.). It is used to prepare + * the code/artifacts for upload to Google Cloud Storage. + * @param config - The App Hosting backend configuration. + * @param rootDir - The root directory of the project. + * @param targetSubDir - Optional subdirectory to simplify (e.g. if we only want to zip 'dist'). + * @return A promise that resolves to the absolute path of the created temporary tarball. + */ +export async function createLocalBuildTarArchive( + config: AppHostingSingle, + rootDir: string, + targetSubDir?: string, +): Promise { + const tmpFile = tmp.fileSync({ prefix: `${config.backendId}-`, postfix: ".tar.gz" }).name; + + const targetDir = targetSubDir ? path.join(rootDir, targetSubDir) : rootDir; + const ignore = ["firebase-debug.log", "firebase-debug.*.log", ".git"]; + const rdrFiles = await fsAsync.readdirRecursive({ + path: targetDir, + ignore: ignore, + isGitIgnore: true, + }); + const allFiles: string[] = rdrFiles.map((rdrf) => path.relative(rootDir, rdrf.name)); + + if (targetSubDir) { + const defaultFiles = fs.readdirSync(rootDir).filter((file) => { + return APPHOSTING_YAML_FILE_REGEX.test(file); + }); + for (const file of defaultFiles) { + if (!allFiles.includes(file)) { + allFiles.push(file); + } + } + } + + // `tar` returns a `TypeError` if `allFiles` is empty. Let's check a feww things. + try { + fs.statSync(rootDir); + } catch (err: unknown) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + throw new FirebaseError(`Could not read directory "${rootDir}"`); + } + throw err; + } + if (!allFiles.length) { + throw new FirebaseError(`Cannot create a tar archive with 0 files from directory "${rootDir}"`); + } + + await tar.create( + { + gzip: true, + file: tmpFile, + cwd: rootDir, + portable: true, + }, + allFiles, + ); + return tmpFile; +} + /** * Locates the source code for a backend and creates an archive to eventually upload to GCS. * Based heavily on functions upload logic in src/deploy/functions/prepareFunctionsUpload.ts. */ -export async function createArchive( +export async function createSourceDeployArchive( config: AppHostingSingle, rootDir: string, targetSubDir?: string, diff --git a/tsconfig.json b/tsconfig.json index dfc39754898..8ddf4cc254c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,10 @@ "dom.iterable", "ES2020" ], - "include": ["src/**/*"], - "exclude": ["src/dynamicImport.js"] + "include": [ + "src/**/*" + ], + "exclude": [ + "src/dynamicImport.js" + ] }