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

Conversation

blidd-google
Copy link
Contributor

@blidd-google blidd-google commented May 7, 2025

firebase deploy now deploys source code on the user's local machine to the corresponding backend on App Hosting. Guides the user through backend creation if the backend does not exist yet.

Note that firebase deploy relies on a new apphosting section in the firebase.json file, which can be initialized using the enhanced firebase init apphosting command (implemented here #8479).

@annajowang
Copy link
Contributor

annajowang commented May 8, 2025

some changes from https://github.com/firebase/firebase-tools/pull/8479/files are showing up in this PR
could you separate these?

I'm happy to do a fast review of https://github.com/firebase/firebase-tools/pull/8479/files after comments have been resolved so you can merge and get those changes rebased into this PR

meanwhile will try an initial review using https://github.com/firebase/firebase-tools/compare/b5697e6209372606c2692bafe253c0fc9f9280dd..fdc6255ec928bac4a3871572793d856703674a06?diff=split&w

Copy link
Contributor

@annajowang annajowang left a comment

Choose a reason for hiding this comment

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

initial review of just the first few files in https://github.com/firebase/firebase-tools/compare/b5697e6209372606c2692bafe253c0fc9f9280dd..fdc6255ec928bac4a3871572793d856703674a06?diff=split&w

I skimmed the others. Could you just pull out src/gcp/apphosting.ts, src/gcp/devConnect.ts, and src/gcp/storage.ts changes into other PRs, and I can take a quick look and then stamp, before continuing with this PR

@@ -250,12 +251,31 @@ export async function ensureAppHostingComputeServiceAccount(
);
}
}
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.

@@ -23,6 +23,7 @@ export const VALID_DEPLOY_TARGETS = [
"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

Copy link
Contributor

@annajowang annajowang left a comment

Choose a reason for hiding this comment

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

reviewed up to src/deploy/apphosting/deploy.ts

}

// Ensure that a bucket exists in each region that a backend is or will be deployed to
const backendLocations = context.backendLocations.values() || [];
Copy link
Contributor

Choose a reason for hiding this comment

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

if context.backendLocations is empty, i would expect .values() to just return [], so we don't need the || [] ?

also nit: can we inline context.backendLocations.values() into the for loop as well?

