Skip to content

Commit

Permalink
Merge branch 'master' into launch.dataconnect
Browse files Browse the repository at this point in the history
  • Loading branch information
joehan committed Apr 26, 2024
2 parents 534076b + 3a716c9 commit 3838504
Show file tree
Hide file tree
Showing 18 changed files with 207 additions and 144 deletions.
3 changes: 0 additions & 3 deletions CHANGELOG.md
@@ -1,3 +0,0 @@
- Release Firestore Emulator version 1.19.5 which adds support for import and export in Datastore Mode (#7020).
- Fix non static check for not-found route in Next.js 14.2 (#7012)
- Fix Next.js path issue on Windows (#7031)
4 changes: 2 additions & 2 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
@@ -1,6 +1,6 @@
{
"name": "firebase-tools",
"version": "13.7.3",
"version": "13.7.5",
"description": "Command-Line Interface for Firebase",
"main": "./lib/index.js",
"bin": {
Expand Down
26 changes: 15 additions & 11 deletions src/api.ts
Expand Up @@ -24,13 +24,27 @@ export const cloudMonitoringOrigin = () =>
utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com");
export const containerRegistryDomain = () =>
utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io");

export const developerConnectOrigin = () =>
utils.envOverride("DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com");
export const developerConnectP4SADomain = () =>
utils.envOverride("DEVELOPERCONNECT_P4SA_DOMAIN", "gcp-sa-devconnect.iam.gserviceaccount.com");

export const artifactRegistryDomain = () =>
utils.envOverride("ARTIFACT_REGISTRY_DOMAIN", "https://artifactregistry.googleapis.com");
export const appDistributionOrigin = () =>
utils.envOverride(
"FIREBASE_APP_DISTRIBUTION_URL",
"https://firebaseappdistribution.googleapis.com",
);
export const apphostingOrigin = () =>
utils.envOverride("FIREBASE_APPHOSTING_URL", "https://firebaseapphosting.googleapis.com");
export const apphostingP4SADomain = () =>
utils.envOverride(
"FIREBASE_APPHOSTING_P4SA_DOMAIN",
"gcp-sa-firebaseapphosting.iam.gserviceaccount.com",
);

export const authOrigin = () =>
utils.envOverride("FIREBASE_AUTH_URL", "https://accounts.google.com");
export const consoleOrigin = () =>
Expand Down Expand Up @@ -71,15 +85,6 @@ export const functionsDefaultRegion = () =>
export const cloudbuildOrigin = () =>
utils.envOverride("FIREBASE_CLOUDBUILD_URL", "https://cloudbuild.googleapis.com");

export const developerConnectOrigin = () =>
utils.envOverride("FIREBASE_DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com");

export const developerConnectP4SAOrigin = () =>
utils.envOverride(
"FIREBASE_DEVELOPERCONNECT_P4SA_URL",
"gcp-sa-devconnect.iam.gserviceaccount.com",
);

export const cloudschedulerOrigin = () =>
utils.envOverride("FIREBASE_CLOUDSCHEDULER_URL", "https://cloudscheduler.googleapis.com");
export const cloudTasksOrigin = () =>
Expand Down Expand Up @@ -128,8 +133,7 @@ export const cloudRunApiOrigin = () =>
utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com");
export const serviceUsageOrigin = () =>
utils.envOverride("FIREBASE_SERVICE_USAGE_URL", "https://serviceusage.googleapis.com");
export const apphostingOrigin = () =>
utils.envOverride("APPHOSTING_URL", "https://firebaseapphosting.googleapis.com");

export const githubOrigin = () => utils.envOverride("GITHUB_URL", "https://github.com");
export const githubApiOrigin = () => utils.envOverride("GITHUB_API_URL", "https://api.github.com");
export const secretManagerOrigin = () =>
Expand Down
2 changes: 1 addition & 1 deletion src/apphosting/githubConnections.ts
Expand Up @@ -135,7 +135,7 @@ export async function linkGitHubRepository(

const repo = await getOrCreateRepository(projectId, location, connectionId, repoCloneUri);
utils.logSuccess(`Successfully linked GitHub repository at remote URI`);
utils.logSuccess(`\t${repo.cloneUri}`);
utils.logSuccess(`\t${repo.cloneUri}\n`);
return repo;
}

Expand Down
95 changes: 62 additions & 33 deletions src/apphosting/index.ts
Expand Up @@ -8,7 +8,9 @@ import {
artifactRegistryDomain,
cloudRunApiOrigin,
cloudbuildOrigin,
consoleOrigin,
developerConnectOrigin,
iamOrigin,
secretManagerOrigin,
} from "../api";
import { Backend, BackendOutputOnlyFields, API_VERSION, Build, Rollout } from "../gcp/apphosting";
Expand All @@ -23,6 +25,8 @@ import * as deploymentTool from "../deploymentTool";
import { DeepOmit } from "../metaprogramming";
import * as apps from "./app";
import { GitRepositoryLink } from "../gcp/devConnect";
import * as ora from "ora";

const DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME = "firebase-app-hosting-compute";

const apphostingPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
Expand All @@ -48,7 +52,13 @@ export async function doSetup(
ensure(projectId, secretManagerOrigin(), "apphosting", true),
ensure(projectId, cloudRunApiOrigin(), "apphosting", true),
ensure(projectId, artifactRegistryDomain(), "apphosting", true),
ensure(projectId, iamOrigin(), "apphosting", true),
]);
logBullet("First we need a few details to create your backend.\n");

// Hack: Because IAM can take ~45 seconds to propagate, we provision the service account as soon as
// possible to reduce the likelihood that the subsequent Cloud Build fails. See b/336862200.
await ensureAppHostingComputeServiceAccount(projectId, serviceAccount);

const allowedLocations = (await apphosting.listLocations(projectId)).map((loc) => loc.locationId);
if (location) {
Expand All @@ -59,8 +69,6 @@ export async function doSetup(
}
}

logBullet("First we need a few details to create your backend.\n");

location =
location || (await promptLocation(projectId, "Select a location to host your backend:\n"));
logSuccess(`Location set to ${location}.\n`);
Expand Down Expand Up @@ -90,6 +98,7 @@ export async function doSetup(
message: "Specify your app's root directory relative to your repository",
});

const createBackendSpinner = ora("Creating your new backend...").start();
const backend = await createBackend(
projectId,
location,
Expand All @@ -99,6 +108,7 @@ export async function doSetup(
webApp?.id,
rootDir,
);
createBackendSpinner.succeed(`Successfully created backend:\n\t${backend.name}\n`);

// TODO: Once tag patterns are implemented, prompt which method the user
// prefers. We could reduce the number of questions asked by letting people
Expand All @@ -120,21 +130,59 @@ export async function doSetup(
});

if (!confirmRollout) {
logSuccess(`Successfully created backend:\n\t${backend.name}`);
logSuccess(`Your backend will be deployed at:\n\thttps://${backend.uri}`);
return;
}

logBullet(
`You may also track this rollout at:\n\t${consoleOrigin()}/project/${projectId}/apphosting`,
);
const createRolloutSpinner = ora(
"Starting a new rollout... This make take a few minutes. It's safe to exit now.",
).start();
await orchestrateRollout(projectId, location, backendId, {
source: {
codebase: {
branch,
},
},
});
createRolloutSpinner.succeed(`Your backend is now deployed at:\n\thttps://${backend.uri}`);
}

logSuccess(`Successfully created backend:\n\t${backend.name}`);
logSuccess(`Your backend is now deployed at:\n\thttps://${backend.uri}`);
/**
* Ensures the service account is present the user has permissions to use it by
* checking the `iam.serviceAccounts.actAs` permission. If the permissions
* check fails, this returns an error. If the permission check fails with a
* "not found" error, this attempts to provision the service account.
*/
export async function ensureAppHostingComputeServiceAccount(
projectId: string,
serviceAccount: string | null,
): Promise<void> {
const sa = serviceAccount || defaultComputeServiceAccountEmail(projectId);
const name = `projects/${projectId}/serviceAccounts/${sa}`;
try {
await iam.testResourceIamPermissions(
iamOrigin(),
"v1",
name,
["iam.serviceAccounts.actAs"],
`projects/${projectId}`,
);
} catch (err: unknown) {
if (!(err instanceof FirebaseError)) {
throw err;
}
if (err.status === 404) {
await provisionDefaultComputeServiceAccount(projectId);
} else if (err.status === 403) {
throw new FirebaseError(
`Failed to create backend due to missing delegation permissions for ${sa}. Make sure you have the iam.serviceAccounts.actAs permission.`,
{ original: err },
);
}
}
}

/**
Expand Down Expand Up @@ -191,9 +239,6 @@ export async function createBackend(
appId: webAppId,
};

// TODO: remove computeServiceAccount when the backend supports the field.
delete backendReqBody.serviceAccount;

async function createBackendAndPoll(): Promise<apphosting.Backend> {
const op = await apphosting.createBackend(projectId, location, backendReqBody, backendId);
return await poller.pollOperation<Backend>({
Expand All @@ -203,32 +248,16 @@ export async function createBackend(
});
}

try {
return await createBackendAndPoll();
} catch (err: any) {
if (err.status === 403) {
if (err.message.includes(defaultServiceAccount)) {
// Create the default service account if it doesn't exist and try again.
await provisionDefaultComputeServiceAccount(projectId);
return await createBackendAndPoll();
} else if (serviceAccount && err.message.includes(serviceAccount)) {
throw new FirebaseError(
`Failed to create backend due to missing delegation permissions for ${serviceAccount}. Make sure you have the iam.serviceAccounts.actAs permission.`,
{ children: [err] },
);
}
}
throw err;
}
return await createBackendAndPoll();
}

async function provisionDefaultComputeServiceAccount(projectId: string): Promise<void> {
try {
await iam.createServiceAccount(
projectId,
DEFAULT_COMPUTE_SERVICE_ACCOUNT_NAME,
"Firebase App Hosting compute service account",
"Default service account used to run builds and deploys for Firebase App Hosting",
"Firebase App Hosting compute service account",
);
} catch (err: any) {
// 409 Already Exists errors can safely be ignored.
Expand All @@ -240,12 +269,9 @@ async function provisionDefaultComputeServiceAccount(projectId: string): Promise
projectId,
defaultComputeServiceAccountEmail(projectId),
[
// TODO: Update to roles/firebaseapphosting.computeRunner when it is available.
"roles/firebaseapphosting.viewer",
"roles/artifactregistry.createOnPushWriter",
"roles/logging.logWriter",
"roles/storage.objectAdmin",
"roles/firebaseapphosting.computeRunner",
"roles/firebase.sdkAdminServiceAgent",
"roles/developerconnect.readTokenAccessor",
],
/* skipAccountLookup= */ true,
);
Expand Down Expand Up @@ -279,6 +305,10 @@ export async function setDefaultTrafficPolicy(
});
}

