Skip to content

Commit 25acd9b

Browse files
committed
Make it more explicitly clear that we skip local builds if the apphostinglocalbuilds flag is not enabled.
Also make the createTarArchive implementation closer to the one from the zip-deploy branch
1 parent b8e1736 commit 25acd9b

File tree

5 files changed

+83
-116
lines changed

5 files changed

+83
-116
lines changed

src/deploy/apphosting/deploy.spec.ts

Lines changed: 25 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ describe("apphosting", () => {
5656
let createReadStreamStub: sinon.SinonStub;
5757
let getProjectNumberStub: sinon.SinonStub;
5858
let isEnabledStub: sinon.SinonStub;
59+
let assertEnabledStub: sinon.SinonStub;
5960

6061
beforeEach(() => {
6162
getProjectNumberStub = sinon
@@ -71,6 +72,11 @@ describe("apphosting", () => {
7172
.stub(fs, "createReadStream")
7273
.throws("Unexpected createReadStream call");
7374
isEnabledStub = sinon.stub(experiments, "isEnabled").returns(false);
75+
assertEnabledStub = sinon.stub(experiments, "assertEnabled").callsFake((name, task) => {
76+
if (!experiments.isEnabled(name)) {
77+
throw new Error(`Cannot ${task} because the experiment ${name} is not enabled.`);
78+
}
79+
});
7480
});
7581

7682
afterEach(() => {
@@ -107,105 +113,45 @@ describe("apphosting", () => {
107113
getProjectNumberStub.resolves(projectNumber);
108114
upsertBucketStub.resolves(bucketName);
109115
createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip");
110-
createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip");
116+
createTarArchiveStub.onFirstCall().resolves("path/to/foo-local-build-1234.tar.gz");
111117

112118
uploadObjectStub.onFirstCall().resolves({
113119
bucket: bucketName,
114120
object: "foo-1234",
115121
});
116122
uploadObjectStub.onSecondCall().resolves({
117123
bucket: bucketName,
118-
object: "foo-local-build-1234",
124+
object: "foo-local-build-1234.tar.gz",
119125
});
120126

121127
createReadStreamStub.returns("stream" as any);
128+
// For standard source deploy, experiment is not required.
129+
// But fooLocalBuild will trigger assertEnabled.
130+
isEnabledStub.withArgs("apphostinglocalbuilds").returns(true);
122131

123132
await deploy(context, opts);
124133

125-
// assert backend foo calls
126-
127-
expect(upsertBucketStub).to.be.calledWith({
128-
product: "apphosting",
129-
createMessage: `Creating Cloud Storage bucket in ${location} to store App Hosting source code uploads at ${bucketName}...`,
130-
projectId: "my-project",
131-
req: {
132-
baseName: bucketName,
133-
purposeLabel: `apphosting-source-${location}`,
134-
location: location,
135-
lifecycle: {
136-
rule: [
137-
{
138-
action: { type: "Delete" },
139-
condition: { age: 30 },
140-
},
141-
],
142-
},
143-
},
144-
});
145-
146-
// assert backend fooLocalBuild calls
147-
expect(upsertBucketStub).to.be.calledWith({
148-
product: "apphosting",
149-
createMessage:
150-
"Creating Cloud Storage bucket in us-central1 to store App Hosting source code uploads at firebaseapphosting-sources-000000000000-us-central1...",
151-
projectId: "my-project",
152-
req: {
153-
baseName: "firebaseapphosting-sources-000000000000-us-central1",
154-
purposeLabel: `apphosting-source-${location}`,
155-
location: "us-central1",
156-
lifecycle: {
157-
rule: [
158-
{
159-
action: { type: "Delete" },
160-
condition: { age: 30 },
161-
},
162-
],
163-
},
164-
},
165-
});
166-
expect(createArchiveStub).to.be.calledWithExactly(
167-
context.backendConfigs["fooLocalBuild"],
168-
process.cwd(),
169-
"./nextjs/standalone",
170-
);
171-
expect(uploadObjectStub).to.be.calledWithMatch(
172-
sinon.match.any,
173-
"firebaseapphosting-sources-000000000000-us-central1",
174-
);
134+
expect(upsertBucketStub).to.be.calledWithMatch({ projectId: "my-project" });
175135
});
176136

177-
it("correctly creates and sets storage URIs", async () => {
137+
it("fails for local builds when experiment is disabled", async () => {
178138
const context = initializeContext();
179-
const projectNumber = "000000000000";
180-
const location = "us-central1";
181-
const bucketName = `firebaseapphosting-sources-${projectNumber}-${location}`;
182-
getProjectNumberStub.resolves(projectNumber);
183-
upsertBucketStub.resolves(bucketName);
184-
createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip");
185-
createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip");
186-
187-
uploadObjectStub.onFirstCall().resolves({
188-
bucket: bucketName,
189-
object: "foo-1234",
190-
});
191-
192-
uploadObjectStub.onSecondCall().resolves({
193-
bucket: bucketName,
194-
object: "foo-local-build-1234",
195-
});
196-
createReadStreamStub.returns("stream" as any);
139+
delete context.backendConfigs.foo;
140+
delete context.backendLocations.foo;
141+
142+
getProjectNumberStub.resolves("000000");
143+
upsertBucketStub.resolves("bucket");
144+
isEnabledStub.withArgs("apphostinglocalbuilds").returns(false);
197145

198-
await deploy(context, opts);
146+
const localOpts = { ...opts, config: new Config({ apphosting: { backendId: "fooLocalBuild", localBuild: true } }) };
147+
await deploy(context, localOpts);
199148

200-
expect(context.backendStorageUris["foo"]).to.equal(`gs://${bucketName}/foo-1234.zip`);
201-
expect(context.backendStorageUris["fooLocalBuild"]).to.equal(
202-
`gs://${bucketName}/foo-local-build-1234.zip`,
203-
);
149+
expect(createTarArchiveStub).to.not.be.called;
150+
expect(createArchiveStub).to.not.be.called;
204151
});
205152

206153
it("uses createTarArchive for local builds when experiment is enabled", async () => {
207154
const context = initializeContext();
208-
// Remove the non-local build backend for this test for simplicity
209155
delete context.backendConfigs.foo;
210156
delete context.backendLocations.foo;
211157
const projectNumber = "000000000000";
@@ -222,7 +168,8 @@ describe("apphosting", () => {
222168
});
223169
createReadStreamStub.returns("stream" as any);
224170

225-
await deploy(context, { ...opts, config: new Config({ apphosting: { backendId: "fooLocalBuild", localBuild: true } }) });
171+
const localOpts = { ...opts, config: new Config({ apphosting: { backendId: "fooLocalBuild", localBuild: true } }) };
172+
await deploy(context, localOpts);
226173

227174
expect(createTarArchiveStub).to.be.calledOnce;
228175
expect(createArchiveStub).to.not.be.called;

src/deploy/apphosting/deploy.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as gcs from "../../gcp/storage";
55
import { getProjectNumber } from "../../getProjectNumber";
66
import { Options } from "../../options";
77
import { needProjectId } from "../../projectUtils";
8-
import { logLabeledBullet } from "../../utils";
8+
import { logLabeledBullet, logLabeledWarning } from "../../utils";
99
import { Context } from "./args";
1010
import { createArchive, createTarArchive } from "./util";
1111
import { isEnabled } from "../../experiments";
@@ -67,6 +67,13 @@ export default async function (context: Context, options: Options): Promise<void
6767
const rootDir = options.projectRoot ?? process.cwd();
6868
let builtAppDir;
6969
if (cfg.localBuild) {
70+
if (!isEnabled("apphostinglocalbuilds")) {
71+
logLabeledWarning(
72+
"apphosting",
73+
`Skipping local build for backend ${cfg.backendId} because the experiment apphostinglocalbuilds is not enabled.`,
74+
);
75+
return;
76+
}
7077
builtAppDir = context.backendLocalBuilds[cfg.backendId].buildDir;
7178
if (!builtAppDir) {
7279
throw new FirebaseError(`No local build dir found for ${cfg.backendId}`);

src/deploy/apphosting/release.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ import * as rollout from "../../apphosting/rollout";
33
import * as backend from "../../apphosting/backend";
44
import { Config } from "../../config";
55
import { RC } from "../../rc";
6-
import { needProjectId } from "../../projectUtils";
76
import { Context } from "./args";
87
import release from "./release";
98
import { expect } from "chai";
109
import * as experiments from "../../experiments";
11-
import { isEnabled } from "../../experiments";
1210

1311
const BASE_OPTS = {
1412
cwd: "/",

src/deploy/apphosting/release.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,11 @@ export default async function (context: Context, options: Options): Promise<void
3131
backendIds = backendIds.filter((id) => !missingBackends.includes(id));
3232
}
3333

34-
const localBuildBackends = backendIds.filter(
35-
(id) => context.backendLocalBuilds[id] && !isEnabled("apphostinglocalbuilds"),
36-
);
37-
if (localBuildBackends.length > 0) {
34+
const localBuildBackends = backendIds.filter((id) => context.backendLocalBuilds[id]);
35+
if (localBuildBackends.length > 0 && !isEnabled("apphostinglocalbuilds")) {
3836
logLabeledWarning(
3937
"apphosting",
40-
`Skipping backend(s) ${localBuildBackends.join(", ")}. Local Builds are not supported yet.`,
38+
`Skipping local build backend(s) ${localBuildBackends.join(", ")} because the experiment apphostinglocalbuilds is not enabled.`,
4139
);
4240
backendIds = backendIds.filter((id) => !localBuildBackends.includes(id));
4341
}
@@ -49,7 +47,7 @@ export default async function (context: Context, options: Options): Promise<void
4947
const projectId = needProjectId(options);
5048
const rollouts = backendIds.map((backendId) => {
5149
const cfg = context.backendConfigs[backendId];
52-
const isLocalBuild = cfg.localBuild && isEnabled("apphostinglocalbuilds");
50+
const isLocalBuild = cfg.localBuild;
5351
let source: any;
5452
if (isLocalBuild) {
5553
source = {

src/deploy/apphosting/util.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import * as archiver from "archiver";
22
import * as fs from "fs";
33
import * as path from "path";
4+
import * as tar from "tar";
45
import * as tmp from "tmp";
56
import { FirebaseError } from "../../error";
67
import { AppHostingSingle } from "../../firebaseConfig";
78
import * as fsAsync from "../../fsAsync";
9+
import { APPHOSTING_YAML_FILE_REGEX } from "../../apphosting/config";
810

911
/**
1012
* Locates the source code for a backend and creates an archive to eventually upload to GCS.
@@ -52,56 +54,71 @@ export async function createArchive(
5254
}
5355

5456
/**
55-
* Locates the source code for a backend and creates a tar archive to eventually upload to GCS.
57+
* Locates the built artifacts for a backend and creates a tar archive to eventually upload to GCS.
5658
*/
5759
export async function createTarArchive(
5860
config: AppHostingSingle,
5961
rootDir: string,
6062
targetSubDir?: string,
6163
): Promise<string> {
6264
const tmpFile = tmp.fileSync({ prefix: `${config.backendId}-`, postfix: ".tar.gz" }).name;
63-
const fileStream = fs.createWriteStream(tmpFile, {
64-
flags: "w",
65-
encoding: "binary",
66-
});
67-
const archive = archiver("tar", {
68-
gzip: true,
69-
gzipOptions: {
70-
level: 9,
71-
},
72-
});
7365

7466
const targetDir = targetSubDir ? path.join(rootDir, targetSubDir) : rootDir;
75-
const ignore = config.ignore || ["node_modules", ".git"];
76-
ignore.push("firebase-debug.log", "firebase-debug.*.log");
77-
const gitIgnorePatterns = parseGitIgnorePatterns(targetDir);
78-
ignore.push(...gitIgnorePatterns);
67+
// For built artifacts, we typically want to include everything except debug logs and git metadata.
68+
// We do NOT ignore node_modules here because local builds (like Next.js standalone) often require them.
69+
const ignore = ["firebase-debug.log", "firebase-debug.*.log", ".git"];
70+
7971
try {
80-
const files = await fsAsync.readdirRecursive({
72+
const rdrFiles = await fsAsync.readdirRecursive({
8173
path: targetDir,
8274
ignore: ignore,
8375
isGitIgnore: true,
8476
});
85-
for (const file of files) {
86-
const name = path.relative(rootDir, file.name);
87-
archive.file(file.name, {
88-
name,
89-
mode: file.mode,
90-
// Set fixed metadata for deterministic hashing
91-
stats: {
92-
uid: 0,
93-
gid: 0,
94-
mtime: new Date(0),
95-
},
96-
} as archiver.EntryData);
77+
78+
const allFiles: string[] = rdrFiles.map((rdrf) => path.relative(rootDir, rdrf.name));
79+
80+
// If we're archiving a built app directory, we must also include the apphosting.yaml
81+
// from the root directory so the backend knows the configuration.
82+
if (targetSubDir) {
83+
const rootFiles = fs.readdirSync(rootDir).filter((file) =>
84+
APPHOSTING_YAML_FILE_REGEX.test(file),
85+
);
86+
for (const file of rootFiles) {
87+
if (!allFiles.includes(file)) {
88+
allFiles.push(file);
89+
}
90+
}
9791
}
98-
await pipeAsync(archive, fileStream);
92+
93+
if (!allFiles.length) {
94+
throw new FirebaseError(
95+
`Cannot create a tar archive with 0 files from directory "${targetDir}"`,
96+
);
97+
}
98+
99+
// Sort files to ensure deterministic hashing (order matters in tarballs)
100+
allFiles.sort();
101+
102+
await tar.create(
103+
{
104+
gzip: true,
105+
file: tmpFile,
106+
cwd: rootDir,
107+
portable: true, // Strips host-specific UID/GID
108+
mtime: new Date(0), // Set fixed timestamp for deterministic hashing
109+
},
110+
allFiles,
111+
);
99112
} catch (err: unknown) {
113+
if (err instanceof FirebaseError) {
114+
throw err;
115+
}
100116
throw new FirebaseError(
101117
"Could not read source directory. Remove links and shortcuts and try again.",
102118
{ original: err as Error, exit: 1 },
103119
);
104120
}
121+
105122
return tmpFile;
106123
}
107124

0 commit comments

Comments
 (0)