Skip to content
Draft
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
98 changes: 58 additions & 40 deletions src/deploy/apphosting/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logLabeledBullet } from "../../utils";
import { Context } from "./args";
import * as util from "./util";
import * as experiments from "../../experiments";
import { logger } from "../../logger";

/**
* Uploads App Hosting source code or local build output to Google Cloud Storage.
Expand Down Expand Up @@ -70,50 +71,67 @@ export default async function (context: Context, options: Options): Promise<void
);

// Zip and upload code to GCS bucket.
await Promise.all(
Object.values(context.backendConfigs).map(async (cfg) => {
const rootDir = options.projectRoot ?? process.cwd();
let builtAppDir;
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}`);
try {
await Promise.all(
Object.values(context.backendConfigs).map(async (cfg) => {
const rootDir = options.projectRoot ?? process.cwd();
let builtAppDir;
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 = isLocalBuild
? await util.createLocalBuildTarArchive(cfg, rootDir, builtAppDir)
: await util.createSourceDeployArchive(cfg, rootDir);
const zippedSourcePath = isLocalBuild
? await util.createLocalBuildTarArchive(cfg, rootDir, builtAppDir)
: await util.createSourceDeployArchive(cfg, rootDir);

logLabeledBullet(
"apphosting",
`Zipped ${isLocalBuild ? "built app" : "source"} for backend ${cfg.backendId}`,
);
logLabeledBullet(
"apphosting",
`Zipped ${isLocalBuild ? "built app" : "source"} for backend ${cfg.backendId}`,
);

const backendLocation = context.backendLocations[cfg.backendId];
if (!backendLocation) {
throw new FirebaseError(
`Failed to find location for backend ${cfg.backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`,
const backendLocation = context.backendLocations[cfg.backendId];
if (!backendLocation) {
throw new FirebaseError(
`Failed to find location for backend ${cfg.backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`,
);
}
logLabeledBullet(
"apphosting",
`Uploading ${isLocalBuild ? "built app" : "source"} for backend ${cfg.backendId}...`,
);
const bucketName = bucketsPerLocation[backendLocation]!;
const { bucket, object } = await gcs.uploadObject(
{
file: zippedSourcePath,
stream: fs.createReadStream(zippedSourcePath),
},
bucketName,
isLocalBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP,
);
logLabeledBullet("apphosting", `Uploaded at gs://${bucket}/${object}`);
context.backendStorageUris[cfg.backendId] =
`gs://${bucketName}/${path.basename(zippedSourcePath)}`;
}),
);
} finally {
// Clean up local build directories
const rootDir = options.projectRoot || process.cwd();
for (const cfg of Object.values(context.backendConfigs)) {
if (cfg.localBuild) {
const localBuildDir = path.join(rootDir, "local_build");
Comment thread
falahat marked this conversation as resolved.
if (fs.existsSync(localBuildDir)) {
try {
fs.rmSync(localBuildDir, { recursive: true, force: true });
} catch (err) {
logger.debug(`Failed to clean up local build directory ${localBuildDir}: ${err}`);
}
}
}
logLabeledBullet(
"apphosting",
`Uploading ${isLocalBuild ? "built app" : "source"} for backend ${cfg.backendId}...`,
);
const bucketName = bucketsPerLocation[backendLocation]!;
const { bucket, object } = await gcs.uploadObject(
{
file: zippedSourcePath,
stream: fs.createReadStream(zippedSourcePath),
},
bucketName,
isLocalBuild ? gcs.ContentType.TAR : gcs.ContentType.ZIP,
);
logLabeledBullet("apphosting", `Uploaded at gs://${bucket}/${object}`);
context.backendStorageUris[cfg.backendId] =
`gs://${bucketName}/${path.basename(zippedSourcePath)}`;
}),
);
}
}
}
43 changes: 43 additions & 0 deletions src/deploy/apphosting/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import * as apphostingUtils from "../../apphosting/utils";
import { AppHostingYamlConfig, EnvMap } from "../../apphosting/yaml";
import { Options } from "../../options";
import { AppHostingSingle } from "../../firebaseConfig";
import * as fs from "fs";
import * as fsAsync from "../../fsAsync";

const BASE_OPTS = {
cwd: "/",
Expand Down Expand Up @@ -88,6 +90,12 @@ describe("apphosting", () => {
addServiceAccountToRolesStub = sinon
.stub(resourceManager, "addServiceAccountToRoles")
.resolves();

sinon.stub(fs, "existsSync").returns(false);
sinon.stub(fs, "mkdirSync").returns(undefined);
sinon.stub(fs, "rmSync").returns(undefined);
sinon.stub(fs, "copyFileSync").returns(undefined);
sinon.stub(fsAsync, "readdirRecursive").resolves([]);
});

afterEach(() => {
Expand Down Expand Up @@ -305,6 +313,41 @@ describe("apphosting", () => {
}
});

it("should fail if localBuild is specified and local build directory already exists", async () => {
const optsWithLocalBuild = {
...opts,
config: new Config({
apphosting: {
backendId: "foo",
rootDir: "/",
ignore: [],
localBuild: true,
},
}),
};
const context = initializeContext();

(fs.existsSync as sinon.SinonStub).callsFake((pathLike: fs.PathLike) => {
if (typeof pathLike === "string" && pathLike.endsWith("local_build")) {
return true;
}
return false;
});

listBackendsStub.onFirstCall().resolves({
backends: [
{
name: "projects/my-project/locations/us-central1/backends/foo",
},
],
});

await expect(prepare(context, optsWithLocalBuild)).to.be.rejectedWith(
FirebaseError,
"The local build directory",
);
});

it("links to existing backend if it already exists", async () => {
const context = initializeContext();
listBackendsStub.onFirstCall().resolves({
Expand Down
53 changes: 52 additions & 1 deletion src/deploy/apphosting/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as fs from "fs";
import * as path from "path";
import * as fsAsync from "../../fsAsync";
import { resolveIgnorePatterns } from "./util";
import {
doSetupSourceDeploy,
ensureAppHostingComputeServiceAccount,
Expand Down Expand Up @@ -192,10 +195,15 @@ export default async function (context: Context, options: Options): Promise<void
);
await injectAutoInitEnvVars(cfg, backends, buildEnv, runtimeEnv);

const rootDir = options.projectRoot || process.cwd();
const localBuildDir = path.join(rootDir, "local_build");
Comment thread
falahat marked this conversation as resolved.

try {
await prepareLocalBuildDirectory(rootDir, localBuildDir, cfg);

const { outputFiles, annotations, buildConfig } = await localBuild(
projectId,
options.projectRoot || "./",
localBuildDir,
"nextjs",
buildEnv[cfg.backendId] || {},
{
Expand Down Expand Up @@ -377,3 +385,46 @@ async function ensureAppHostingServiceAgentRoles(
);
}
}

/**
* Prepares the directory for local builds by copying non-ignored files.
*/
async function prepareLocalBuildDirectory(
rootDir: string,
localBuildDir: string,
cfg: AppHostingSingle,
): Promise<void> {
// Resolve ignores for local builds, skipping default node_modules ignore
const ignore = resolveIgnorePatterns(cfg, rootDir, /* skipDefaultNodeModules= */ true);
ignore.push("local_build"); // Always ignore the build directory itself

// Warn if node_modules is explicitly ignored
if (cfg.ignore?.includes("node_modules")) {
logLabeledWarning(
"apphosting",
`You have included 'node_modules' in your ignore list for local builds. This might cause the build to fail if dependencies are missing in the build directory.`,
);
}

// Check if local_build dir already exists
if (fs.existsSync(localBuildDir)) {
throw new FirebaseError(
`The local build directory '${localBuildDir}' already exists. Please delete it and try again.`,
);
}
fs.mkdirSync(localBuildDir, { recursive: true });

// Copy files respecting ignores
const filesToCopy = await fsAsync.readdirRecursive({
path: rootDir,
ignore: ignore,
isGitIgnore: true,
});

for (const file of filesToCopy) {
const relativePath = path.relative(rootDir, file.name);
const destPath = path.join(localBuildDir, relativePath);
Comment thread
falahat marked this conversation as resolved.
Comment thread
falahat marked this conversation as resolved.
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(file.name, destPath);
}
}
86 changes: 86 additions & 0 deletions src/deploy/apphosting/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "path";
import * as tmp from "tmp";
import * as tar from "tar";
import * as util from "./util";
import { AppHostingSingle } from "../../firebaseConfig";

