Skip to content

Commit be56dfc

Browse files
authored
Add localBuild and zip upload support into firebase deploy (#9193)
1 parent fb79f78 commit be56dfc

File tree

15 files changed

+603
-115
lines changed

15 files changed

+603
-115
lines changed

npm-shrinkwrap.json

Lines changed: 259 additions & 70 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@
102102
]
103103
},
104104
"dependencies": {
105+
"@apphosting/build": "^0.1.6",
106+
"@apphosting/common": "^0.0.8",
105107
"@electric-sql/pglite": "^0.3.3",
106108
"@electric-sql/pglite-tools": "^0.2.8",
107109
"@google-cloud/cloud-sql-connector": "^1.3.3",

schema/firebase-config.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,9 @@
11071107
},
11081108
"type": "array"
11091109
},
1110+
"localBuild": {
1111+
"type": "boolean"
1112+
},
11101113
"rootDir": {
11111114
"type": "string"
11121115
}
@@ -1134,6 +1137,9 @@
11341137
},
11351138
"type": "array"
11361139
},
1140+
"localBuild": {
1141+
"type": "boolean"
1142+
},
11371143
"rootDir": {
11381144
"type": "string"
11391145
}

src/apphosting/localbuilds.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as sinon from "sinon";
2+
import { expect } from "chai";
3+
import * as localBuildModule from "@apphosting/build";
4+
import { localBuild } from "./localbuilds";
5+
6+
describe("localBuild", () => {
7+
afterEach(() => {
8+
sinon.restore();
9+
});
10+
11+
it("returns the expected output", async () => {
12+
const bundleConfig = {
13+
version: "v1" as const,
14+
runConfig: {
15+
runCommand: "npm run build:prod",
16+
},
17+
metadata: {
18+
adapterPackageName: "@apphosting/angular-adapter",
19+
adapterVersion: "14.1",
20+
framework: "nextjs",
21+
},
22+
outputFiles: {
23+
serverApp: {
24+
include: ["./next/standalone"],
25+
},
26+
},
27+
};
28+
const expectedAnnotations = {
29+
adapterPackageName: "@apphosting/angular-adapter",
30+
adapterVersion: "14.1",
31+
framework: "nextjs",
32+
};
33+
const expectedOutputFiles = ["./next/standalone"];
34+
const expectedBuildConfig = {
35+
runCommand: "npm run build:prod",
36+
env: [],
37+
};
38+
const localApphostingBuildStub: sinon.SinonStub = sinon
39+
.stub(localBuildModule, "localBuild")
40+
.resolves(bundleConfig);
41+
const { outputFiles, annotations, buildConfig } = await localBuild("./", "nextjs");
42+
expect(annotations).to.deep.equal(expectedAnnotations);
43+
expect(buildConfig).to.deep.equal(expectedBuildConfig);
44+
expect(outputFiles).to.deep.equal(expectedOutputFiles);
45+
sinon.assert.calledWith(localApphostingBuildStub, "./", "nextjs");
46+
});
47+
});

src/apphosting/localbuilds.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BuildConfig, Env } from "../gcp/apphosting";
2+
import { localBuild as localAppHostingBuild } from "@apphosting/build";
3+
4+
/**
5+
* Triggers a local apphosting build.
6+
*/
7+
export async function localBuild(
8+
projectRoot: string,
9+
framework: string,
10+
): Promise<{
11+
outputFiles: string[];
12+
annotations: Record<string, string>;
13+
buildConfig: BuildConfig;
14+
}> {
15+
const apphostingBuildOutput = await localAppHostingBuild(projectRoot, framework);
16+
17+
const annotations: Record<string, string> = Object.fromEntries(
18+
Object.entries(apphostingBuildOutput.metadata).map(([key, value]) => [key, String(value)]),
19+
);
20+
21+
const env: Env[] | undefined = apphostingBuildOutput.runConfig.environmentVariables?.map(
22+
({ variable, value, availability }) => ({
23+
variable,
24+
value,
25+
availability,
26+
}),
27+
);
28+
29+
return {
30+
outputFiles: apphostingBuildOutput.outputFiles?.serverApp.include ?? [],
31+
annotations,
32+
buildConfig: {
33+
runCommand: apphostingBuildOutput.runConfig.runCommand,
34+
env: env ?? [],
35+
},
36+
};
37+
}

src/deploy/apphosting/args.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { AppHostingSingle } from "../../firebaseConfig";
2+
import { BuildConfig } from "../../gcp/apphosting";
3+
4+
export interface LocalBuild {
5+
buildConfig: BuildConfig;
6+
buildDir: string;
7+
annotations: Record<string, string>;
8+
}
29

