Skip to content

Add support for deploying local source to App Hosting #8516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
May 15, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
944ab1f
deploy from local source
blidd-google May 7, 2025
a0f00ff
minor fixes & use fuzzy search for backends
blidd-google May 9, 2025
1588252
add storage.objectViewer role on compute SA
blidd-google May 9, 2025
f7be953
respond to first round of feedback
blidd-google May 12, 2025
b5519ac
second round of feedback
blidd-google May 12, 2025
e344fae
Fix VSCode import error (#8546)
joehan May 12, 2025
68caf08
Add GCP API client functions to support App Hosting deploy from sourc…
blidd-google May 12, 2025
588b11f
fix(mcp): Make all input schemas valid for Gemini. (#8537)
mbleigh May 12, 2025
8cc675f
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 12, 2025
219b901
log source code upload path
blidd-google May 12, 2025
cbe7ffe
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 12, 2025
908eb34
simplify gitignore parsing
blidd-google May 12, 2025
d771ce8
fix bug not deploying with no --only flag
blidd-google May 12, 2025
affa033
support negation rules in .gitignore
blidd-google May 13, 2025
bc0cfbf
clean up readdirrecursive logic
blidd-google May 13, 2025
41ca746
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 13, 2025
f0eefcc
polish & cleanup
blidd-google May 13, 2025
f84ff77
refactor to use 'ignore' pkg to apply .gitignore rules
blidd-google May 14, 2025
9fe4133
fix gitignore unit test
blidd-google May 14, 2025
d019b57
rework prepare flow and polish copy
blidd-google May 15, 2025
93db1a6
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 15, 2025
e72954e
fix merge issues
blidd-google May 15, 2025
57b9d4c
more polish & cleanup
blidd-google May 15, 2025
23c3e2f
Merge branch 'master' into bl-fah-deploy-from-source
Yuangwang May 15, 2025
d6e37bc
skip not found backends with force
blidd-google May 15, 2025
611dc97
return empty array if git ignore doesn't exist
blidd-google May 15, 2025
1526d3d
return empty array if no gitignore found
blidd-google May 15, 2025
a999b7a
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 15, 2025
09bcac9
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 15, 2025
df7042d
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 15, 2025
f8cce50
Merge branch 'master' into bl-fah-deploy-from-source
blidd-google May 15, 2025
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
51 changes: 50 additions & 1 deletion src/apphosting/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
secretManagerOrigin,
} from "../api";
import { Backend, BackendOutputOnlyFields, API_VERSION } from "../gcp/apphosting";
import { addServiceAccountToRoles } from "../gcp/resourceManager";
import { addServiceAccountToRoles, getIamPolicy } from "../gcp/resourceManager";
import * as iam from "../gcp/iam";
import { FirebaseError, getErrStatus, getError } from "../error";
import { input, confirm, select, checkbox, search, Choice } from "../prompt";
Expand Down Expand Up @@ -49,7 +49,7 @@
// SSL.
const maybeNodeError = err as { cause: { code: string }; code: string };
if (
/HANDSHAKE_FAILURE/.test(maybeNodeError?.cause?.code) ||

Check warning on line 52 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Use `String#includes()` method with a string instead
"EPROTO" === maybeNodeError?.code
) {
return false;
Expand Down Expand Up @@ -179,6 +179,30 @@
logSuccess(`Your backend is now deployed at:\n\thttps://${backend.uri}`);
}

/**
* Setup up a new App Hosting backend to deploy from source.
*/
export async function doSetupSourceDeploy(
projectId: string,
backendId: string,
): Promise<{ backend: Backend; location: string }> {
const location = await promptLocation(
projectId,
"Select a primary region to host your backend:\n",
);
const webApp = await webApps.getOrCreateWebApp(projectId, null, backendId);
if (!webApp) {
logWarning(`Firebase web app not set`);
}
const createBackendSpinner = ora("Creating your new backend...").start();
const backend = await createBackend(projectId, location, backendId, null, undefined, webApp?.id);
createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`);
return {
backend,
location,
};
}

/**
* Set up a new App Hosting-type Developer Connect GitRepoLink, optionally with a specific connection ID
*/
Expand Down Expand Up @@ -218,6 +242,7 @@
export async function ensureAppHostingComputeServiceAccount(
projectId: string,
serviceAccount: string | null,
deployFromSource = false,
): Promise<void> {
const sa = serviceAccount || defaultComputeServiceAccountEmail(projectId);
const name = `projects/${projectId}/serviceAccounts/${sa}`;
Expand All @@ -242,13 +267,36 @@
);
}
}
// N.B. To deploy from source, the App Hosting Compute Service Account must have
// the storage.objectViewer IAM role. For firebase-tools <= 14.3.0, the CLI does
// not add the objectViewer role, which means all existing customers will need to
// add it before deploying from source.
if (deployFromSource) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment in the code explaining why this new roles/storage.objectViewer stuff is necessary

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ for better code comprehension e.g. letting reader know that deploy from source has the source code bundled in storage buckets so we need access to that bucket.

but also, now I see below that roles/storage.objectViewer is also added in provisionDefaultComputeServiceAccount() below, which is called in this ensureAppHostingComputeServiceAccount()

and I'm not sure why?

either there's a better way to do it to make things more clear natively or let's document what we're doing and explain any weird/confusing stuff.

const policy = await getIamPolicy(projectId);
const objectViewerBinding = policy.bindings.find(
(binding) => binding.role === "roles/storage.objectViewer",
);
if (
!objectViewerBinding ||
!objectViewerBinding.members.includes(
`serviceAccount:${defaultComputeServiceAccountEmail(projectId)}`,
)
) {
await addServiceAccountToRoles(
projectId,
defaultComputeServiceAccountEmail(projectId),
["roles/storage.objectViewer"],
/* skipAccountLookup= */ true,
);
}
}
}

/**
* Prompts the user for a backend id and verifies that it doesn't match a pre-existing backend.
*/
export async function promptNewBackendId(projectId: string, location: string): Promise<string> {
while (true) {

Check warning on line 299 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const backendId = await input({
default: "my-web-app",
message: "Provide a name for your backend [1-30 characters]",
Expand Down Expand Up @@ -333,6 +381,7 @@
"roles/firebaseapphosting.computeRunner",
"roles/firebase.sdkAdminServiceAgent",
"roles/developerconnect.readTokenAccessor",
"roles/storage.objectViewer",
],
/* skipAccountLookup= */ true,
);
Expand Down Expand Up @@ -546,7 +595,7 @@
message: locationDisambugationPrompt,
choices: [...backendsByLocation.keys()],
});
return backendsByLocation.get(location)!;

Check warning on line 598 in src/apphosting/backend.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/checkValidTargetFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const FILTERABLE_TARGETS = new Set([
"storage",
"database",
"dataconnect",
"apphosting",
]);

/**
Expand Down
1 change: 1 addition & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"remoteconfig",
"extensions",
"dataconnect",
"apphosting",
];
export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does apphosting require any permissions we need to check for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added calls with ensureApiEnabled to check if App Hosting API is enabled. Double checking with team which specific IAM permissions are required

database: ["firebasedatabase.instances.update"],
Expand Down Expand Up @@ -98,14 +99,14 @@
)
.before(requireConfig)
.before((options) => {
options.filteredTargets = filterTargets(options, VALID_DEPLOY_TARGETS);

Check warning on line 102 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Options`

Check warning on line 102 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .filteredTargets on an `any` value
const permissions = options.filteredTargets.reduce((perms: string[], target: string) => {

Check warning on line 103 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 103 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 103 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
return perms.concat(TARGET_PERMISSIONS[target]);
}, []);
return requirePermissions(options, permissions);

Check warning on line 106 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string[]`
})
.before((options) => {
if (options.filteredTargets.includes("functions")) {

Check warning on line 109 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .filteredTargets on an `any` value
return checkServiceAccountIam(options.project);
}
})
Expand Down
7 changes: 7 additions & 0 deletions src/deploy/apphosting/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { AppHostingSingle } from "../../firebaseConfig";

export interface Context {
backendConfigs: Map<string, AppHostingSingle>;
backendLocations: Map<string, string>;
backendStorageUris: Map<string, string>;
}
129 changes: 129 additions & 0 deletions src/deploy/apphosting/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { expect } from "chai";
import * as sinon from "sinon";
import { Config } from "../../config";
import { FirebaseError } from "../../error";
import { AppHostingSingle } from "../../firebaseConfig";
import * as gcs from "../../gcp/storage";
import { RC } from "../../rc";
import { Context } from "./args";
import deploy from "./deploy";
import * as util from "./util";
import * as fs from "fs";
import * as getProjectNumber from "../../getProjectNumber";

const BASE_OPTS = {
cwd: "/",
configPath: "/",
except: "",
force: false,
nonInteractive: false,
interactive: false,
debug: false,
filteredTargets: [],
rc: new RC(),
json: false,
};

function initializeContext(): Context {
return {
backendConfigs: new Map<string, AppHostingSingle>([
[
"foo",
{
backendId: "foo",
rootDir: "/",
ignore: [],
},
],
]),
backendLocations: new Map<string, string>([["foo", "us-central1"]]),
backendStorageUris: new Map<string, string>(),
};
}

describe("apphosting", () => {
let getBucketStub: sinon.SinonStub;
let createBucketStub: sinon.SinonStub;
let uploadObjectStub: sinon.SinonStub;
let createArchiveStub: sinon.SinonStub;
let createReadStreamStub: sinon.SinonStub;
let getProjectNumberStub: sinon.SinonStub;

beforeEach(() => {
getProjectNumberStub = sinon
.stub(getProjectNumber, "getProjectNumber")
.throws("Unexpected getProjectNumber call");
getBucketStub = sinon.stub(gcs, "getBucket").throws("Unexpected getBucket call");
createBucketStub = sinon.stub(gcs, "createBucket").throws("Unexpected createBucket call");
uploadObjectStub = sinon.stub(gcs, "uploadObject").throws("Unexpected uploadObject call");
createArchiveStub = sinon.stub(util, "createArchive").throws("Unexpected createArchive call");
createReadStreamStub = sinon
.stub(fs, "createReadStream")
.throws("Unexpected createReadStream call");
});

afterEach(() => {
sinon.verifyAndRestore();
});

describe("deploy", () => {
const opts = {
...BASE_OPTS,
projectId: "my-project",
only: "apphosting",
config: new Config({
apphosting: {
backendId: "foo",
rootDir: "/",
ignore: [],
},
}),
};

it("creates regional GCS bucket if one doesn't exist yet", async () => {
const context = initializeContext();
getProjectNumberStub.resolves("000000000000");
getBucketStub.onFirstCall().rejects(
new FirebaseError("error", {
original: new FirebaseError("original error", { status: 404 }),
}),
);
createBucketStub.resolves();
createArchiveStub.resolves({
projectSourcePath: "my-project/",
zippedSourcePath: "path/to/foo-1234.zip",
});
uploadObjectStub.resolves({
bucket: "firebaseapphosting-sources-12345678-us-central1",
object: "foo-1234",
});
createReadStreamStub.resolves();

await deploy(context, opts);

expect(createBucketStub).to.be.calledOnce;
});

it("correctly creates and sets storage URIs", async () => {
const context = initializeContext();
getProjectNumberStub.resolves("000000000000");
getBucketStub.resolves();
createBucketStub.resolves();
createArchiveStub.resolves({
projectSourcePath: "my-project/",
zippedSourcePath: "path/to/foo-1234.zip",
});
uploadObjectStub.resolves({
bucket: "firebaseapphosting-sources-12345678-us-central1",
object: "foo-1234",
});
createReadStreamStub.resolves();

await deploy(context, opts);

expect(context.backendStorageUris.get("foo")).to.equal(
"gs://firebaseapphosting-sources-000000000000-us-central1/foo-1234.zip",
);
});
});
});
92 changes: 92 additions & 0 deletions src/deploy/apphosting/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as fs from "fs";
import * as path from "path";
import { FirebaseError, getErrStatus } from "../../error";
import * as gcs from "../../gcp/storage";
import { getProjectNumber } from "../../getProjectNumber";
import { Options } from "../../options";
import { needProjectId } from "../../projectUtils";
import { logBullet, logWarning } from "../../utils";
import { Context } from "./args";
import { createArchive } from "./util";

/**
* Zips and uploads App Hosting source code to Google Cloud Storage in preparation for
* build and deployment. Creates storage buckets if necessary.
*/
export default async function (context: Context, options: Options): Promise<void> {
const projectId = needProjectId(options);
options.projectNumber = await getProjectNumber(options);
if (!context.backendConfigs) {
return;
}

// Ensure that a bucket exists in each region that a backend is or will be deployed to
for (const loc of context.backendLocations.values()) {
const bucketName = `firebaseapphosting-sources-${options.projectNumber}-${loc.toLowerCase()}`;
try {
await gcs.getBucket(bucketName);
} catch (err) {
const errStatus = getErrStatus((err as FirebaseError).original);
// Unfortunately, requests for a non-existent bucket from the GCS API sometimes return 403 responses as well as 404s.
// We must attempt to create a new bucket on both 403s and 404s.
if (errStatus === 403 || errStatus === 404) {
logBullet(
`Creating Cloud Storage bucket in ${loc} to store App Hosting source code uploads at ${bucketName}...`,
);
try {
await gcs.createBucket(projectId, {
name: bucketName,
location: loc,
lifecycle: {
rule: [
{
action: {
type: "Delete",
},
condition: {
age: 30,
},
},
],
},
});
} catch (err) {
if (getErrStatus((err as FirebaseError).original) === 403) {
logWarning(
"Failed to create Cloud Storage bucket because user does not have sufficient permissions. " +
"See https://cloud.google.com/storage/docs/access-control/iam-roles for more details on " +
"IAM roles that are able to create a Cloud Storage bucket, and ask your project administrator " +
"to grant you one of those roles.",
);
throw (err as FirebaseError).original;
}
}
} else {
throw err;
}
}
}

for (const cfg of context.backendConfigs.values()) {
const { projectSourcePath, zippedSourcePath } = await createArchive(cfg, options.projectRoot);
const backendLocation = context.backendLocations.get(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.`,
);
}
logBullet(`Uploading source code at ${projectSourcePath}...`);
const { bucket, object } = await gcs.uploadObject(
{
file: zippedSourcePath,
stream: fs.createReadStream(zippedSourcePath),
},
`firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}`,
);
logBullet(`Source code uploaded at gs://${bucket}/${object}`);
context.backendStorageUris.set(
cfg.backendId,
`gs://firebaseapphosting-sources-${options.projectNumber}-${backendLocation.toLowerCase()}/${path.basename(zippedSourcePath)}`,
);
}
}
5 changes: 5 additions & 0 deletions src/deploy/apphosting/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import prepare from "./prepare";
import deploy from "./deploy";
import release from "./release";

export { prepare, deploy, release };
Loading
Loading