describe("util", () => {
let tmpDir: tmp.DirResult;
Expand Down Expand Up @@ -78,5 +79,90 @@ describe("util", () => {
expect(files).to.include("dist/index.js");
expect(files).to.not.include("apphosting.yaml");
});

it("should respect ignore patterns in config", async () => {
fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')");
fs.writeFileSync(path.join(distDir, "ignored.txt"), "ignore me");

const config = {
backendId: "test-backend",
rootDir: "",
ignore: ["**/ignored.txt"],
};

const tarballPath: string = await util.createLocalBuildTarArchive(
config,
rootDir,
path.relative(rootDir, distDir),
);

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("dist/ignored.txt");
});

it("should use default ignores when config.ignore is missing", async () => {
fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')");
const nodeModulesDir = path.join(distDir, "node_modules");
fs.mkdirSync(nodeModulesDir);
fs.writeFileSync(path.join(nodeModulesDir, "some-file.js"), "console.log('vendor')");

const configWithoutIgnore: AppHostingSingle = {
backendId: "test-backend",
rootDir: "",
ignore: undefined as unknown as string[],
};

const tarballPath: string = await util.createLocalBuildTarArchive(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
configWithoutIgnore,
rootDir,
path.relative(rootDir, distDir),
);

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("dist/node_modules/some-file.js");
});

it("should respect .gitignore patterns", async () => {
fs.writeFileSync(path.join(distDir, "index.js"), "console.log('hello')");
fs.writeFileSync(path.join(distDir, "gitignored.txt"), "ignore me");
fs.writeFileSync(path.join(distDir, ".gitignore"), "gitignored.txt");

const config = {
backendId: "test-backend",
rootDir: "",
ignore: [],
};

const tarballPath: string = await util.createLocalBuildTarArchive(
config,
rootDir,
path.relative(rootDir, distDir),
);

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("dist/gitignored.txt");
});
});
});
Loading
Loading