try {
await gcs.createBucket(projectId, {
name: bucketName,
location: "US-CENTRAL1",
Copy link
Contributor

Choose a reason for hiding this comment

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

does the bucket location also need to match the apphosting location? or are we just creating a bucket for each apphosting location?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We're creating a bucket on demand for every location that has an deploy-from-source backend in it.

For example: the user is trying to deploy from source backend A in us-central1, but there's no bucket yet in us-central1 so we create it. There is also a backend B in asia-east1, but the user hasn't tried to deploy B from source yet, so we don't create that bucket.

Copy link
Contributor

@annajowang annajowang May 9, 2025

Choose a reason for hiding this comment

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

So should this line be:
location: loc
not:
location: "US-CENTRAL1"

Comment on lines 60 to 87
if (options.force) {
throw new FirebaseError(
`Failed to deploy in non-interactive mode: backend ${cfg.backendId} does not exist yet, ` +
"and we cannot create one for you because you must choose a primary region for the backend. " +
"Please run 'firebase apphosting:backends:create' to create the backend, then retry deployment.",
);
}
logBullet(`No backend '${cfg.backendId}' found. Creating a new backend...`);
const location = await promptLocation(
projectId,
"Select a primary region to host your backend:\n",
);
const webApp = await webApps.getOrCreateWebApp(projectId, null, cfg.backendId);
if (!webApp) {
logWarning(`Firebase web app not set`);
}
const createBackendSpinner = ora("Creating your new backend...").start();
const backend = await createBackend(
projectId,
location,
cfg.backendId,
null,
undefined,
webApp?.id,
);
createBackendSpinner.succeed(`Successfully created backend!\n\t${backend.name}\n`);
context.backendConfigs.set(cfg.backendId, cfg);
context.backendLocations.set(cfg.backendId, location);
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 pull this into some separate doSetupSourceDeploy() function that lives in https://github.com/firebase/firebase-tools/blob/master/src/apphosting/backend.ts

This has a too much repeat code from deSetup(): https://github.com/firebase/firebase-tools/blob/master/src/apphosting/backend.ts#L74

if we don't have time to refactor and reduce the duplicate code here, these should at least live near each other, so we're more likely to dedupe in the future and we're more likely to change things in both places and keep the UX behaviors consistent.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also it seems like the big block of code above

 await Promise.all([
    ensure(projectId, developerConnectOrigin(), "apphosting", true),
    ensure(projectId, cloudbuildOrigin(), "apphosting", true),
    ensure(projectId, secretManagerOrigin(), "apphosting", true),
    ensure(projectId, cloudRunApiOrigin(), "apphosting", true),
    ensure(projectId, artifactRegistryDomain(), "apphosting", true),
    ensure(projectId, iamOrigin(), "apphosting", true),
  ]);
  await ensureAppHostingComputeServiceAccount(
    projectId,
    /* serviceAccount= */ null,
    /* deployFromSource= */ true,
  );

should go into this new doSetupSourceDeploy(), since it's also in doSetup() and all these don't need to be made when the backend already exists?

Copy link
Contributor

Choose a reason for hiding this comment

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

This is repeated below:

context.backendConfigs.set(cfg.backendId, cfg);
context.backendLocations.set(cfg.backendId, location);

can you move this to the bottom of the for loop

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see your point about having the create logic in the same module, I've refactored it into a doSetupSourceDeploy() function in https://github.com/firebase/firebase-tools/blob/master/src/apphosting/backend.ts. However, I think de-duping might be a bit premature; refactoring the duplicate logic into a helper function would require introducing new arguments to that helper (i.e. we would need an optional backendId arg since source deploys won't need to prompt for a backend ID, and same thing for webAppName), just to avoid repeating three function calls. So as of now, from a readability perspective I'm leaning on the side of keeping separate doSetup and doSetupSourceDeploy. But as the logic expands in the future, I'm open to revisiting the refactor option. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah keeping similar code close together, so anyone making changes in one is more likely to not miss making the same changes in the other is my main concern. agree deduping would be a big task right now that can be left for later given timeline.

}
if (selector.startsWith("apphosting:")) {
const backendId = selector.replace("apphosting:", "");
if (selector.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be if (backendId.length > 0) ?

Comment on lines 175 to 180
for (const id of backendIds) {
const cfg = backendConfigs.find((cfg) => cfg.backendId === id);
if (cfg) {
filtered.push(cfg);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

feel like this could all be replaced by

filtered = backendConfigs.filter(cfg => backendIds.include(cfg.BackendId));

Comment on lines 91 to 93
if (cfg.alwaysDeployFromSource === false) {
continue;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: move this above defining the two constants, since you don't need them.
for readability, delay creating new variables until right before they're first needed.

Comment on lines 123 to 126
if (!confirmDeploy) {
logWarning(`Skipping deployment of backend ${cfg.backendId}`);
continue;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think confirmDeploy could ever be falsey except within the if (!options.force) { if statement. So i think we can keep confirmDeploy and the logwarning all inside the if statement and get rid of this extra else

Comment on lines 44 to 48
const multipleRolloutsMessage = `Starting new rollouts for backends ${Array.from(context.backendConfigs.keys()).join(", ")}`;
const singleRolloutMessage = `Starting a new rollout for backend ${Array.from(context.backendConfigs.keys()).join(", ")}`;
logBullet(
`${rollouts.length > 1 ? multipleRolloutsMessage : singleRolloutMessage}; this may take a few minutes. It's safe to exit now.`,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

i think just saying "Starting rollout(s) for backend(s) ...." is fine and does not degrade user experience.

@blidd-google blidd-google force-pushed the bl-fah-deploy-from-source branch from ce05795 to 1588252 Compare May 12, 2025 04:41
ignore.push(...gitIgnorePatterns);

if (!projectRoot) {
projectRoot = process.cwd();
Copy link
Contributor

Choose a reason for hiding this comment

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

do we want to let the user known what directory is being used as their projectRoot?
I think it would be a big deal if the user accidentally deploys the wrong set of files, but I don't know if we're preventing / reducing chances of that happening at some point prior to this.

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 some log lines. Users can cancel the upload file stream if they realize they uploaded the wrong files.

crlfDelay: Infinity,
});
rl.on("line", (line: string) => {
if (line.startsWith("#") || line.trim() === "") {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: would feel better if line was trimmed before checking if it starts with "#" as well.


async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise<string[]> {
const absoluteFilePath: string = path.resolve(filePath);
const lines: string[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: move lines to inside promise?

return { projectSourcePath: projectRoot, zippedSourcePath: tmpFile };
}

async function parseGitIgnorePatterns(filePath = ".gitignore"): Promise<string[]> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd really like us to not write code where we don't have to. but since we're on a time crunch:
could you leave a TODO to say that this could potentially be replaced by
https://www.npmjs.com/package/parse-gitignore

so whoever touches this next can consider doing it.

Copy link
Contributor

@annajowang annajowang May 12, 2025

Choose a reason for hiding this comment

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

actually, as bryan said somewhere else, this package essentially does
"fs.ReadFileSync("path").filter(startsWith("#"))"

I'm realizing it's not clear to me why we need 30+ lines of code instead of just

return fs.ReadFileSync(".gitinore").filter( (line) => line.trim().startsWith("#") || line.trim() === ""))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair enough. The event listening approach is kind of an old school way to do it. Made the changes

@blidd-google blidd-google enabled auto-merge (squash) May 15, 2025 19:11
@blidd-google blidd-google merged commit 0486bd9 into master May 15, 2025
50 checks passed
@github-project-automation github-project-automation bot moved this from Approved [PR] to Done in [Cloud] Extensions + Functions May 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants