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.
- package/lib/apiv2.js +91 -32
- package/lib/apphosting/backend.js +11 -4
- 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/functions/backend.js +9 -0
- package/lib/deploy/functions/build.js +3 -0
- package/lib/deploy/functions/prepare.js +90 -1
- package/lib/deploy/functions/prompts.js +62 -0
- package/lib/deploy/functions/release/fabricator.js +79 -4
- package/lib/deploy/functions/release/index.js +40 -25
- package/lib/deploy/functions/release/lifecycle.js +44 -51
- 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 +18 -1
- package/lib/deploy/functions/runtimes/node/index.js +1 -2
- package/lib/deploy/functions/runtimes/python/index.js +1 -2
- 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 +24 -24
- 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/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
|
@@ -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,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
|
-
|
|
36
|
-
for (const [codebase, { wantBackend, haveBackend }] of Object.entries(payload.functions)) {
|
|
37
|
-
plan = {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
58
|
+
for (const changes of allRegionalChanges) {
|
|
59
|
+
changes.endpointsToDelete = [];
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
|
-
const fnsToUpdate =
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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.
|
|
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
|
|
14
|
+
function determineDeploymentEvent(haveBackend) {
|
|
14
15
|
const hasExistingEndpoints = backend.someEndpoint(haveBackend, () => true);
|
|
15
16
|
if (!hasExistingEndpoints) {
|
|
16
|
-
return "
|
|
17
|
+
return "afterFirstDeploy";
|
|
17
18
|
}
|
|
18
|
-
return "
|
|
19
|
+
return "afterRedeploy";
|
|
19
20
|
}
|
|
20
21
|
async function executeLifecycleHooks(wantBackend, haveBackend, plan, codebase) {
|
|
21
|
-
const
|
|
22
|
+
const event = determineDeploymentEvent(haveBackend);
|
|
22
23
|
const hooks = wantBackend.lifecycleHooks || {};
|
|
23
|
-
const hook = hooks[
|
|
24
|
+
const hook = hooks[event];
|
|
24
25
|
if (!hook) {
|
|
25
|
-
logger_1.logger.debug(`No lifecycle hook configured for event: ${
|
|
26
|
+
logger_1.logger.debug(`No lifecycle hook configured for event: ${event}`);
|
|
26
27
|
return false;
|
|
27
28
|
}
|
|
28
|
-
if (
|
|
29
|
-
const
|
|
30
|
-
|
|
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",
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
await executeTaskQueueHook(hook.task, wantBackend);
|
|
39
|
+
try {
|
|
40
|
+
await executeHook(event, hook, wantBackend);
|
|
51
41
|
return true;
|
|
52
42
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([(
|
|
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
|
-
|
|
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 !== "
|
|
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 (
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
}
|