firebase-tools 15.22.4 → 15.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/lib/apiv2.js +91 -32
  2. package/lib/apphosting/backend.js +11 -4
  3. package/lib/commands/functions-delete.js +5 -3
  4. package/lib/commands/functions-lifecycle-list.js +94 -0
  5. package/lib/commands/functions-lifecycle-run.js +29 -0
  6. package/lib/commands/index.js +3 -0
  7. package/lib/database/import.js +3 -3
  8. package/lib/dataconnect/webhook.js +1 -2
  9. package/lib/deploy/functions/backend.js +9 -0
  10. package/lib/deploy/functions/build.js +3 -0
  11. package/lib/deploy/functions/prepare.js +90 -1
  12. package/lib/deploy/functions/prompts.js +62 -0
  13. package/lib/deploy/functions/release/fabricator.js +79 -4
  14. package/lib/deploy/functions/release/index.js +40 -25
  15. package/lib/deploy/functions/release/lifecycle.js +44 -51
  16. package/lib/deploy/functions/release/planner.js +41 -5
  17. package/lib/deploy/functions/runtimes/discovery/index.js +4 -3
  18. package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +18 -1
  19. package/lib/deploy/functions/runtimes/node/index.js +1 -2
  20. package/lib/deploy/functions/runtimes/python/index.js +1 -2
  21. package/lib/deploy/hosting/uploader.js +2 -3
  22. package/lib/downloadUtils.js +3 -1
  23. package/lib/emulator/auth/operations.js +18 -8
  24. package/lib/emulator/downloadableEmulatorInfo.json +24 -24
  25. package/lib/emulator/storage/apis/gcloud.js +61 -0
  26. package/lib/emulator/storage/multipart.js +82 -2
  27. package/lib/emulator/taskQueue.js +3 -11
  28. package/lib/extensions/extensionsHelper.js +6 -4
  29. package/lib/gcp/iam.js +68 -2
  30. package/lib/gcp/knownRoles.json +99 -0
  31. package/lib/gcp/resourceManager.js +42 -1
  32. package/lib/gemini/fdcExperience.js +2 -2
  33. package/lib/hosting/initMiddleware.js +1 -1
  34. package/lib/hosting/proxy.js +42 -20
  35. package/lib/management/apps.js +4 -2
  36. package/lib/profiler.js +1 -2
  37. package/lib/streamUtils.js +24 -0
  38. package/lib/track.js +1 -2
  39. package/lib/tsconfig.compile.tsbuildinfo +1 -1
  40. package/lib/tsconfig.publish.tsbuildinfo +1 -1
  41. package/lib/utils.js +4 -21
  42. package/package.json +2 -4
@@ -5,6 +5,7 @@ exports.promptForFunctionDeletion = promptForFunctionDeletion;
5
5
  exports.promptForUnsafeMigration = promptForUnsafeMigration;
6
6
  exports.promptForMinInstances = promptForMinInstances;
7
7
  exports.promptForCleanupPolicyDays = promptForCleanupPolicyDays;
8
+ exports.promptForSecurityChanges = promptForSecurityChanges;
8
9
  const clc = require("colorette");
9
10
  const functionsDeployHelper_1 = require("./functionsDeployHelper");
10
11
  const error_1 = require("../../error");
@@ -14,6 +15,7 @@ const backend = require("./backend");
14
15
  const pricing = require("./pricing");
15
16
  const utils = require("../../utils");
16
17
  const artifacts = require("../../functions/artifacts");
