Skip to content

Commit b8e1736

Browse files
committed
Create a basic implementation of local builds that uses the new effective API fields. Also, this code keeps the source deploy path (existing feature, different from local builds) unaffected.
We create tar.gz files instead of zip files.
1 parent 199f11e commit b8e1736

File tree

6 files changed

+252
-17
lines changed

6 files changed

+252
-17
lines changed

src/deploy/apphosting/deploy.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import deploy from "./deploy";
88
import * as util from "./util";
99
import * as fs from "fs";
1010
import * as getProjectNumber from "../../getProjectNumber";
11+
import * as experiments from "../../experiments";
1112

1213
const BASE_OPTS = {
1314
cwd: "/",
@@ -51,8 +52,10 @@ describe("apphosting", () => {
5152
let upsertBucketStub: sinon.SinonStub;
5253
let uploadObjectStub: sinon.SinonStub;
5354
let createArchiveStub: sinon.SinonStub;
55+
let createTarArchiveStub: sinon.SinonStub;
5456
let createReadStreamStub: sinon.SinonStub;
5557
let getProjectNumberStub: sinon.SinonStub;
58+
let isEnabledStub: sinon.SinonStub;
5659

5760
beforeEach(() => {
5861
getProjectNumberStub = sinon
@@ -61,9 +64,13 @@ describe("apphosting", () => {
6164
upsertBucketStub = sinon.stub(gcs, "upsertBucket").throws("Unexpected upsertBucket call");
6265
uploadObjectStub = sinon.stub(gcs, "uploadObject").throws("Unexpected uploadObject call");
6366
createArchiveStub = sinon.stub(util, "createArchive").throws("Unexpected createArchive call");
67+
createTarArchiveStub = sinon
68+
.stub(util, "createTarArchive")
69+
.throws("Unexpected createTarArchive call");
6470
createReadStreamStub = sinon
6571
.stub(fs, "createReadStream")
6672
.throws("Unexpected createReadStream call");
73+
isEnabledStub = sinon.stub(experiments, "isEnabled").returns(false);
6774
});
6875

6976
afterEach(() => {
@@ -195,5 +202,31 @@ describe("apphosting", () => {
195202
`gs://${bucketName}/foo-local-build-1234.zip`,
196203
);
197204
});
205+
206+
it("uses createTarArchive for local builds when experiment is enabled", async () => {
207+
const context = initializeContext();
208+
// Remove the non-local build backend for this test for simplicity
209+
delete context.backendConfigs.foo;
210+
delete context.backendLocations.foo;
211+
const projectNumber = "000000000000";
212+
const location = "us-central1";
213+
const bucketName = `firebaseapphosting-sources-${projectNumber}-${location}`;
214+
215+
getProjectNumberStub.resolves(projectNumber);
216+
upsertBucketStub.resolves(bucketName);
217+
isEnabledStub.withArgs("apphostinglocalbuilds").returns(true);
218+
createTarArchiveStub.resolves("path/to/foo-local-build-1234.tar.gz");
219+
uploadObjectStub.resolves({
220+
bucket: bucketName,
221+
object: "foo-local-build-1234.tar.gz",
222+
});
223+
createReadStreamStub.returns("stream" as any);
224+
225+
await deploy(context, { ...opts, config: new Config({ apphosting: { backendId: "fooLocalBuild", localBuild: true } }) });
226+
227+
expect(createTarArchiveStub).to.be.calledOnce;
228+
expect(createArchiveStub).to.not.be.called;
229+
expect(context.backendStorageUris["fooLocalBuild"]).to.equal(`gs://${bucketName}/foo-local-build-1234.tar.gz`);
230+
});
198231
});
199232
});

src/deploy/apphosting/deploy.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { Options } from "../../options";
77
import { needProjectId } from "../../projectUtils";
88
import { logLabeledBullet } from "../../utils";
99
import { Context } from "./args";
10-
import { createArchive } from "./util";
10+
import { createArchive, createTarArchive } from "./util";
11+
import { isEnabled } from "../../experiments";
1112

1213
/**
1314
* Zips and uploads App Hosting source code to Google Cloud Storage in preparation for
@@ -71,10 +72,15 @@ export default async function (context: Context, options: Options): Promise<void
7172
throw new FirebaseError(`No local build dir found for ${cfg.backendId}`);
7273
}
7374
}
74-
const zippedSourcePath = await createArchive(cfg, rootDir, builtAppDir);
75+
let zippedSourcePath;
76+
if (cfg.localBuild && isEnabled("apphostinglocalbuilds")) {
77+
zippedSourcePath = await createTarArchive(cfg, rootDir, builtAppDir);
78+
} else {
79+
zippedSourcePath = await createArchive(cfg, rootDir, builtAppDir);
80+
}
7581
logLabeledBullet(
7682
"apphosting",
77-
`Zipped ${cfg.localBuild ? "built app" : "source"} for backend ${cfg.backendId}`,
83+
`Archived ${cfg.localBuild ? "built app" : "source"} for backend ${cfg.backendId}`,
7884
);
7985

8086
const backendLocation = context.backendLocations[cfg.backendId];

src/deploy/apphosting/release.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import * as sinon from "sinon";
22
import * as rollout from "../../apphosting/rollout";
3+
import * as backend from "../../apphosting/backend";
34
import { Config } from "../../config";
45
import { RC } from "../../rc";
6+
import { needProjectId } from "../../projectUtils";
57
import { Context } from "./args";
68
import release from "./release";
79
import { expect } from "chai";
10+
import * as experiments from "../../experiments";
11+
import { isEnabled } from "../../experiments";
812

913
const BASE_OPTS = {
1014
cwd: "/",
@@ -18,7 +22,14 @@ const BASE_OPTS = {
1822
};
1923

2024
describe("apphosting", () => {
25+
let isEnabledStub: sinon.SinonStub;
2126
let orchestrateRolloutStub: sinon.SinonStub;
27+
let getBackendStub: sinon.SinonStub;
28+
29+
beforeEach(() => {
30+
isEnabledStub = sinon.stub(experiments, "isEnabled").returns(false);
31+
getBackendStub = sinon.stub(backend, "getBackend").resolves({ uri: "https://foo-us-central1.a.run.app" } as any);
32+
});
2233

2334
afterEach(() => {
2435
sinon.verifyAndRestore();
@@ -63,5 +74,107 @@ describe("apphosting", () => {
6374

6475
await expect(release(context, opts)).to.eventually.not.rejected;
6576
});
77+
78+
it("uses archive for standard source deployments", async () => {
79+
const context: Context = {
80+
backendConfigs: {
81+
foo: {
82+
backendId: "foo",
83+
rootDir: "/",
84+
ignore: [],
85+
},
86+
},
87+
backendLocations: { foo: "us-central1" },
88+
backendStorageUris: {
89+
foo: "gs://bucket/source.zip",
90+
},
91+
backendLocalBuilds: {},
92+
};
93+
94+
orchestrateRolloutStub = sinon.stub(rollout, "orchestrateRollout").resolves();
95+
96+
await release(context, opts);
97+
98+
expect(orchestrateRolloutStub).to.be.calledOnceWith(sinon.match({
99+
buildInput: {
100+
source: {
101+
archive: {
102+
userStorageUri: "gs://bucket/source.zip",
103+
rootDirectory: "/",
104+
},
105+
},
106+
},
107+
}));
108+
});
109+
110+
it("uses locallyBuilt for local builds when experiment is enabled", async () => {
111+
isEnabledStub.withArgs("apphostinglocalbuilds").returns(true);
112+
const context: Context = {
113+
backendConfigs: {
114+
foo: {
115+
backendId: "foo",
116+
rootDir: "/",
117+
ignore: [],
118+
localBuild: true,
119+
},
120+
},
121+
backendLocations: { foo: "us-central1" },
122+
backendStorageUris: {
123+
foo: "gs://bucket/built.tar.gz",
124+
},
125+
backendLocalBuilds: {
126+
foo: {
127+
buildDir: "dist",
128+
buildConfig: {},
129+
annotations: {},
130+
},
131+
},
132+
};
133+
134+
orchestrateRolloutStub = sinon.stub(rollout, "orchestrateRollout").resolves();
135+
136+
await release(context, opts);
137+
138+
expect(orchestrateRolloutStub).to.be.calledOnceWith(sinon.match({
139+
buildInput: {
140+
source: {
141+
locallyBuilt: {
142+
userStorageUri: "gs://bucket/built.tar.gz",
143+
},
144+
},
145+
},
146+
}));
147+
});
148+
149+
it("skips local builds when experiment is disabled", async () => {
150+
isEnabledStub.withArgs("apphostinglocalbuilds").returns(false);
151+
const context: Context = {
152+
backendConfigs: {
153+
foo: {
154+
backendId: "foo",
155+
rootDir: "/",
156+
ignore: [],
157+
localBuild: true,
158+
},
159+
},
160+
backendLocations: { foo: "us-central1" },
161+
backendStorageUris: {
162+
foo: "gs://bucket/built.tar.gz",
163+
},
164+
backendLocalBuilds: {
165+
foo: {
166+
buildDir: "dist",
167+
buildConfig: {},
168+
annotations: {},
169+
},
170+
},
171+
};
172+
173+
orchestrateRolloutStub = sinon.stub(rollout, "orchestrateRollout").resolves();
174+
175+
await release(context, opts);
176+
177+
expect(orchestrateRolloutStub).to.not.be.called;
178+
});
66179
});
67180
});

src/deploy/apphosting/release.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../../utils";
1313
import { Context } from "./args";
1414
import { FirebaseError } from "../../error";
15+
import { isEnabled } from "../../experiments";
1516

1617
/**
1718
* Orchestrates rollouts for the backends targeted for deployment.
@@ -30,7 +31,9 @@ export default async function (context: Context, options: Options): Promise<void
3031
backendIds = backendIds.filter((id) => !missingBackends.includes(id));
3132
}
3233

33-
const localBuildBackends = backendIds.filter((id) => context.backendLocalBuilds[id]);
34+
const localBuildBackends = backendIds.filter(
35+
(id) => context.backendLocalBuilds[id] && !isEnabled("apphostinglocalbuilds"),
36+
);
3437
if (localBuildBackends.length > 0) {
3538
logLabeledWarning(
3639
"apphosting",
@@ -44,24 +47,34 @@ export default async function (context: Context, options: Options): Promise<void
4447
}
4548

4649
const projectId = needProjectId(options);
47-
const rollouts = backendIds.map((backendId) =>
48-
// TODO(9114): Add run_command
49-
// TODO(914): Set the buildConfig.
50-
// TODO(914): Set locallyBuiltSource.
51-
orchestrateRollout({
50+
const rollouts = backendIds.map((backendId) => {
51+
const cfg = context.backendConfigs[backendId];
52+
const isLocalBuild = cfg.localBuild && isEnabled("apphostinglocalbuilds");
53+
let source: any;
54+
if (isLocalBuild) {
55+
source = {
56+
locallyBuilt: {
57+
userStorageUri: context.backendStorageUris[backendId],
58+
},
59+
};
60+
} else {
61+
source = {
62+
archive: {
63+
userStorageUri: context.backendStorageUris[backendId],
64+
rootDirectory: cfg.rootDir,
65+
},
66+
};
67+
}
68+
69+
return orchestrateRollout({
5270
projectId,
5371
backendId,
5472
location: context.backendLocations[backendId],
5573
buildInput: {
56-
source: {
57-
archive: {
58-
userStorageUri: context.backendStorageUris[backendId],
59-
rootDirectory: context.backendConfigs[backendId].rootDir,
60-
},
61-
},
74+
source,
6275
},
63-
}),
64-
);
76+
});
77+
});
6578

6679
logLabeledBullet(
6780
"apphosting",

src/deploy/apphosting/util.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,60 @@ export async function createArchive(
5151
return tmpFile;
5252
}
5353

54+
/**
55+
* Locates the source code for a backend and creates a tar archive to eventually upload to GCS.
56+
*/
57+
export async function createTarArchive(
58+
config: AppHostingSingle,
59+
rootDir: string,
60+
targetSubDir?: string,
61+
): Promise<string> {
62+
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+
});
73+
74+
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);
79+
try {
80+
const files = await fsAsync.readdirRecursive({
81+
path: targetDir,
82+
ignore: ignore,
83+
isGitIgnore: true,
84+
});
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);
97+
}
98+
await pipeAsync(archive, fileStream);
99+
} catch (err: unknown) {
100+
throw new FirebaseError(
101+
"Could not read source directory. Remove links and shortcuts and try again.",
102+
{ original: err as Error, exit: 1 },
103+
);
104+
}
105+
return tmpFile;
106+
}
107+
54108
function parseGitIgnorePatterns(projectRoot: string, gitIgnorePath = ".gitignore"): string[] {
55109
const absoluteFilePath = path.resolve(projectRoot, gitIgnorePath);
56110
if (!fs.existsSync(absoluteFilePath)) {

src/gcp/apphosting.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ export interface BuildConfig {
118118
interface BuildSource {
119119
codebase?: CodebaseSource;
120120
archive?: ArchiveSource;
121+
locallyBuilt?: LocallyBuiltSource;
122+
}
123+
124+
export interface LocallyBuiltSource {
125+
userStorageUri: string;
126+
rootDirectory?: string;
127+
description?: string;
128+
runCommand?: string;
129+
runConfig?: RunConfig;
130+
env?: Env[];
131+
}
132+
133+
export interface RunConfig {
134+
cpu?: number;
135+
memoryMiB?: number;
121136
}
122137

123138
interface CodebaseSource {
@@ -140,6 +155,7 @@ interface ArchiveSource {
140155
// end oneof reference
141156
rootDirectory?: string;
142157
author?: SourceUserMetadata;
158+
/** @deprecated use Build.source.locallyBuilt instead */
143159
locallyBuiltSource?: boolean;
144160
}
145161

0 commit comments

Comments
 (0)