firebase-tools 15.22.3 → 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 (49) hide show
  1. package/lib/accountExporter.js +1 -0
  2. package/lib/accountImporter.js +1 -0
  3. package/lib/apiv2.js +91 -32
  4. package/lib/apphosting/backend.js +11 -4
  5. package/lib/apphosting/localbuilds.js +50 -0
  6. package/lib/commands/functions-delete.js +5 -3
  7. package/lib/commands/functions-lifecycle-list.js +94 -0
  8. package/lib/commands/functions-lifecycle-run.js +29 -0
  9. package/lib/commands/index.js +3 -0
  10. package/lib/database/import.js +3 -3
  11. package/lib/dataconnect/webhook.js +1 -2
  12. package/lib/deploy/apphosting/prepare.js +100 -1
  13. package/lib/deploy/functions/backend.js +15 -1
  14. package/lib/deploy/functions/build.js +28 -0
  15. package/lib/deploy/functions/prepare.js +91 -2
  16. package/lib/deploy/functions/prompts.js +62 -0
  17. package/lib/deploy/functions/release/fabricator.js +79 -4
  18. package/lib/deploy/functions/release/index.js +42 -23
  19. package/lib/deploy/functions/release/lifecycle.js +113 -0
  20. package/lib/deploy/functions/release/planner.js +41 -5
  21. package/lib/deploy/functions/runtimes/discovery/index.js +4 -3
  22. package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +50 -0
  23. package/lib/deploy/functions/runtimes/node/index.js +1 -2
  24. package/lib/deploy/functions/runtimes/python/index.js +1 -2
  25. package/lib/deploy/functions/validate.js +44 -1
  26. package/lib/deploy/hosting/uploader.js +2 -3
  27. package/lib/downloadUtils.js +3 -1
  28. package/lib/emulator/auth/operations.js +18 -8
  29. package/lib/emulator/downloadableEmulatorInfo.json +31 -31
  30. package/lib/emulator/storage/apis/gcloud.js +61 -0
  31. package/lib/emulator/storage/multipart.js +82 -2
  32. package/lib/emulator/taskQueue.js +3 -11
  33. package/lib/extensions/extensionsHelper.js +6 -4
  34. package/lib/firestore/api.js +3 -1
  35. package/lib/gcp/cloudtasks.js +4 -0
  36. package/lib/gcp/iam.js +68 -2
  37. package/lib/gcp/knownRoles.json +99 -0
  38. package/lib/gcp/resourceManager.js +42 -1
  39. package/lib/gemini/fdcExperience.js +2 -2
  40. package/lib/hosting/initMiddleware.js +1 -1
  41. package/lib/hosting/proxy.js +42 -20
  42. package/lib/management/apps.js +4 -2
  43. package/lib/profiler.js +1 -2
  44. package/lib/streamUtils.js +24 -0
  45. package/lib/track.js +1 -2
  46. package/lib/tsconfig.compile.tsbuildinfo +1 -1
  47. package/lib/tsconfig.publish.tsbuildinfo +1 -1
  48. package/lib/utils.js +4 -21
  49. package/package.json +2 -4
@@ -4,6 +4,7 @@ exports.default = default_1;
4
4
  exports.injectEnvVarsFromApphostingConfig = injectEnvVarsFromApphostingConfig;
5
5
  exports.injectAutoInitEnvVars = injectAutoInitEnvVars;
6
6
  exports.getBackendConfigs = getBackendConfigs;
7
+ exports.injectAngularEnvVars = injectAngularEnvVars;
7
8
  const crypto = require("crypto");
8
9
  const fs = require("fs");
9
10
  const os = require("os");
@@ -134,10 +135,29 @@ async function default_1(context, options) {
134
135
  continue;
135
136
  }
136
137
  experiments.assertEnabled("apphostinglocalbuilds", "locally build App Hosting backends");
138
+ let backend = backends.find((b) => (0, apphosting_1.parseBackendName)(b.name).id === cfg.backendId);
139
+ if (!backend) {
140
+ const location = context.backendLocations[cfg.backendId];
141
+ if (location) {
142
+ const apphosting = await Promise.resolve().then(() => require("../../gcp/apphosting"));
143
+ try {
144
+ backend = await apphosting.getBackend(projectId, location, cfg.backendId);
145
+ }
146
+ catch {
147
+ }
148
+ }
149
+ }
150
+ if (!backend) {
151
+ throw new error_1.FirebaseError(`Backend ${cfg.backendId} not found.`);
152
+ }
153
+ const rootDir = path.resolve(options.projectRoot || process.cwd());
154
+ const appDir = path.join(rootDir, cfg.rootDir || "");
155
+ (0, localbuilds_1.validateLocalBuildNodeVersion)(backend, appDir);
137
156
  (0, utils_1.logLabeledBullet)("apphosting", `Starting local build for backend ${cfg.backendId}`);
138
157
  await injectEnvVarsFromApphostingConfig(configs.filter((c) => c.backendId === cfg.backendId), options, buildEnv, runtimeEnv);
139
158
  await injectAutoInitEnvVars(cfg, backends, buildEnv, runtimeEnv);
140
- const rootDir = path.resolve(options.projectRoot || process.cwd());
159
+ const location = context.backendLocations[cfg.backendId];
160
+ await injectAngularEnvVars(cfg, rootDir, projectId, location, buildEnv, runtimeEnv);
141
161
  const pathHash = crypto.createHash("md5").update(rootDir).digest("hex").substring(0, 8);
142
162
  const localBuildScratchDir = path.join(os.tmpdir(), `apphosting-local-build-${cfg.backendId}-${pathHash}`);
143
163
  try {
@@ -266,3 +286,82 @@ async function prepareLocalBuildScratchDirectory(rootDir, localBuildScratchDir,
266
286
  fs.copyFileSync(file.name, destPath);
267
287
  }
268
288
  }
289
+ function isAngularApplication(appDir) {
290
+ const packageJsonPath = path.join(appDir, "package.json");
291
+ if (fs.existsSync(packageJsonPath)) {
292
+ try {
293
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
294
+ if (parsed && typeof parsed === "object") {
295
+ const pkg = parsed;
296
+ const angularDeps = ["@angular/core", "@angular/ssr", "@angular/platform-server"];
297
+ if (angularDeps.some((d) => pkg.dependencies?.[d] || pkg.devDependencies?.[d])) {
298
+ return true;
299
+ }
300
+ }
301
+ }
302
+ catch (e) {
303
+ (0, utils_1.logLabeledError)("apphosting", `error when checking if application is angular: ${e instanceof Error ? e.message : String(e)}`);
304
+ }
305
+ }
306
+ const angularJsonPath = path.join(appDir, "angular.json");
307
+ if (fs.existsSync(angularJsonPath)) {
308
+ return true;
309
+ }
310
+ return false;
311
+ }
312
+ async function injectAngularEnvVars(cfg, projectRoot, projectId, location, buildEnv, runtimeEnv) {
313
+ const appDir = path.join(projectRoot, cfg.rootDir || "");
314
+ if (!isAngularApplication(appDir)) {
315
+ return;
316
+ }
317
+ if (!location) {
318
+ throw new error_1.FirebaseError(`Failed to find location for backend ${cfg.backendId}.`);
319
+ }
320
+ const backendId = cfg.backendId;
321
+ runtimeEnv[backendId] ?? (runtimeEnv[backendId] = {});
322
+ buildEnv[backendId] ?? (buildEnv[backendId] = {});
323
+ const allowedProxyHeaders = [
324
+ "x-forwarded-host",
325
+ "x-forwarded-port",
326
+ "x-forwarded-proto",
327
+ "x-forwarded-for",
328
+ ];
329
+ const allowedProxyHeadersValue = allowedProxyHeaders.join(",");
330
+ let shouldInjectProxyHeaders = true;
331
+ if (runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"]) {
332
+ const userProxyHeadersStr = runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"].value;
333
+ const userProxyHeaders = userProxyHeadersStr
334
+ ? userProxyHeadersStr
335
+ .split(",")
336
+ .map((h) => h.trim().toLowerCase())
337
+ .filter(Boolean)
338
+ : [];
339
+ const isSubset = userProxyHeaders.length > 0 && userProxyHeaders.every((h) => allowedProxyHeaders.includes(h));
340
+ if (isSubset) {
341
+ shouldInjectProxyHeaders = false;
342
+ }
343
+ else {
344
+ throw new error_1.FirebaseError(`User-defined RUNTIME environment variable NG_TRUST_PROXY_HEADERS contains invalid headers. Allowed values: ${allowedProxyHeadersValue}`);
345
+ }
346
+ }
347
+ if (shouldInjectProxyHeaders) {
348
+ runtimeEnv[backendId]["NG_TRUST_PROXY_HEADERS"] = {
349
+ value: allowedProxyHeadersValue,
350
+ availability: ["RUNTIME"],
351
+ };
352
+ }
353
+ if (!runtimeEnv[backendId]["NG_ALLOWED_HOSTS"]) {
354
+ const projectNumber = await (0, getProjectNumber_1.getProjectNumber)({ project: projectId });
355
+ const sharedDomain = "hosted.app";
356
+ const defaultHosts = [
357
+ `${backendId}-${projectNumber}.${location}.run.app`,
358
+ `${backendId}--${projectId}.${location}.${sharedDomain}`,
359
+ `${backendId}--${projectId}.web.app`,
360
+ `${backendId}--${projectId}.firebaseapp.com`,
361
+ ];
362
+ runtimeEnv[backendId]["NG_ALLOWED_HOSTS"] = {
363
+ value: defaultHosts.join(","),
364
+ availability: ["RUNTIME"],
365
+ };
366
+ }
367
+ }
@@ -165,6 +165,7 @@ function of(...endpoints) {
165
165
  function merge(...backends) {
166
166
  const merged = of(...(0, functional_1.flattenArray)(backends.map((b) => allEndpoints(b))));
167
167
  const apiToReasons = {};
168
+ const requiredRoles = new Set();
168
169
  for (const b of backends) {
169
170
  for (const { api, reason } of b.requiredAPIs) {
170
171
  const reasons = apiToReasons[api] || new Set();
@@ -174,14 +175,27 @@ function merge(...backends) {
174
175
  apiToReasons[api] = reasons;
175
176
  }
176
177
  merged.environmentVariables = { ...merged.environmentVariables, ...b.environmentVariables };
178
+ if (b.requiredRoles) {
179
+ for (const role of b.requiredRoles) {
180
+ requiredRoles.add(role);
181
+ }
182
+ }
183
+ if (b.lifecycleHooks) {
184
+ merged.lifecycleHooks = { ...(merged.lifecycleHooks || {}), ...b.lifecycleHooks };
185
+ }
177
186
  }
178
187
  for (const [api, reasons] of Object.entries(apiToReasons)) {
179
188
  merged.requiredAPIs.push({ api, reason: Array.from(reasons).join(" ") });
180
189
  }
190
+ if (requiredRoles.size > 0) {
191
+ merged.requiredRoles = Array.from(requiredRoles);
192
+ }
181
193
  return merged;
182
194
  }
183
195
  function isEmptyBackend(backend) {
184
- return (Object.keys(backend.requiredAPIs).length === 0 && Object.keys(backend.endpoints).length === 0);
196
+ return (Object.keys(backend.requiredAPIs).length === 0 &&
197
+ Object.keys(backend.endpoints).length === 0 &&
198
+ Object.keys(backend.lifecycleHooks || {}).length === 0);
185
199
  }
186
200
  function functionName(cloudFunction) {
187
201
  return `projects/${cloudFunction.project}/locations/${cloudFunction.region}/functions/${cloudFunction.id}`;
@@ -263,6 +263,12 @@ function toBackend(build, paramValues) {
263
263
  }
264
264
  const bkend = backend.of(...bkEndpoints);
265
265
  bkend.requiredAPIs = build.requiredAPIs;
266
+ if (build.requiredRoles) {
267
+ bkend.requiredRoles = build.requiredRoles;
268
+ }
269
+ if (build.lifecycleHooks) {
270
+ bkend.lifecycleHooks = build.lifecycleHooks;
271
+ }
266
272
  return bkend;
267
273
  }
268
274
  function discoverTrigger(endpoint, region, r) {
@@ -375,4 +381,26 @@ function applyPrefix(build, prefix) {
375
381
  }
376
382
  }
377
383
  build.endpoints = newEndpoints;
384
+ if (build.lifecycleHooks) {
385
+ for (const hook of Object.values(build.lifecycleHooks)) {
386
+ if ("task" in hook) {
387
+ if (hook.task?.function) {
388
+ hook.task.function = `${prefix}-${hook.task.function}`;
389
+ }
390
+ }
391
+ else if ("call" in hook) {
392
+ if (hook.call?.function) {
393
+ hook.call.function = `${prefix}-${hook.call.function}`;
394
+ }
395
+ }
396
+ else if ("http" in hook) {
397
+ if (hook.http?.function) {
398
+ hook.http.function = `${prefix}-${hook.http.function}`;
399
+ }
400
+ }
401
+ else {
402
+ (0, functional_1.assertExhaustive)(hook);
403
+ }
404
+ }
405
+ }
378
406
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EVENTARC_SOURCE_ENV = void 0;
4
+ exports.discoverSecurityDetails = discoverSecurityDetails;
4
5
  exports.prepare = prepare;
5
6
  exports.resolveDefaultRegionsForBuild = resolveDefaultRegionsForBuild;
6
7
  exports.inferDetailsFromExisting = inferDetailsFromExisting;
@@ -43,7 +44,94 @@ const backend_1 = require("./backend");
43
44
  const functional_1 = require("../../functional");
44
45
  const prepare_1 = require("../extensions/prepare");
45
46
  const prompt = require("../../prompt");
47
+ const iam = require("../../gcp/iam");
48
+ const resourcemanager = require("../../gcp/resourceManager");
46
49
  exports.EVENTARC_SOURCE_ENV = "EVENTARC_CLOUD_EVENT_SOURCE";
50
+ async function discoverSecurityDetails(codebase, want, have, projectId) {
51
+ const requiredRoles = want.requiredRoles;
52
+ const firstHave = backend.allEndpoints(have)[0];
53
+ let existingManagedSA;
54
+ let haveRolesEtag;
55
+ if (firstHave) {
56
+ haveRolesEtag = firstHave.labels?.["firebase-declarative-security-etag"];
57
+ existingManagedSA = firstHave.serviceAccount?.startsWith("firebase-fn-")
58
+ ? firstHave.serviceAccount
59
+ : undefined;
60
+ }
61
+ if (!requiredRoles && (!existingManagedSA || !haveRolesEtag)) {
62
+ return {};
63
+ }
64
+ if (requiredRoles &&
65
+ backend.someEndpoint(want, (e) => typeof e.serviceAccount === "string" &&
66
+ e.serviceAccount !== "default" &&
67
+ !e.serviceAccount.startsWith("firebase-fn-"))) {
68
+ throw new error_1.FirebaseError(`Cannot use explicit custom service accounts on functions while using declarative security in codebase ${codebase}.`);
69
+ }
70
+ if (!requiredRoles && existingManagedSA && haveRolesEtag) {
71
+ for (const endpoint of backend.allEndpoints(want)) {
72
+ if (!endpoint.serviceAccount || endpoint.serviceAccount === existingManagedSA) {
73
+ endpoint.serviceAccount = "default";
74
+ }
75
+ if (endpoint.labels) {
76
+ delete endpoint.labels["firebase-declarative-security-etag"];
77
+ }
78
+ }
79
+ return {
80
+ haveRolesEtag,
81
+ existingManagedSA,
82
+ };
83
+ }
84
+ let managedSA = existingManagedSA;
85
+ if (!managedSA) {
86
+ const saToCreate = await iam.generateManagedServiceAccountName(projectId, "firebase-fn");
87
+ managedSA = `${saToCreate}@${projectId}.iam.gserviceaccount.com`;
88
+ }
89
+ const existingSalt = haveRolesEtag ? haveRolesEtag.split("-")[0] : undefined;
90
+ const newEtag = iam.computeRolesEtag(requiredRoles, existingSalt);
91
+ for (const endpoint of backend.allEndpoints(want)) {
92
+ endpoint.serviceAccount = managedSA;
93
+ endpoint.labels = endpoint.labels || {};
94
+ endpoint.labels["firebase-declarative-security-etag"] = newEtag;
95
+ }
96
+ if (haveRolesEtag && haveRolesEtag === newEtag) {
97
+ return {
98
+ haveRoles: requiredRoles,
99
+ haveRolesEtag,
100
+ existingManagedSA,
101
+ managedSA,
102
+ newEtag,
103
+ };
104
+ }
105
+ const permissionsToTest = ["resourcemanager.projects.setIamPolicy"];
106
+ if (!existingManagedSA) {
107
+ permissionsToTest.push("iam.serviceAccounts.create");
108
+ }
109
+ const iamResult = await iam.testIamPermissions(projectId, permissionsToTest);
110
+ if (!iamResult.passed) {
111
+ if (!existingManagedSA) {
112
+ 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.`);
113
+ }
114
+ else {
115
+ throw new error_1.FirebaseError(`You do not have access to make the policy changes required in codebase ${codebase} deploy. Please ask an IAM administrator to perform the next deploy.`);
116
+ }
117
+ }
118
+ let haveRoles = [];
119
+ if (existingManagedSA) {
120
+ try {
121
+ haveRoles = await resourcemanager.getServiceAccountRoles(projectId, managedSA);
122
+ }
123
+ catch (err) {
124
+ throw new error_1.FirebaseError(`The declarative security roles for codebase ${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: err });
125
+ }
126
+ }
127
+ return {
128
+ haveRoles,
129
+ haveRolesEtag,
130
+ existingManagedSA,
131
+ managedSA,
132
+ newEtag,
133
+ };
134
+ }
47
135
  async function prepare(context, options, payload) {
48
136
  const projectId = (0, projectUtils_1.needProjectId)(options);
49
137
  const projectNumber = await (0, projectUtils_1.needProjectNumber)(options);
@@ -196,14 +284,15 @@ async function prepare(context, options, payload) {
196
284
  const haveBackends = (0, functionsDeployHelper_1.groupEndpointsByCodebase)(wantBackends, backend.allEndpoints(existingBackend));
197
285
  for (const [codebase, wantBackend] of Object.entries(wantBackends)) {
198
286
  const haveBackend = haveBackends[codebase] || backend.empty();
199
- payload.functions[codebase] = { wantBackend, haveBackend };
287
+ const security = await discoverSecurityDetails(codebase, wantBackend, haveBackend, projectId);
288
+ payload.functions[codebase] = { wantBackend, haveBackend, ...security };
200
289
  }
201
290
  for (const [codebase, { wantBackend, haveBackend }] of Object.entries(payload.functions)) {
202
291
  inferDetailsFromExisting(wantBackend, haveBackend, codebaseUsesEnvs.includes(codebase));
203
292
  await (0, triggerRegionHelper_1.ensureTriggerRegions)(wantBackend);
204
293
  resolveCpuAndConcurrency(wantBackend);
205
294
  resolveDefaultTimeout(wantBackend);
206
- validate.endpointsAreValid(wantBackend);
295
+ validate.endpointsAreValid(wantBackend, existingBackend);
207
296
  inferBlockingDetails(wantBackend);
208
297
  }
209
298
  const wantBackend = backend.merge(...Object.values(wantBackends));
@@ -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,8 @@ 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");
22
+ const lifecycle_1 = require("./lifecycle");
21
23
  async function release(context, options, payload) {
22
24
  if (context.extensions && payload.extensions) {
23
25
  await (0, extensions_1.release)(context.extensions, options, payload.extensions);
@@ -31,38 +33,39 @@ async function release(context, options, payload) {
31
33
  if (!context.sources) {
32
34
  return;
33
35
  }
34
- let plan = {};
35
- for (const [codebase, { wantBackend, haveBackend }] of Object.entries(payload.functions)) {
36
- plan = {
37
- ...plan,
38
- ...planner.createDeploymentPlan({
39
- codebase,
40
- wantBackend,
41
- haveBackend,
42
- filters: context.filters,
43
- }),
44
- };
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
+ });
45
48
  }
46
- 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
47
54
  .map((regionalChanges) => regionalChanges.endpointsToDelete)
48
55
  .reduce(functional_1.reduceFlat, []);
49
56
  const shouldDelete = await prompts.promptForFunctionDeletion(fnsToDelete, options);
50
57
  if (!shouldDelete) {
51
- for (const change of Object.values(plan)) {
52
- change.endpointsToDelete = [];
58
+ for (const changes of allRegionalChanges) {
59
+ changes.endpointsToDelete = [];
53
60
  }
54
61
  }
55
- const fnsToUpdate = Object.values(plan)
62
+ const fnsToUpdate = allRegionalChanges
56
63
  .map((regionalChanges) => regionalChanges.endpointsToUpdate)
57
64
  .reduce(functional_1.reduceFlat, []);
58
65
  const fnsToUpdateSafe = await prompts.promptForUnsafeMigration(fnsToUpdate, options);
59
- for (const key of Object.keys(plan)) {
60
- plan[key].endpointsToUpdate = [];
61
- }
62
- for (const eu of fnsToUpdateSafe) {
63
- const e = eu.endpoint;
64
- const key = `${e.codebase || ""}-${e.region}-${e.availableMemoryMb || "default"}`;
65
- 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));
66
69
  }
67
70
  const throttlerOptions = {
68
71
  retries: 30,
@@ -82,15 +85,31 @@ async function release(context, options, payload) {
82
85
  sources: context.sources,
83
86
  appEngineLocation: (0, functionsConfig_1.getAppEngineLocation)(context.firebaseConfig),
84
87
  projectNumber: projectNumber,
88
+ projectId: context.projectId,
85
89
  });
86
90
  const summary = await fab.applyPlan(plan);
87
91
  await reporter.logAndTrackDeployStats(summary, context);
88
92
  reporter.printErrors(summary);
89
93
  const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend));
90
94
  printTriggerUrls(wantBackend, projectNumber);
91
- await setupArtifactCleanupPolicies(options, options.projectId, Object.keys(wantBackend.endpoints));
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}`);
98
+ }
92
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));
93
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
+ }
94
113
  const opts = allErrors.length === 1 ? { original: allErrors[0] } : { children: allErrors };
95
114
  logger_1.logger.debug("Functions deploy failed.");
96
115
  for (const error of allErrors) {