18
+ const iam = require("../../gcp/iam");
17
19
  async function promptForFailurePolicies(options, want, have) {
18
20
  const retryEndpoints = backend.allEndpoints(want).filter((e) => {
19
21
  return backend.isEventTriggered(e) && e.eventTrigger.retry;
@@ -191,3 +193,63 @@ async function promptForCleanupPolicyDays(options, locations) {
191
193
  validate: (days) => !days || isNaN(days) || days < 0 ? "Please enter a non-negative number" : true,
192
194
  });
193
195
  }
196
+ async function promptForSecurityChanges(plan, options) {
197
+ if (options.force) {
198
+ return;
199
+ }
200
+ for (const [codebase, codebasePlan] of Object.entries(plan)) {
201
+ if (codebasePlan.serviceAccountToDelete) {
202
+ if (options.nonInteractive) {
203
+ throw new error_1.FirebaseError(`Cannot opt out of declarative security and delete managed service account ${codebasePlan.serviceAccountToDelete} in non-interactive mode. Please deploy with --force to confirm.`, { exit: 1 });
204
+ }
205
+ const msg = `Deploying this code will opt out of declarative security for codebase ${codebase}. All functions which do not specify a custom service account will use a default service account on next deploy. As a cleanup, the managed service account ${codebasePlan.serviceAccountToDelete} will be deleted. Continue?`;
206
+ const confirmed = await (0, prompt_1.confirm)({ default: false, message: msg });
207
+ if (!confirmed) {
208
+ throw new error_1.FirebaseError("Deployment canceled by user.");
209
+ }
210
+ }
211
+ if (codebasePlan.serviceAccountToCreate) {
212
+ if (options.nonInteractive) {
213
+ throw new error_1.FirebaseError(`Cannot enable declarative security and create managed service account ${codebasePlan.serviceAccountToCreate} in non-interactive mode. Please deploy with --force to confirm.`, { exit: 1 });
214
+ }
215
+ const roleNames = await Promise.all((codebasePlan.rolesToAdd || []).map((r) => iam.getRoleName(r)));
216
+ let msg = "This codebase uses declarative security. ";
217
+ if (roleNames.length > 0) {
218
+ msg += `It will use the following role(s):\n${roleNames
219
+ .map((r) => `* ${r}`)
220
+ .join("\n")}\nContinue?`;
221
+ }
222
+ else {
223
+ msg += "It will not use any additional roles. Continue?";
224
+ }
225
+ const confirmed = await (0, prompt_1.confirm)({ default: false, message: msg });
226
+ if (!confirmed) {
227
+ throw new error_1.FirebaseError("Deployment canceled by user.");
228
+ }
229
+ }
230
+ else if ((codebasePlan.rolesToAdd && codebasePlan.rolesToAdd.length > 0) ||
231
+ (codebasePlan.rolesToRemove && codebasePlan.rolesToRemove.length > 0)) {
232
+ if (options.nonInteractive) {
233
+ throw new error_1.FirebaseError(`Cannot modify declarative security roles for codebase ${codebase} in non-interactive mode. Please deploy with --force to confirm.`, { exit: 1 });
234
+ }
235
+ let msg = `Deploying this code will modify the managed service account for codebase ${codebase}.\n`;
236
+ if (codebasePlan.rolesToAdd && codebasePlan.rolesToAdd.length > 0) {
237
+ const addedNames = await Promise.all(codebasePlan.rolesToAdd.map((r) => iam.getRoleName(r)));
238
+ msg += `All functions in this codebase will be granted the following new role(s):\n${addedNames
239
+ .map((r) => `* ${r}`)
240
+ .join("\n")}\n`;
241
+ }
242
+ if (codebasePlan.rolesToRemove && codebasePlan.rolesToRemove.length > 0) {
243
+ const removedNames = await Promise.all(codebasePlan.rolesToRemove.map((r) => iam.getRoleName(r)));
244
+ msg += `All functions in this codebase will lose access to the following role(s):\n${removedNames
245
+ .map((r) => `* ${r}`)
246
+ .join("\n")}\n`;
247
+ }
248
+ msg += "Continue?";
249
+ const confirmed = await (0, prompt_1.confirm)({ default: false, message: msg });
250
+ if (!confirmed) {
251
+ throw new error_1.FirebaseError("Deployment canceled by user.");
252
+ }
253
+ }
254
+ }
255
+ }
@@ -49,6 +49,8 @@ const eventarcPollerOptions = {
49
49
  maxBackoff: 10000,
50
50
  };
51
51
  const CLOUD_RUN_RESOURCE_EXHAUSTED_CODE = 8;
52
+ const iam = require("../../../gcp/iam");
53
+ const resourcemanager = require("../../../gcp/resourceManager");
52
54
  const rethrowAs = (endpoint, op) => (err) => {
53
55
  logger_1.logger.error(err.message);
54
56
  throw new reporter.DeploymentError(endpoint, op, err);
@@ -61,6 +63,70 @@ class Fabricator {
61
63
  this.sources = args.sources;
62
64
  this.appEngineLocation = args.appEngineLocation;
63
65
  this.projectNumber = args.projectNumber;
66
+ this.projectId = args.projectId;
67
+ }
68
+ async grantNewRoles(plan, codebase) {
69
+ if (plan.serviceAccountToCreate) {
70
+ utils.logLabeledBullet("functions", `Creating managed service account ${plan.serviceAccountToCreate}...`);
71
+ const saName = plan.serviceAccountToCreate.split("@")[0];
72
+ try {
73
+ await iam.createServiceAccount(this.projectId, saName, `Managed by Firebase CLI for codebase ${codebase}`, `Firebase Functions ${codebase}`);
74
+ }
75
+ catch (e) {
76
+ throw new error_1.FirebaseError("Cannot enable declarative security because you do not have permissions necessary to create the service account. Please ask an IAM administrator to perform the next deploy.", { original: e });
77
+ }
78
+ }
79
+ if (plan.rolesToAdd?.length) {
80
+ if (!plan.managedServiceAccount) {
81
+ throw new error_1.FirebaseError("Failed to grant IAM roles: managed service account is missing.", {
82
+ exit: 1,
83
+ });
84
+ }
85
+ utils.logLabeledBullet("functions", `Granting IAM roles to ${plan.managedServiceAccount}...`);
86
+ try {
87
+ await resourcemanager.addServiceAccountRoles(this.projectId, plan.managedServiceAccount, plan.rolesToAdd, true);
88
+ }
89
+ catch (e) {
90
+ throw new error_1.FirebaseError("The declarative security roles for this codebase have changed, but you do not have access to see what has changed. Please ask an IAM administrator to perform the next deploy.", { original: e });
91
+ }
92
+ }
93
+ else if (plan.rolesToRemove?.length) {
94
+ const iamResult = await iam.testIamPermissions(this.projectId, [
95
+ "resourcemanager.projects.setIamPolicy",
96
+ ]);
97
+ if (!iamResult.passed) {
98
+ throw new error_1.FirebaseError("The declarative security roles for this codebase have changed, but you do not have access to see what has changed. Please ask an IAM administrator to perform the next deploy.");
99
+ }
100
+ }
101
+ }
102
+ async removeOldRoles(plan, codebase) {
103
+ if (plan.serviceAccountToDelete) {
104
+ utils.logLabeledBullet("functions", `Deleting managed service account ${plan.serviceAccountToDelete}...`);
105
+ try {
106
+ await iam.deleteServiceAccount(this.projectId, plan.serviceAccountToDelete);
107
+ }
108
+ catch (e) {
109
+ throw new error_1.FirebaseError("Failed to delete managed service account " +
110
+ plan.serviceAccountToDelete +
111
+ ". Please ask an IAM administrator to delete it manually.", { original: e });
112
+ }
113
+ return;
114
+ }
115
+ if (!plan.rolesToRemove?.length) {
116
+ return;
117
+ }
118
+ if (!plan.managedServiceAccount) {
119
+ throw new error_1.FirebaseError("Failed to revoke IAM roles: managed service account is missing.", {
120
+ exit: 1,
121
+ });
122
+ }
123
+ utils.logLabeledBullet("functions", `Revoking unneeded IAM roles from ${plan.managedServiceAccount} for codebase ${codebase}...`);
124
+ try {
125
+ await resourcemanager.removeServiceAccountRoles(this.projectId, plan.managedServiceAccount, plan.rolesToRemove);
126
+ }
127
+ catch (e) {
128
+ throw new error_1.FirebaseError("The declarative security roles for this codebase have changed, but you do not have access to see what has changed. Please ask an IAM administrator to perform the next deploy.", { original: e });
129
+ }
64
130
  }
65
131
  async applyPlan(plan) {
66
132
  const timer = new timer_1.Timer();
@@ -68,8 +134,14 @@ class Fabricator {
68
134
  totalTime: 0,
69
135
  results: [],
70
136
  };
71
- const changesets = Object.values(plan);
72
- const createAndUpdatePromises = changesets.map((changes) => {
137
+ for (const [codebase, codebasePlan] of Object.entries(plan)) {
138
+ await this.grantNewRoles(codebasePlan, codebase);
139
+ }
140
+ const allChangesets = [];
141
+ for (const codebasePlan of Object.values(plan)) {
142
+ allChangesets.push(...Object.values(codebasePlan.regionalChangesets));
143
+ }
144
+ const createAndUpdatePromises = allChangesets.map((changes) => {
73
145
  const scraperV1 = new sourceTokenScraper_1.SourceTokenScraper();
74
146
  const scraperV2 = new sourceTokenScraper_1.SourceTokenScraper();
75
147
  return this.applyUpserts(changes, scraperV1, scraperV2);
@@ -85,7 +157,7 @@ class Fabricator {
85
157
  const hasFailures = summary.results.some((r) => r.error);
86
158
  if (hasFailures) {
87
159
  utils.logLabeledWarning("functions", "Deploys failed. Skipping deletes.");
88
- summary.results = changesets.reduce((accum, changes) => {
160
+ summary.results = allChangesets.reduce((accum, changes) => {
89
161
  const currentAborts = changes.endpointsToDelete.map((endpoint) => ({
90
162
  endpoint,
91
163
  durationMs: 0,
@@ -96,7 +168,7 @@ class Fabricator {
96
168
  summary.totalTime = timer.stop();
97
169
  return summary;
98
170
  }
99
- const deleteResultsArray = await Promise.allSettled(changesets.map((changes) => this.applyDeletes(changes)));
171
+ const deleteResultsArray = await Promise.allSettled(allChangesets.map((changes) => this.applyDeletes(changes)));
100
172
  const deleteResults = deleteResultsArray.reduce((acc, r) => {
101
173
  if (r.status === "fulfilled") {
102
174
  return [...acc, ...r.value];
@@ -105,6 +177,9 @@ class Fabricator {
105
177
  return acc;
106
178
  }, []);
107
179
  summary.results.push(...deleteResults);
180
+ for (const [codebase, codebasePlan] of Object.entries(plan)) {
181
+ await this.removeOldRoles(codebasePlan, codebase);
182
+ }
108
183
  summary.totalTime = timer.stop();
109
184
  return summary;
110
185
  }
@@ -18,6 +18,7 @@ const error_1 = require("../../../error");
18
18
  const getProjectNumber_1 = require("../../../getProjectNumber");
19
19
  const extensions_1 = require("../../extensions");
20
20
  const artifacts = require("../../../functions/artifacts");
21
+ const supported_1 = require("../runtimes/supported");
21
22
  const lifecycle_1 = require("./lifecycle");
22
23
  async function release(context, options, payload) {
23
24
  if (context.extensions && payload.extensions) {
@@ -32,38 +33,39 @@ async function release(context, options, payload) {
32
33
  if (!context.sources) {
33
34
  return;
34
35
  }
35
- let plan = {};
36
- for (const [codebase, { wantBackend, haveBackend }] of Object.entries(payload.functions)) {
37
- plan = {
38
- ...plan,
39
- ...planner.createDeploymentPlan({
40
- codebase,
41
- wantBackend,
42
- haveBackend,
43
- filters: context.filters,
44
- }),
45
- };
36
+ const plan = {};
37
+ for (const [codebase, { wantBackend, haveBackend, haveRoles, existingManagedSA, managedSA },] of Object.entries(payload.functions)) {
38
+ plan[codebase] = await planner.createDeploymentPlan({
39
+ codebase,
40
+ wantBackend,
41
+ haveBackend,
42
+ projectId: context.projectId,
43
+ filters: context.filters,
44
+ haveRoles,
45
+ existingManagedSA,
46
+ managedSA,
47
+ });
46
48
  }
47
- const fnsToDelete = Object.values(plan)
49
+ await prompts.promptForSecurityChanges(plan, options);
50
+ const allRegionalChanges = Object.values(plan)
51
+ .map((codebasePlan) => Object.values(codebasePlan.regionalChangesets))
52
+ .reduce(functional_1.reduceFlat, []);
53
+ const fnsToDelete = allRegionalChanges
48
54
  .map((regionalChanges) => regionalChanges.endpointsToDelete)
49
55
  .reduce(functional_1.reduceFlat, []);
50
56
  const shouldDelete = await prompts.promptForFunctionDeletion(fnsToDelete, options);
51
57
  if (!shouldDelete) {
52
- for (const change of Object.values(plan)) {
53
- change.endpointsToDelete = [];
58
+ for (const changes of allRegionalChanges) {
59
+ changes.endpointsToDelete = [];
54
60
  }
55
61
  }
56
- const fnsToUpdate = Object.values(plan)
62
+ const fnsToUpdate = allRegionalChanges
57
63
  .map((regionalChanges) => regionalChanges.endpointsToUpdate)
58
64
  .reduce(functional_1.reduceFlat, []);
59
65
  const fnsToUpdateSafe = await prompts.promptForUnsafeMigration(fnsToUpdate, options);
60
- for (const key of Object.keys(plan)) {
61
- plan[key].endpointsToUpdate = [];
62
- }
63
- for (const eu of fnsToUpdateSafe) {
64
- const e = eu.endpoint;
65
- const key = `${e.codebase || ""}-${e.region}-${e.availableMemoryMb || "default"}`;
66
- plan[key].endpointsToUpdate.push(eu);
66
+ const safeEndpoints = new Set(fnsToUpdateSafe.map((eu) => eu.endpoint));
67
+ for (const changes of allRegionalChanges) {
68
+ changes.endpointsToUpdate = changes.endpointsToUpdate.filter((eu) => safeEndpoints.has(eu.endpoint));
67
69
  }
68
70
  const throttlerOptions = {
69
71
  retries: 30,
@@ -83,18 +85,31 @@ async function release(context, options, payload) {
83
85
  sources: context.sources,
84
86
  appEngineLocation: (0, functionsConfig_1.getAppEngineLocation)(context.firebaseConfig),
85
87
  projectNumber: projectNumber,
88
+ projectId: context.projectId,
86
89
  });
87
90
  const summary = await fab.applyPlan(plan);
88
91
  await reporter.logAndTrackDeployStats(summary, context);
89
92
  reporter.printErrors(summary);
90
93
  const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend));
91
94
  printTriggerUrls(wantBackend, projectNumber);
92
- for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) {
93
- await (0, lifecycle_1.executeLifecycleHooks)(w, h, plan, codebase);
95
+ if (backend.someEndpoint(wantBackend, (endpoint) => (0, supported_1.runtimeIsLanguage)(endpoint.runtime, "dart"))) {
96
+ utils.logLabeledBullet("functions", "Dart functions may not yet be visible in the Firebase Console. " +
97
+ `View them in the Cloud Console at https://console.cloud.google.com/run/services?project=${context.projectId}`);
94
98
  }
95
- await setupArtifactCleanupPolicies(options, options.projectId, Object.keys(wantBackend.endpoints));
96
99
  const allErrors = summary.results.filter((r) => r.error).map((r) => r.error);
100
+ if (allErrors.length === 0) {
101
+ for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) {
102
+ await (0, lifecycle_1.executeLifecycleHooks)(w, h, plan, codebase);
103
+ }
104
+ }
105
+ await setupArtifactCleanupPolicies(options, options.projectId, Object.keys(wantBackend.endpoints));
97
106
  if (allErrors.length) {
107
+ for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) {
108
+ const event = (0, lifecycle_1.determineDeploymentEvent)(h);
109
+ if (w.lifecycleHooks?.[event]) {
110
+ utils.logLabeledWarning("functions", `Lifecycle hook "${event}" for codebase "${codebase}" was configured but not executed because one or more function deployments failed.`);
111
+ }
112
+ }
98
113
  const opts = allErrors.length === 1 ? { original: allErrors[0] } : { children: allErrors };
99
114
  logger_1.logger.debug("Functions deploy failed.");
100
115
  for (const error of allErrors) {
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.determineDeploymentDelta = determineDeploymentDelta;
3
+ exports.determineDeploymentEvent = determineDeploymentEvent;
4
4
  exports.executeLifecycleHooks = executeLifecycleHooks;
5
+ exports.executeHook = executeHook;
5
6
  const backend = require("../backend");
6
7
  const error_1 = require("../../../error");
7
8
  const logger_1 = require("../../../logger");
@@ -10,58 +11,44 @@ const cloudtasks = require("../../../gcp/cloudtasks");
10
11
  const computeEngine = require("../../../gcp/computeEngine");
11
12
  const projects_1 = require("../../../management/projects");
12
13
  const functional_1 = require("../../../functional");
13
- function determineDeploymentDelta(haveBackend) {
14
+ function determineDeploymentEvent(haveBackend) {
14
15
  const hasExistingEndpoints = backend.someEndpoint(haveBackend, () => true);
15
16
  if (!hasExistingEndpoints) {
16
- return "afterInstall";
17
+ return "afterFirstDeploy";
17
18
  }
18
- return "afterUpdate";
19
+ return "afterRedeploy";
19
20
  }
20
21
  async function executeLifecycleHooks(wantBackend, haveBackend, plan, codebase) {
21
- const delta = determineDeploymentDelta(haveBackend);
22
+ const event = determineDeploymentEvent(haveBackend);
22
23
  const hooks = wantBackend.lifecycleHooks || {};
23
- const hook = hooks[delta];
24
+ const hook = hooks[event];
24
25
  if (!hook) {
25
- logger_1.logger.debug(`No lifecycle hook configured for event: ${delta}`);
26
+ logger_1.logger.debug(`No lifecycle hook configured for event: ${event}`);
26
27
  return false;
27
28
  }
28
- if (delta === "afterUpdate" && plan) {
29
- const relevantChangesets = Object.entries(plan)
30
- .filter(([key]) => {
31
- if (!codebase)
32
- return true;
33
- if (key.startsWith(`${codebase}-`))
34
- return true;
35
- if (codebase === "default" && key.startsWith("-"))
36
- return true;
37
- return false;
38
- })
39
- .map(([, c]) => c);
40
- const hasResourceModifications = relevantChangesets.some((changeset) => changeset.endpointsToCreate.length > 0 ||
29
+ if (event === "afterRedeploy" && plan) {
30
+ const codebasePlans = codebase ? [plan[codebase]].filter(Boolean) : Object.values(plan);
31
+ const hasResourceModifications = codebasePlans.some((codebasePlan) => Object.values(codebasePlan.regionalChangesets).some((changeset) => changeset.endpointsToCreate.length > 0 ||
41
32
  changeset.endpointsToUpdate.length > 0 ||
42
- changeset.endpointsToDelete.length > 0);
33
+ changeset.endpointsToDelete.length > 0));
43
34
  if (!hasResourceModifications) {
44
- (0, utils_1.logLabeledBullet)("functions", "No resources modified in codebase. Skipping afterUpdate lifecycle hook.");
35
+ (0, utils_1.logLabeledBullet)("functions", `No resources modified for codebase: ${codebase ?? "default"}. Skipping afterRedeploy lifecycle hook.`);
45
36
  return false;
46
37
  }
47
38
  }
48
- if ("task" in hook) {
49
- (0, utils_1.logLabeledBullet)("functions", `Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`);
50
- await executeTaskQueueHook(hook.task, wantBackend);
39
+ try {
40
+ await executeHook(event, hook, wantBackend);
51
41
  return true;
52
42
  }
53
- else if ("call" in hook) {
54
- throw new error_1.FirebaseError(`Lifecycle hook action type "call" is not supported.`);
55
- }
56
- else if ("http" in hook) {
57
- throw new error_1.FirebaseError(`Lifecycle hook action type "http" is not supported.`);
58
- }
59
- else {
60
- (0, functional_1.assertExhaustive)(hook);
43
+ catch (err) {
44
+ const errorMsg = err instanceof Error ? err.message : String(err);
45
+ (0, utils_1.logLabeledWarning)("functions", `Failed to execute ${event} lifecycle hook: ${errorMsg}`);
46
+ (0, utils_1.logLabeledBullet)("functions", `You can retry the lifecycle hook in isolation by running: firebase functions:lifecycle:run ${event} ${codebase ?? "default"}`);
47
+ return false;
61
48
  }
62
49
  }
63
50
  async function executeTaskQueueHook(taskHook, wantBackend) {
64
- const targetEndpoint = findTargetEndpoint(wantBackend, taskHook.function);
51
+ const targetEndpoint = backend.findEndpoint(wantBackend, (ep) => ep.id === taskHook.function);
65
52
  if (!targetEndpoint) {
66
53
  throw new error_1.FirebaseError(`Target endpoint "${taskHook.function}" not found in backend for lifecycle hook.`);
67
54
  }
@@ -94,23 +81,9 @@ async function executeTaskQueueHook(taskHook, wantBackend) {
94
81
  if (body) {
95
82
  task.httpRequest.body = body;
96
83
  }
97
- try {
98
- await cloudtasks.enqueueTask(queueName, task);
99
- (0, utils_1.logLabeledSuccess)("functions", `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`);
100
- (0, utils_1.logLabeledBullet)("functions", `View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`);
101
- }
102
- catch (err) {
103
- const errorMsg = err instanceof Error ? err.message : String(err);
104
- (0, utils_1.logLabeledWarning)("functions", `Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`);
105
- }
106
- }
107
- function findTargetEndpoint(backendSpec, targetId) {
108
- for (const endpoint of backend.allEndpoints(backendSpec)) {
109
- if (endpoint.id === targetId) {
110
- return endpoint;
111
- }
112
- }
113
- return undefined;
84
+ await cloudtasks.enqueueTask(queueName, task);
85
+ (0, utils_1.logLabeledSuccess)("functions", `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`);
86
+ return targetEndpoint;
114
87
  }
115
88
  function getCloudConsoleLogUrl(endpoint) {
116
89
  const { project, region, id } = endpoint;
@@ -118,3 +91,23 @@ function getCloudConsoleLogUrl(endpoint) {
118
91
  const query = `resource.type="cloud_run_revision"\nresource.labels.service_name="${serviceName}"\nresource.labels.location="${region}"`;
119
92
  return `https://console.cloud.google.com/logs/query;query=${encodeURIComponent(query)};project=${project}`;
120
93
  }
94
+ async function executeHook(event, hook, backendSpec) {
95
+ let executedEndpoint;
96
+ if ("task" in hook) {
97
+ (0, utils_1.logLabeledBullet)("functions", `Executing ${event} lifecycle hook targeting: ${hook.task.function}...`);
98
+ executedEndpoint = await executeTaskQueueHook(hook.task, backendSpec);
99
+ }
100
+ else if ("call" in hook) {
101
+ throw new error_1.FirebaseError(`Lifecycle hook action type "call" is not supported.`);
102
+ }
103
+ else if ("http" in hook) {
104
+ throw new error_1.FirebaseError(`Lifecycle hook action type "http" is not supported.`);
105
+ }
106
+ else {
107
+ (0, functional_1.assertExhaustive)(hook);
108
+ }
109
+ if (executedEndpoint) {
110
+ (0, utils_1.logLabeledBullet)("functions", `View logs for ${event} at: ${getCloudConsoleLogUrl(executedEndpoint)}`);
111
+ }
112
+ return executedEndpoint;
113
+ }
@@ -75,9 +75,25 @@ function calculateUpdate(want, have) {
75
75
  }
76
76
  return update;
77
77
  }
78
- function createDeploymentPlan(args) {
79
- let { wantBackend, haveBackend, codebase, filters, deleteAll } = args;
80
- let deployment = {};
78
+ async function createDeploymentPlan(args) {
79
+ let { wantBackend, haveBackend, codebase, filters, deleteAll, haveRoles, existingManagedSA, managedSA, } = args;
80
+ const requiredRoles = wantBackend.requiredRoles;
81
+ const roles = haveRoles || [];
82
+ let rolesToAdd;
83
+ let rolesToRemove;
84
+ let serviceAccountToCreate;
85
+ let serviceAccountToDelete;
86
+ const isFiltered = !!(filters && filters.length > 0 && !deleteAll);
87
+ if (requiredRoles) {
88
+ rolesToAdd = requiredRoles.filter((r) => !roles.includes(r));
89
+ rolesToRemove = roles.filter((r) => !requiredRoles.includes(r));
90
+ if (!existingManagedSA && managedSA) {
91
+ serviceAccountToCreate = managedSA;
92
+ }
93
+ }
94
+ else if (existingManagedSA && !isFiltered) {
95
+ serviceAccountToDelete = existingManagedSA;
96
+ }
81
97
  wantBackend = backend.matchingBackend(wantBackend, (endpoint) => {
82
98
  return (0, functionsDeployHelper_1.endpointMatchesAnyFilter)(endpoint, filters);
83
99
  });
@@ -85,13 +101,14 @@ function createDeploymentPlan(args) {
85
101
  haveBackend = backend.matchingBackend(haveBackend, (endpoint) => {
86
102
  return wantedEndpoint(endpoint) || (0, functionsDeployHelper_1.endpointMatchesAnyFilter)(endpoint, filters);
87
103
  });
104
+ const regionalChangesets = {};
88
105
  const regions = new Set([
89
106
  ...Object.keys(wantBackend.endpoints),
90
107
  ...Object.keys(haveBackend.endpoints),
91
108
  ]);
92
109
  for (const region of regions) {
93
110
  const changesets = calculateChangesets(wantBackend.endpoints[region] || {}, haveBackend.endpoints[region] || {}, (e) => `${codebase}-${e.region}-${e.availableMemoryMb || "default"}`, deleteAll);
94
- deployment = { ...deployment, ...changesets };
111
+ Object.assign(regionalChangesets, changesets);
95
112
  }
96
113
  if (upgradedToGCFv2WithoutSettingConcurrency(wantBackend, haveBackend)) {
97
114
  utils.logLabeledBullet("functions", "You are updating one or more functions to Google Cloud Functions v2, " +
@@ -99,7 +116,26 @@ function createDeploymentPlan(args) {
99
116
  "default to 80 concurrent executions, but existing functions keep the " +
100
117
  "old default of 1. You can change this with the 'concurrency' option.");
101
118
  }
102
- return deployment;
119
+ if (requiredRoles) {
120
+ if (!managedSA) {
121
+ throw new error_1.FirebaseError("managedServiceAccount is required when requiredRoles is defined.", {
122
+ exit: 1,
123
+ });
124
+ }
125
+ return {
126
+ regionalChangesets,
127
+ rolesToAdd,
128
+ rolesToRemove,
129
+ serviceAccountToCreate,
130
+ managedServiceAccount: managedSA,
131
+ };
132
+ }
133
+ else {
134
+ return {
135
+ regionalChangesets,
136
+ serviceAccountToDelete,
137
+ };
138
+ }
103
139
  }
104
140
  function upgradedToGCFv2WithoutSettingConcurrency(want, have) {
105
141
  return backend.someEndpoint(want, (endpoint) => {
@@ -5,7 +5,6 @@ exports.yamlToBuild = yamlToBuild;
5
5
  exports.detectFromYaml = detectFromYaml;
6
6
  exports.detectFromPort = detectFromPort;
7
7
  exports.detectFromOutputPath = detectFromOutputPath;
8
- const node_fetch_1 = require("node-fetch");
9
8
  const fs = require("fs");
10
9
  const path = require("path");
11
10
  const yaml = require("yaml");
@@ -65,12 +64,14 @@ async function detectFromPort(port, project, runtime, initialDelay = 0, timeout
65
64
  const url = `http://127.0.0.1:${port}/__/functions.yaml`;
66
65
  while (true) {
67
66
  try {
68
- res = await Promise.race([(0, node_fetch_1.default)(url), timedOut]);
67
+ res = await Promise.race([fetch(url), timedOut]);
69
68
  break;
70
69
  }
71
70
  catch (err) {
71
+ const realErr = err?.cause || err;
72
72
  if (err?.name === "FetchError" ||
73
- ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(err?.code)) {
73
+ realErr?.name === "FetchError" ||
74
+ ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(realErr?.code)) {
74
75
  continue;
75
76
  }
76
77
  throw err;
@@ -20,6 +20,7 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
20
20
  specVersion: "string",
21
21
  params: "array",
22
22
  requiredAPIs: "array",
23
+ requiredRoles: "array",
23
24
  endpoints: "object",
24
25
  extensions: "object",
25
26
  lifecycleHooks: "object",
@@ -27,6 +28,9 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
27
28
  const bd = build.empty();
28
29
  bd.params = manifest.params || [];
29
30
  bd.requiredAPIs = parseRequiredAPIs(manifest);
31
+ if (manifest.requiredRoles) {
32
+ bd.requiredRoles = parseRequiredRoles(manifest);
33
+ }
30
34
  for (const id of Object.keys(manifest.endpoints)) {
31
35
  const me = manifest.endpoints[id];
32
36
  assertBuildEndpoint(me, id);
@@ -45,7 +49,7 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
45
49
  if (manifest.lifecycleHooks) {
46
50
  bd.lifecycleHooks = {};
47
51
  for (const id of Object.keys(manifest.lifecycleHooks)) {
48
- if (id !== "afterInstall" && id !== "afterUpdate") {
52
+ if (id !== "afterFirstDeploy" && id !== "afterRedeploy") {
49
53
  throw new error_1.FirebaseError(`Invalid eventType "${id}" for lifecycle hook.`);
50
54
  }
51
55
  const hook = manifest.lifecycleHooks[id];
@@ -76,6 +80,19 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
76
80
  }
77
81
  return bd;
78
82
  }
83
+ const ROLE_REGEX = /^(roles\/[a-zA-Z0-9_\.\-]+|projects\/[a-zA-Z0-9\-]+\/roles\/[a-zA-Z0-9_\.\-]+|organizations\/[0-9]+\/roles\/[a-zA-Z0-9_\.\-]+)$/;
84
+ function parseRequiredRoles(manifest) {
85
+ const roles = manifest.requiredRoles || [];
86
+ for (const role of roles) {
87
+ if (typeof role !== "string") {
88
+ throw new error_1.FirebaseError(`Invalid role "${JSON.stringify(role)}". Expected string`);
89
+ }
90
+ if (!ROLE_REGEX.test(role)) {
91
+ throw new error_1.FirebaseError(`Invalid IAM role format "${role}".`);
92
+ }
93
+ }
94
+ return Array.from(new Set(roles));
95
+ }
79
96
  function parseRequiredAPIs(manifest) {
80
97
  const requiredAPIs = manifest.requiredAPIs || [];
81
98
  for (const { api, reason } of requiredAPIs) {
@@ -8,7 +8,6 @@ const path = require("path");
8
8
  const portfinder = require("portfinder");
9
9
  const semver = require("semver");
10
10
  const spawn = require("cross-spawn");
11
- const node_fetch_1 = require("node-fetch");
12
11
  const error_1 = require("../../../../error");
13
12
  const parseRuntimeAndValidateSDK_1 = require("./parseRuntimeAndValidateSDK");
14
13
  const logger_1 = require("../../../../logger");
@@ -156,7 +155,7 @@ class Delegate {
156
155
  childProcess.once("error", reject);
157
156
  });
158
157
  try {
159
- await (0, node_fetch_1.default)(`http://localhost:${port}/__/quitquitquit`);
158
+ await fetch(`http://localhost:${port}/__/quitquitquit`);
160
159
  }
161
160
  catch (e) {
162
161
  logger_1.logger.debug("Failed to call quitquitquit. This often means the server failed to start", e);
@@ -5,7 +5,6 @@ exports.tryCreateDelegate = tryCreateDelegate;
5
5
  exports.getPythonBinary = getPythonBinary;
6
6
  const fs = require("fs");
7
7
  const path = require("path");
8
- const node_fetch_1 = require("node-fetch");
9
8
  const util_1 = require("util");
10
9
  const portfinder = require("portfinder");
11
10
  const discovery = require("../discovery");
@@ -130,7 +129,7 @@ class Delegate {
130
129
  });
131
130
  return Promise.resolve(async () => {
132
131
  try {
133
- await (0, node_fetch_1.default)(`http://127.0.0.1:${port}/__/quitquitquit`);
132
+ await fetch(`http://127.0.0.1:${port}/__/quitquitquit`);
134
133
  }
135
134
  catch (e) {
136
135
  logger_1.logger.debug("Failed to call quitquitquit. This often means the server failed to start", e);
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Uploader = void 0;
4
4
  const lodash_1 = require("lodash");
5
- const abort_controller_1 = require("abort-controller");
6
5
  const clc = require("colorette");
7
6
  const crypto = require("crypto");
8
7
  const fs = require("fs");
@@ -172,7 +171,7 @@ class Uploader {
172
171
  if (!this.uploadClient) {
173
172
  throw new error_1.FirebaseError("No upload client available.", { exit: 2 });
174
173
  }
175
- const controller = new abort_controller_1.default();
174
+ const controller = new AbortController();
176
175
  const timeout = setTimeout(() => {
177
176
  controller.abort();
178
177
  }, this.uploadTimeout(this.hashMap[toUpload]));
@@ -190,7 +189,7 @@ class Uploader {
190
189
  }
191
190
  if (res.status !== 200) {
192
191
  const errorMessage = await res.response.text();
193
- logger_1.logger.debug(`[hosting][upload] ${this.hashMap[toUpload]} (${toUpload}) HTTP ERROR ${res.status}: headers=${JSON.stringify(res.response.headers.raw())} ${errorMessage}`);
192
+ logger_1.logger.debug(`[hosting][upload] ${this.hashMap[toUpload]} (${toUpload}) HTTP ERROR ${res.status}: headers=${JSON.stringify(Object.fromEntries(res.response.headers.entries()))} ${errorMessage}`);
194
193
  throw new Error(`Unexpected error while uploading file: ${errorMessage}`);
195
194
  }
196
195
  }