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.
- package/lib/accountExporter.js +1 -0
- package/lib/accountImporter.js +1 -0
- package/lib/apiv2.js +91 -32
- package/lib/apphosting/backend.js +11 -4
- package/lib/apphosting/localbuilds.js +50 -0
- package/lib/commands/functions-delete.js +5 -3
- package/lib/commands/functions-lifecycle-list.js +94 -0
- package/lib/commands/functions-lifecycle-run.js +29 -0
- package/lib/commands/index.js +3 -0
- package/lib/database/import.js +3 -3
- package/lib/dataconnect/webhook.js +1 -2
- package/lib/deploy/apphosting/prepare.js +100 -1
- package/lib/deploy/functions/backend.js +15 -1
- package/lib/deploy/functions/build.js +28 -0
- package/lib/deploy/functions/prepare.js +91 -2
- package/lib/deploy/functions/prompts.js +62 -0
- package/lib/deploy/functions/release/fabricator.js +79 -4
- package/lib/deploy/functions/release/index.js +42 -23
- package/lib/deploy/functions/release/lifecycle.js +113 -0
- package/lib/deploy/functions/release/planner.js +41 -5
- package/lib/deploy/functions/runtimes/discovery/index.js +4 -3
- package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +50 -0
- package/lib/deploy/functions/runtimes/node/index.js +1 -2
- package/lib/deploy/functions/runtimes/python/index.js +1 -2
- package/lib/deploy/functions/validate.js +44 -1
- package/lib/deploy/hosting/uploader.js +2 -3
- package/lib/downloadUtils.js +3 -1
- package/lib/emulator/auth/operations.js +18 -8
- package/lib/emulator/downloadableEmulatorInfo.json +31 -31
- package/lib/emulator/storage/apis/gcloud.js +61 -0
- package/lib/emulator/storage/multipart.js +82 -2
- package/lib/emulator/taskQueue.js +3 -11
- package/lib/extensions/extensionsHelper.js +6 -4
- package/lib/firestore/api.js +3 -1
- package/lib/gcp/cloudtasks.js +4 -0
- package/lib/gcp/iam.js +68 -2
- package/lib/gcp/knownRoles.json +99 -0
- package/lib/gcp/resourceManager.js +42 -1
- package/lib/gemini/fdcExperience.js +2 -2
- package/lib/hosting/initMiddleware.js +1 -1
- package/lib/hosting/proxy.js +42 -20
- package/lib/management/apps.js +4 -2
- package/lib/profiler.js +1 -2
- package/lib/streamUtils.js +24 -0
- package/lib/track.js +1 -2
- package/lib/tsconfig.compile.tsbuildinfo +1 -1
- package/lib/tsconfig.publish.tsbuildinfo +1 -1
- package/lib/utils.js +4 -21
- 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
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
35
|
-
for (const [codebase, { wantBackend, haveBackend }] of Object.entries(payload.functions)) {
|
|
36
|
-
plan = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
58
|
+
for (const changes of allRegionalChanges) {
|
|
59
|
+
changes.endpointsToDelete = [];
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
|
-
const fnsToUpdate =
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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) {
|