310
export interface Context {
411
backendConfigs: Record<string, AppHostingSingle>;
512
backendLocations: Record<string, string>;
613
backendStorageUris: Record<string, string>;
14+
backendLocalBuilds: Record<string, LocalBuild>;
715
}

src/deploy/apphosting/deploy.spec.ts

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,22 @@ function initializeContext(): Context {
3030
rootDir: "/",
3131
ignore: [],
3232
},
33+
fooLocalBuild: {
34+
backendId: "fooLocalBuild",
35+
rootDir: "/",
36+
ignore: [],
37+
localBuild: true,
38+
},
3339
},
34-
backendLocations: { foo: "us-central1" },
40+
backendLocations: { foo: "us-central1", fooLocalBuild: "us-central1" },
3541
backendStorageUris: {},
42+
backendLocalBuilds: {
43+
fooLocalBuild: {
44+
buildDir: "./nextjs/standalone",
45+
buildConfig: {},
46+
annotations: {},
47+
},
48+
},
3649
};
3750
}
3851

@@ -59,17 +72,25 @@ describe("apphosting", () => {
5972
sinon.verifyAndRestore();
6073
});
6174

62-
describe("deploy", () => {
75+
describe("deploy local source", () => {
6376
const opts = {
6477
...BASE_OPTS,
6578
projectId: "my-project",
6679
only: "apphosting",
6780
config: new Config({
68-
apphosting: {
69-
backendId: "foo",
70-
rootDir: "/",
71-
ignore: [],
72-
},
81+
apphosting: [
82+
{
83+
backendId: "foo",
84+
rootDir: "/",
85+
ignore: [],
86+
},
87+
{
88+
backendId: "fooLocalBuild",
89+
rootDir: "/",
90+
ignore: [],
91+
localBuild: true,
92+
},
93+
],
7394
}),
7495
};
7596

@@ -78,18 +99,26 @@ describe("apphosting", () => {
7899
const projectNumber = "000000000000";
79100
const location = "us-central1";
80101
const bucketName = `firebaseapphosting-sources-${projectNumber}-${location}`;
81-
82102
getProjectNumberStub.resolves(projectNumber);
83103
upsertBucketStub.resolves(bucketName);
84-
createArchiveStub.resolves("path/to/foo-1234.zip");
85-
uploadObjectStub.resolves({
104+
createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip");
105+
createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip");
106+
107+
uploadObjectStub.onFirstCall().resolves({
86108
bucket: bucketName,
87109
object: "foo-1234",
88110
});
111+
uploadObjectStub.onSecondCall().resolves({
112+
bucket: bucketName,
113+
object: "foo-local-build-1234",
114+
});
115+
89116
createReadStreamStub.returns("stream" as any);
90117

91118
await deploy(context, opts);
92119

120+
// assert backend foo calls
121+
93122
expect(upsertBucketStub).to.be.calledWith({
94123
product: "apphosting",
95124
createMessage: `Creating Cloud Storage bucket in ${location} to store App Hosting source code uploads at ${bucketName}...`,
@@ -108,26 +137,65 @@ describe("apphosting", () => {
108137
},
109138
},
110139
});
140+
141+
// assert backend fooLocalBuild calls
142+
expect(upsertBucketStub).to.be.calledWith({
143+
product: "apphosting",
144+
createMessage:
145+
"Creating Cloud Storage bucket in us-central1 to store App Hosting source code uploads at firebaseapphosting-sources-000000000000-us-central1...",
146+
projectId: "my-project",
147+
req: {
148+
baseName: "firebaseapphosting-sources-000000000000-us-central1",
149+
purposeLabel: `apphosting-source-${location}`,
150+
location: "us-central1",
151+
lifecycle: {
152+
rule: [
153+
{
154+
action: { type: "Delete" },
155+
condition: { age: 30 },
156+
},
157+
],
158+
},
159+
},
160+
});
161+
expect(createArchiveStub).to.be.calledWithExactly(
162+
context.backendConfigs["fooLocalBuild"],
163+
process.cwd(),
164+
"./nextjs/standalone",
165+
);
166+
expect(uploadObjectStub).to.be.calledWithMatch(
167+
sinon.match.any,
168+
"firebaseapphosting-sources-000000000000-us-central1",
169+
);
111170
});
112171

113172
it("correctly creates and sets storage URIs", async () => {
114173
const context = initializeContext();
115174
const projectNumber = "000000000000";
116175
const location = "us-central1";
117176
const bucketName = `firebaseapphosting-sources-${projectNumber}-${location}`;
118-
119177
getProjectNumberStub.resolves(projectNumber);
120178
upsertBucketStub.resolves(bucketName);
121-
createArchiveStub.resolves("path/to/foo-1234.zip");
122-
uploadObjectStub.resolves({
179+
createArchiveStub.onFirstCall().resolves("path/to/foo-1234.zip");
180+
createArchiveStub.onSecondCall().resolves("path/to/foo-local-build-1234.zip");
181+
182+
uploadObjectStub.onFirstCall().resolves({
123183
bucket: bucketName,
124184
object: "foo-1234",
125185
});
186+
187+
uploadObjectStub.onSecondCall().resolves({
188+
bucket: bucketName,
189+
object: "foo-local-build-1234",
190+
});
126191
createReadStreamStub.returns("stream" as any);
127192

128193
await deploy(context, opts);
129194

130195
expect(context.backendStorageUris["foo"]).to.equal(`gs://${bucketName}/foo-1234.zip`);
196+
expect(context.backendStorageUris["fooLocalBuild"]).to.equal(
197+
`gs://${bucketName}/foo-local-build-1234.zip`,
198+
);
131199
});
132200
});
133201
});

src/deploy/apphosting/deploy.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ export default async function (context: Context, options: Options): Promise<void
2626
// Ensure that a bucket exists in each region that a backend is or will be deployed to
2727
const bucketsPerLocation: Record<string, string> = {};
2828
await Promise.all(
29-
Object.values(context.backendLocations).map(async (loc) => {
29+
Object.entries(context.backendLocations).map(async ([backendId, loc]) => {
30+
const cfg = context.backendConfigs[backendId];
31+
if (!cfg) {
32+
throw new FirebaseError(
33+
`Failed to find config for backend ${backendId}. Please contact support with the contents of your firebase-debug.log to report your issue.`,
34+
);
35+
}
3036
const baseName = `firebaseapphosting-sources-${options.projectNumber}-${loc.toLowerCase()}`;
3137
const resolvedName = await gcs.upsertBucket({
3238
product: "apphosting",
@@ -54,10 +60,23 @@ export default async function (context: Context, options: Options): Promise<void
5460
}),
5561
);
5662

63+
// Zip and upload code to GCS bucket.
5764
await Promise.all(
5865
Object.values(context.backendConfigs).map(async (cfg) => {
59-
const projectSourcePath = options.projectRoot ? options.projectRoot : process.cwd();
60-
const zippedSourcePath = await createArchive(cfg, projectSourcePath);
66+
const rootDir = options.projectRoot ?? process.cwd();
67+
let builtAppDir;
68+
if (cfg.localBuild) {
69+
builtAppDir = context.backendLocalBuilds[cfg.backendId].buildDir;
70+
if (!builtAppDir) {
71+
throw new FirebaseError(`No local build dir found for ${cfg.backendId}`);
72+
}
73+
}
74+
const zippedSourcePath = await createArchive(cfg, rootDir, builtAppDir);
75+
logLabeledBullet(
76+
"apphosting",
77+
`Zipped ${cfg.localBuild ? "built app" : "source"} for backend ${cfg.backendId}`,
78+
);
79+
6180
const backendLocation = context.backendLocations[cfg.backendId];
6281
if (!backendLocation) {
6382
throw new FirebaseError(
@@ -66,7 +85,7 @@ export default async function (context: Context, options: Options): Promise<void
6685
}
6786
logLabeledBullet(
6887
"apphosting",
69-
`Uploading source code at ${projectSourcePath} for backend ${cfg.backendId}...`,
88+
`Uploading ${cfg.localBuild ? "built app" : "source"} for backend ${cfg.backendId}...`,
7089
);
7190
const bucketName = bucketsPerLocation[backendLocation]!;
7291
const { bucket, object } = await gcs.uploadObject(
@@ -76,7 +95,7 @@ export default async function (context: Context, options: Options): Promise<void
7695
},
7796
bucketName,
7897
);
79-
logLabeledBullet("apphosting", `Source code uploaded at gs://${bucket}/${object}`);
98+
logLabeledBullet("apphosting", `Uploaded at gs://${bucket}/${object}`);
8099
context.backendStorageUris[cfg.backendId] =
81100
`gs://${bucketName}/${path.basename(zippedSourcePath)}`;
82101
}),

0 commit comments

Comments
 (0)