function delay(ms: number): Promise<number> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Creates a new build and rollout and polls both to completion.
*/
Expand All @@ -288,7 +318,7 @@ export async function orchestrateRollout(
backendId: string,
buildInput: DeepOmit<Build, apphosting.BuildOutputOnlyFields | "name">,
): Promise<{ rollout: Rollout; build: Build }> {
logBullet("Starting a new rollout... this may take a few minutes.");
await delay(45 * 1000);
const buildId = await apphosting.getNextRolloutId(projectId, location, backendId, 1);
const buildOp = await apphosting.createBuild(projectId, location, backendId, buildId, buildInput);

Expand Down Expand Up @@ -343,7 +373,6 @@ export async function orchestrateRollout(
});

const [rollout, build] = await Promise.all([rolloutPoll, buildPoll]);
logSuccess("Rollout completed.");

if (build.state !== "READY") {
if (!build.buildLogsUri) {
Expand Down
7 changes: 7 additions & 0 deletions src/apphosting/secrets/index.ts
Expand Up @@ -62,9 +62,11 @@ export function serviceAccountsForBackend(
*/
export async function grantSecretAccess(
projectId: string,
projectNumber: string,
secretName: string,
accounts: MultiServiceAccounts,
): Promise<void> {
const p4saEmail = apphosting.serviceAgentEmail(projectNumber);
const newBindings: iam.Binding[] = [
{
role: "roles/secretmanager.secretAccessor",
Expand All @@ -78,6 +80,11 @@ export async function grantSecretAccess(
role: "roles/secretmanager.viewer",
members: accounts.buildServiceAccounts.map((sa) => `serviceAccount:${sa}`),
},
// The App Hosting service agent needs the version manager role for automated garbage collection.
{
role: "roles/secretmanager.secretVersionManager",
members: [`serviceAccount:${p4saEmail}`],
},
];

let existingBindings;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/apphosting-backends-create.ts
Expand Up @@ -19,8 +19,8 @@ export const command = new Command("apphosting:backends:create")
)
.option(
"-w, --with-dev-connect",
"use the Developer Connect flow insetad of Cloud Build Repositories (testing)",
false,
"use the Developer Connect flow instead of Cloud Build Repositories (testing)",
true,
)
.before(ensureApiEnabled)
.before(requireInteractive)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/apphosting-secrets-grantaccess.ts
Expand Up @@ -50,5 +50,5 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
const backend = await apphosting.getBackend(projectId, location, backendId);
const accounts = secrets.toMulti(secrets.serviceAccountsForBackend(projectNumber, backend));

await secrets.grantSecretAccess(projectId, secretName, accounts);
await secrets.grantSecretAccess(projectId, projectNumber, secretName, accounts);
});
2 changes: 1 addition & 1 deletion src/commands/apphosting-secrets-set.ts
Expand Up @@ -71,7 +71,7 @@ export const command = new Command("apphosting:secrets:set <secretName>")

// TODO: For existing secrets, enter the grantSecretAccess dialog only when the necessary permissions don't exist.
} else {
await secrets.grantSecretAccess(projectId, secretName, accounts);
await secrets.grantSecretAccess(projectId, projectNumber, secretName, accounts);
}

await config.maybeAddSecretToYaml(secretName);
Expand Down

0 comments on commit 3838504

Please sign in to comment.