firebase-tools 15.22.3 → 15.22.4
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/apphosting/localbuilds.js +50 -0
- package/lib/deploy/apphosting/prepare.js +100 -1
- package/lib/deploy/functions/backend.js +6 -1
- package/lib/deploy/functions/build.js +25 -0
- package/lib/deploy/functions/prepare.js +1 -1
- package/lib/deploy/functions/release/index.js +4 -0
- package/lib/deploy/functions/release/lifecycle.js +120 -0
- package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +33 -0
- package/lib/deploy/functions/validate.js +44 -1
- package/lib/emulator/downloadableEmulatorInfo.json +7 -7
- package/lib/firestore/api.js +3 -1
- package/lib/gcp/cloudtasks.js +4 -0
- package/lib/tsconfig.compile.tsbuildinfo +1 -1
- package/lib/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +1 -1
package/lib/accountExporter.js
CHANGED
package/lib/accountImporter.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runUniversalMaker = runUniversalMaker;
|
|
4
4
|
exports.localBuild = localBuild;
|
|
5
|
+
exports.validateLocalBuildNodeVersion = validateLocalBuildNodeVersion;
|
|
5
6
|
const childProcess = require("child_process");
|
|
6
7
|
const fs = require("fs-extra");
|
|
7
8
|
const path = require("path");
|
|
9
|
+
const semver = require("semver");
|
|
8
10
|
const index_1 = require("./secrets/index");
|
|
9
11
|
const prompt_1 = require("../prompt");
|
|
10
12
|
const error_1 = require("../error");
|
|
@@ -138,3 +140,51 @@ async function toProcessEnv(projectId, env) {
|
|
|
138
140
|
}));
|
|
139
141
|
return Object.fromEntries(resolvedEntries);
|
|
140
142
|
}
|
|
143
|
+
function validateLocalBuildNodeVersion(backend, projectRoot) {
|
|
144
|
+
const runtimeValue = backend.runtime?.value ?? "";
|
|
145
|
+
const isLegacyRuntime = runtimeValue === "" || runtimeValue === "nodejs";
|
|
146
|
+
const abiuEnabled = !isLegacyRuntime && !backend.automaticBaseImageUpdatesDisabled;
|
|
147
|
+
if (!abiuEnabled) {
|
|
148
|
+
throw new error_1.FirebaseError(`Local builds are only supported for backends with ABIU (Automatic Base Image Updates) enabled. ` +
|
|
149
|
+
`Your backend is currently configured with a non-ABIU runtime ("${runtimeValue || "unspecified"}"). ` +
|
|
150
|
+
`Please update your backend to a versioned runtime (e.g., nodejs22) to enable local builds.`, { exit: 1 });
|
|
151
|
+
}
|
|
152
|
+
const targetMajorMatch = runtimeValue.match(/^nodejs(\d+)$/);
|
|
153
|
+
if (!targetMajorMatch) {
|
|
154
|
+
(0, utils_1.logLabeledWarning)("apphosting", `Unable to extract Node.js major version from the backend runtime ("${runtimeValue}"). ` +
|
|
155
|
+
`Skipping local Node.js version compatibility checks.`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const targetMajor = parseInt(targetMajorMatch[1], 10);
|
|
159
|
+
let localNodeVersion;
|
|
160
|
+
try {
|
|
161
|
+
localNodeVersion = childProcess.execSync("node -v", { encoding: "utf8" }).trim();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
(0, utils_1.logLabeledWarning)("apphosting", `Unable to detect your local Node.js version (is 'node' installed and in your PATH?). ` +
|
|
165
|
+
`Skipping local Node.js version compatibility checks.`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const packageJsonPath = path.join(projectRoot, "package.json");
|
|
169
|
+
const packageJson = fs.readJsonSync(packageJsonPath, { throws: false });
|
|
170
|
+
const enginesNode = packageJson?.engines?.node;
|
|
171
|
+
if (enginesNode) {
|
|
172
|
+
(0, utils_1.logLabeledWarning)("apphosting", `Your package.json specifies Node.js engine "${enginesNode}". ` +
|
|
173
|
+
`Please note that local builds do NOT use the "engines" field to resolve or download Node.js. ` +
|
|
174
|
+
`Instead, your local build uses your host machine's active Node.js version (${localNodeVersion}) to compile the app, ` +
|
|
175
|
+
`and your deployed app will run on the backend's configured ABIU runtime (${runtimeValue}).`);
|
|
176
|
+
const targetRange = `^${targetMajor}.0.0`;
|
|
177
|
+
if (semver.validRange(enginesNode) && !semver.intersects(targetRange, enginesNode)) {
|
|
178
|
+
(0, utils_1.logLabeledWarning)("apphosting", `The Node.js version range specified in your package.json engines ("${enginesNode}") ` +
|
|
179
|
+
`does not satisfy your backend's target ABIU runtime version (Node.js ${targetMajor}). ` +
|
|
180
|
+
`Please update your package.json engines to align with your backend configuration.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const localMajorMatch = localNodeVersion.match(/^v?(\d+)/);
|
|
184
|
+
const localMajor = localMajorMatch ? parseInt(localMajorMatch[1], 10) : null;
|
|
185
|
+
if (localMajor !== null && localMajor !== targetMajor) {
|
|
186
|
+
(0, utils_1.logLabeledWarning)("apphosting", `Local Node.js version (${localNodeVersion}) does not match your backend's target Node.js version (Node.js ${targetMajor}). ` +
|
|
187
|
+
`This mismatch may cause runtime issues. ` +
|
|
188
|
+
`Please switch your local environment to Node.js ${targetMajor} to ensure build-to-run parity.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -174,6 +174,9 @@ function merge(...backends) {
|
|
|
174
174
|
apiToReasons[api] = reasons;
|
|
175
175
|
}
|
|
176
176
|
merged.environmentVariables = { ...merged.environmentVariables, ...b.environmentVariables };
|
|
177
|
+
if (b.lifecycleHooks) {
|
|
178
|
+
merged.lifecycleHooks = { ...(merged.lifecycleHooks || {}), ...b.lifecycleHooks };
|
|
179
|
+
}
|
|
177
180
|
}
|
|
178
181
|
for (const [api, reasons] of Object.entries(apiToReasons)) {
|
|
179
182
|
merged.requiredAPIs.push({ api, reason: Array.from(reasons).join(" ") });
|
|
@@ -181,7 +184,9 @@ function merge(...backends) {
|
|
|
181
184
|
return merged;
|
|
182
185
|
}
|
|
183
186
|
function isEmptyBackend(backend) {
|
|
184
|
-
return (Object.keys(backend.requiredAPIs).length === 0 &&
|
|
187
|
+
return (Object.keys(backend.requiredAPIs).length === 0 &&
|
|
188
|
+
Object.keys(backend.endpoints).length === 0 &&
|
|
189
|
+
Object.keys(backend.lifecycleHooks || {}).length === 0);
|
|
185
190
|
}
|
|
186
191
|
function functionName(cloudFunction) {
|
|
187
192
|
return `projects/${cloudFunction.project}/locations/${cloudFunction.region}/functions/${cloudFunction.id}`;
|
|
@@ -263,6 +263,9 @@ function toBackend(build, paramValues) {
|
|
|
263
263
|
}
|
|
264
264
|
const bkend = backend.of(...bkEndpoints);
|
|
265
265
|
bkend.requiredAPIs = build.requiredAPIs;
|
|
266
|
+
if (build.lifecycleHooks) {
|
|
267
|
+
bkend.lifecycleHooks = build.lifecycleHooks;
|
|
268
|
+
}
|
|
266
269
|
return bkend;
|
|
267
270
|
}
|
|
268
271
|
function discoverTrigger(endpoint, region, r) {
|
|
@@ -375,4 +378,26 @@ function applyPrefix(build, prefix) {
|
|
|
375
378
|
}
|
|
376
379
|
}
|
|
377
380
|
build.endpoints = newEndpoints;
|
|
381
|
+
if (build.lifecycleHooks) {
|
|
382
|
+
for (const hook of Object.values(build.lifecycleHooks)) {
|
|
383
|
+
if ("task" in hook) {
|
|
384
|
+
if (hook.task?.function) {
|
|
385
|
+
hook.task.function = `${prefix}-${hook.task.function}`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else if ("call" in hook) {
|
|
389
|
+
if (hook.call?.function) {
|
|
390
|
+
hook.call.function = `${prefix}-${hook.call.function}`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if ("http" in hook) {
|
|
394
|
+
if (hook.http?.function) {
|
|
395
|
+
hook.http.function = `${prefix}-${hook.http.function}`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
(0, functional_1.assertExhaustive)(hook);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
378
403
|
}
|
|
@@ -203,7 +203,7 @@ async function prepare(context, options, payload) {
|
|
|
203
203
|
await (0, triggerRegionHelper_1.ensureTriggerRegions)(wantBackend);
|
|
204
204
|
resolveCpuAndConcurrency(wantBackend);
|
|
205
205
|
resolveDefaultTimeout(wantBackend);
|
|
206
|
-
validate.endpointsAreValid(wantBackend);
|
|
206
|
+
validate.endpointsAreValid(wantBackend, existingBackend);
|
|
207
207
|
inferBlockingDetails(wantBackend);
|
|
208
208
|
}
|
|
209
209
|
const wantBackend = backend.merge(...Object.values(wantBackends));
|
|
@@ -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 lifecycle_1 = require("./lifecycle");
|
|
21
22
|
async function release(context, options, payload) {
|
|
22
23
|
if (context.extensions && payload.extensions) {
|
|
23
24
|
await (0, extensions_1.release)(context.extensions, options, payload.extensions);
|
|
@@ -88,6 +89,9 @@ async function release(context, options, payload) {
|
|
|
88
89
|
reporter.printErrors(summary);
|
|
89
90
|
const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend));
|
|
90
91
|
printTriggerUrls(wantBackend, projectNumber);
|
|
92
|
+
for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) {
|
|
93
|
+
await (0, lifecycle_1.executeLifecycleHooks)(w, h, plan, codebase);
|
|
94
|
+
}
|
|
91
95
|
await setupArtifactCleanupPolicies(options, options.projectId, Object.keys(wantBackend.endpoints));
|
|
92
96
|
const allErrors = summary.results.filter((r) => r.error).map((r) => r.error);
|
|
93
97
|
if (allErrors.length) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.determineDeploymentDelta = determineDeploymentDelta;
|
|
4
|
+
exports.executeLifecycleHooks = executeLifecycleHooks;
|
|
5
|
+
const backend = require("../backend");
|
|
6
|
+
const error_1 = require("../../../error");
|
|
7
|
+
const logger_1 = require("../../../logger");
|
|
8
|
+
const utils_1 = require("../../../utils");
|
|
9
|
+
const cloudtasks = require("../../../gcp/cloudtasks");
|
|
10
|
+
const computeEngine = require("../../../gcp/computeEngine");
|
|
11
|
+
const projects_1 = require("../../../management/projects");
|
|
12
|
+
const functional_1 = require("../../../functional");
|
|
13
|
+
function determineDeploymentDelta(haveBackend) {
|
|
14
|
+
const hasExistingEndpoints = backend.someEndpoint(haveBackend, () => true);
|
|
15
|
+
if (!hasExistingEndpoints) {
|
|
16
|
+
return "afterInstall";
|
|
17
|
+
}
|
|
18
|
+
return "afterUpdate";
|
|
19
|
+
}
|
|
20
|
+
async function executeLifecycleHooks(wantBackend, haveBackend, plan, codebase) {
|
|
21
|
+
const delta = determineDeploymentDelta(haveBackend);
|
|
22
|
+
const hooks = wantBackend.lifecycleHooks || {};
|
|
23
|
+
const hook = hooks[delta];
|
|
24
|
+
if (!hook) {
|
|
25
|
+
logger_1.logger.debug(`No lifecycle hook configured for event: ${delta}`);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (delta === "afterUpdate" && plan) {
|
|
29
|
+
const relevantChangesets = Object.entries(plan)
|
|
30
|
+
.filter(([key]) => {
|
|
31
|
+
if (!codebase)
|
|
32
|
+
return true;
|
|
33
|
+
if (key.startsWith(`${codebase}-`))
|
|
34
|
+
return true;
|
|
35
|
+
if (codebase === "default" && key.startsWith("-"))
|
|
36
|
+
return true;
|
|
37
|
+
return false;
|
|
38
|
+
})
|
|
39
|
+
.map(([, c]) => c);
|
|
40
|
+
const hasResourceModifications = relevantChangesets.some((changeset) => changeset.endpointsToCreate.length > 0 ||
|
|
41
|
+
changeset.endpointsToUpdate.length > 0 ||
|
|
42
|
+
changeset.endpointsToDelete.length > 0);
|
|
43
|
+
if (!hasResourceModifications) {
|
|
44
|
+
(0, utils_1.logLabeledBullet)("functions", "No resources modified in codebase. Skipping afterUpdate lifecycle hook.");
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if ("task" in hook) {
|
|
49
|
+
(0, utils_1.logLabeledBullet)("functions", `Executing ${delta} lifecycle hook targeting: ${hook.task.function}...`);
|
|
50
|
+
await executeTaskQueueHook(hook.task, wantBackend);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
else if ("call" in hook) {
|
|
54
|
+
throw new error_1.FirebaseError(`Lifecycle hook action type "call" is not supported.`);
|
|
55
|
+
}
|
|
56
|
+
else if ("http" in hook) {
|
|
57
|
+
throw new error_1.FirebaseError(`Lifecycle hook action type "http" is not supported.`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
(0, functional_1.assertExhaustive)(hook);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function executeTaskQueueHook(taskHook, wantBackend) {
|
|
64
|
+
const targetEndpoint = findTargetEndpoint(wantBackend, taskHook.function);
|
|
65
|
+
if (!targetEndpoint) {
|
|
66
|
+
throw new error_1.FirebaseError(`Target endpoint "${taskHook.function}" not found in backend for lifecycle hook.`);
|
|
67
|
+
}
|
|
68
|
+
if (!backend.isTaskQueueTriggered(targetEndpoint)) {
|
|
69
|
+
throw new error_1.FirebaseError(`Target endpoint "${taskHook.function}" is not a task queue function.`);
|
|
70
|
+
}
|
|
71
|
+
const queueName = cloudtasks.queueNameForEndpoint(targetEndpoint);
|
|
72
|
+
const bodyStr = taskHook.body ? JSON.stringify(taskHook.body) : "";
|
|
73
|
+
const body = bodyStr ? Buffer.from(bodyStr).toString("base64") : undefined;
|
|
74
|
+
const url = targetEndpoint.uri;
|
|
75
|
+
if (!url) {
|
|
76
|
+
throw new error_1.FirebaseError(`Target endpoint "${taskHook.function}" does not have a trigger URI.`);
|
|
77
|
+
}
|
|
78
|
+
const projectMetadata = await (0, projects_1.getProject)(targetEndpoint.project);
|
|
79
|
+
const projectNumber = projectMetadata.projectNumber;
|
|
80
|
+
const sa = targetEndpoint.serviceAccount || (await computeEngine.getDefaultServiceAccount(projectNumber));
|
|
81
|
+
const task = {
|
|
82
|
+
httpRequest: {
|
|
83
|
+
url,
|
|
84
|
+
httpMethod: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
},
|
|
88
|
+
oidcToken: {
|
|
89
|
+
serviceAccountEmail: sa,
|
|
90
|
+
audience: url,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
if (body) {
|
|
95
|
+
task.httpRequest.body = body;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await cloudtasks.enqueueTask(queueName, task);
|
|
99
|
+
(0, utils_1.logLabeledSuccess)("functions", `Successfully queued task for lifecycle hook ${taskHook.function} in queue ${queueName}.`);
|
|
100
|
+
(0, utils_1.logLabeledBullet)("functions", `View logs for ${taskHook.function} at: ${getCloudConsoleLogUrl(targetEndpoint)}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
(0, utils_1.logLabeledWarning)("functions", `Failed to enqueue task for lifecycle hook ${taskHook.function}: ${errorMsg}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function findTargetEndpoint(backendSpec, targetId) {
|
|
108
|
+
for (const endpoint of backend.allEndpoints(backendSpec)) {
|
|
109
|
+
if (endpoint.id === targetId) {
|
|
110
|
+
return endpoint;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function getCloudConsoleLogUrl(endpoint) {
|
|
116
|
+
const { project, region, id } = endpoint;
|
|
117
|
+
const serviceName = endpoint.runServiceId || id;
|
|
118
|
+
const query = `resource.type="cloud_run_revision"\nresource.labels.service_name="${serviceName}"\nresource.labels.location="${region}"`;
|
|
119
|
+
return `https://console.cloud.google.com/logs/query;query=${encodeURIComponent(query)};project=${project}`;
|
|
120
|
+
}
|
|
@@ -22,6 +22,7 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
|
|
|
22
22
|
requiredAPIs: "array",
|
|
23
23
|
endpoints: "object",
|
|
24
24
|
extensions: "object",
|
|
25
|
+
lifecycleHooks: "object",
|
|
25
26
|
});
|
|
26
27
|
const bd = build.empty();
|
|
27
28
|
bd.params = manifest.params || [];
|
|
@@ -41,6 +42,38 @@ function buildFromV1Alpha1(yaml, project, region, runtime) {
|
|
|
41
42
|
bd.extensions[id] = be;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
if (manifest.lifecycleHooks) {
|
|
46
|
+
bd.lifecycleHooks = {};
|
|
47
|
+
for (const id of Object.keys(manifest.lifecycleHooks)) {
|
|
48
|
+
if (id !== "afterInstall" && id !== "afterUpdate") {
|
|
49
|
+
throw new error_1.FirebaseError(`Invalid eventType "${id}" for lifecycle hook.`);
|
|
50
|
+
}
|
|
51
|
+
const hook = manifest.lifecycleHooks[id];
|
|
52
|
+
if (!hook || typeof hook !== "object") {
|
|
53
|
+
throw new error_1.FirebaseError(`Invalid lifecycle hook configuration for "${id}".`);
|
|
54
|
+
}
|
|
55
|
+
if (hook.task) {
|
|
56
|
+
if (typeof hook.task.function !== "string" || !hook.task.function) {
|
|
57
|
+
throw new error_1.FirebaseError(`Invalid target "${hook.task.function || ""}" for lifecycle hook "${id}"`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (hook.call) {
|
|
61
|
+
if (typeof hook.call.function !== "string" || !hook.call.function) {
|
|
62
|
+
throw new error_1.FirebaseError(`Invalid target "${hook.call.function || ""}" for lifecycle hook "${id}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (hook.http) {
|
|
66
|
+
const target = hook.http.url || hook.http.function;
|
|
67
|
+
if (typeof target !== "string" || !target) {
|
|
68
|
+
throw new error_1.FirebaseError(`Invalid target "${target || ""}" for lifecycle hook "${id}"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
throw new error_1.FirebaseError(`No action (task, call, or http) specified for lifecycle hook "${id}"`);
|
|
73
|
+
}
|
|
74
|
+
bd.lifecycleHooks[id] = hook;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
44
77
|
return bd;
|
|
45
78
|
}
|
|
46
79
|
function parseRequiredAPIs(manifest) {
|
|
@@ -9,6 +9,7 @@ exports.functionsDirectoryExists = functionsDirectoryExists;
|
|
|
9
9
|
exports.functionIdsAreValid = functionIdsAreValid;
|
|
10
10
|
exports.secretsAreValid = secretsAreValid;
|
|
11
11
|
exports.checkFiltersIntegrity = checkFiltersIntegrity;
|
|
12
|
+
exports.validateLifecycleHooks = validateLifecycleHooks;
|
|
12
13
|
const path = require("path");
|
|
13
14
|
const clc = require("colorette");
|
|
14
15
|
const error_1 = require("../../error");
|
|
@@ -20,6 +21,7 @@ const fsutils = require("../../fsutils");
|
|
|
20
21
|
const backend = require("./backend");
|
|
21
22
|
const utils = require("../../utils");
|
|
22
23
|
const secrets = require("../../functions/secrets");
|
|
24
|
+
const functional_1 = require("../../functional");
|
|
23
25
|
const MAX_V1_TIMEOUT_SECONDS = 540;
|
|
24
26
|
const MAX_V2_EVENTS_TIMEOUT_SECONDS = 540;
|
|
25
27
|
const MAX_V2_SCHEDULE_TIMEOUT_SECONDS = 1800;
|
|
@@ -49,7 +51,8 @@ function validateScheduledTimeout(ep) {
|
|
|
49
51
|
`The attempt deadline will be capped at ${exports.MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS} seconds.`);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
|
-
function endpointsAreValid(wantBackend) {
|
|
54
|
+
function endpointsAreValid(wantBackend, existingBackend) {
|
|
55
|
+
validateLifecycleHooks(wantBackend, existingBackend);
|
|
53
56
|
const endpoints = backend.allEndpoints(wantBackend);
|
|
54
57
|
functionIdsAreValid(endpoints);
|
|
55
58
|
validateTimeoutConfig(endpoints);
|
|
@@ -288,3 +291,43 @@ function checkFiltersIntegrity(wantBackends, filters) {
|
|
|
288
291
|
}
|
|
289
292
|
}
|
|
290
293
|
}
|
|
294
|
+
function validateLifecycleHooks(wantBackend, existingBackend) {
|
|
295
|
+
if (!wantBackend.lifecycleHooks) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const endpoints = [
|
|
299
|
+
...backend.allEndpoints(wantBackend),
|
|
300
|
+
...(existingBackend ? backend.allEndpoints(existingBackend) : []),
|
|
301
|
+
];
|
|
302
|
+
for (const [eventType, hook] of Object.entries(wantBackend.lifecycleHooks)) {
|
|
303
|
+
const keys = Object.keys(hook).filter((k) => ["task", "call", "http"].includes(k));
|
|
304
|
+
if (keys.length !== 1) {
|
|
305
|
+
throw new error_1.FirebaseError(`Lifecycle hook "${eventType}" must specify exactly one action (task, call, or http).`);
|
|
306
|
+
}
|
|
307
|
+
if ("task" in hook) {
|
|
308
|
+
const targetEndpoint = findAndValidateTargetEndpoint(endpoints, hook.task.function, eventType);
|
|
309
|
+
if (!backend.isTaskQueueTriggered(targetEndpoint)) {
|
|
310
|
+
throw new error_1.FirebaseError(`Lifecycle hook "${eventType}" expects a task queue function.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if ("call" in hook) {
|
|
314
|
+
throw new error_1.FirebaseError(`Lifecycle hook action type "call" is not supported in the CLI yet.`);
|
|
315
|
+
}
|
|
316
|
+
else if ("http" in hook) {
|
|
317
|
+
throw new error_1.FirebaseError(`Lifecycle hook action type "http" is not supported in the CLI yet.`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
(0, functional_1.assertExhaustive)(hook);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function findAndValidateTargetEndpoint(endpoints, functionName, eventType) {
|
|
325
|
+
const targetEndpoint = endpoints.find((e) => e.id === functionName);
|
|
326
|
+
if (!targetEndpoint) {
|
|
327
|
+
throw new error_1.FirebaseError(`Target endpoint "${functionName}" not found in backend for lifecycle hook "${eventType}".`);
|
|
328
|
+
}
|
|
329
|
+
if (targetEndpoint.platform === "gcfv1") {
|
|
330
|
+
throw new error_1.FirebaseError(`Target endpoint "${functionName}" is a GCF Gen 1 function. Lifecycle hooks are only supported for GCF Gen 2 functions.`);
|
|
331
|
+
}
|
|
332
|
+
return targetEndpoint;
|
|
333
|
+
}
|
|
@@ -44,13 +44,13 @@
|
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
"pubsub": {
|
|
47
|
-
"version": "0.8.
|
|
48
|
-
"expectedSize":
|
|
49
|
-
"expectedChecksum": "
|
|
50
|
-
"expectedChecksumSHA256": "
|
|
51
|
-
"remoteUrl": "https://storage.googleapis.com/firebase-preview-drop/emulator/pubsub-emulator-0.8.
|
|
52
|
-
"downloadPathRelativeToCacheDir": "pubsub-emulator-0.8.
|
|
53
|
-
"binaryPathRelativeToCacheDir": "pubsub-emulator-0.8.
|
|
47
|
+
"version": "0.8.34",
|
|
48
|
+
"expectedSize": 53008019,
|
|
49
|
+
"expectedChecksum": "3eb75c292dfa72d48a36428a49bac314",
|
|
50
|
+
"expectedChecksumSHA256": "062a71d30cbc0cc29de6e55f22ae9fc7eacb02c60272a2c4692826fdb83f0c25",
|
|
51
|
+
"remoteUrl": "https://storage.googleapis.com/firebase-preview-drop/emulator/pubsub-emulator-0.8.34.zip",
|
|
52
|
+
"downloadPathRelativeToCacheDir": "pubsub-emulator-0.8.34.zip",
|
|
53
|
+
"binaryPathRelativeToCacheDir": "pubsub-emulator-0.8.34/pubsub-emulator/bin/cloud-pubsub-emulator"
|
|
54
54
|
},
|
|
55
55
|
"dataconnect": {
|
|
56
56
|
"darwin": {
|
package/lib/firestore/api.js
CHANGED
|
@@ -646,7 +646,9 @@ class FirestoreApi {
|
|
|
646
646
|
pageSize: limit,
|
|
647
647
|
},
|
|
648
648
|
});
|
|
649
|
-
return
|
|
649
|
+
return {
|
|
650
|
+
operations: res.body?.operations || [],
|
|
651
|
+
};
|
|
650
652
|
}
|
|
651
653
|
async describeOperation(project, databaseId, operationName) {
|
|
652
654
|
const url = `/projects/${project}/databases/${databaseId}/operations/${operationName}`;
|
package/lib/gcp/cloudtasks.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.updateQueue = updateQueue;
|
|
|
7
7
|
exports.upsertQueue = upsertQueue;
|
|
8
8
|
exports.purgeQueue = purgeQueue;
|
|
9
9
|
exports.deleteQueue = deleteQueue;
|
|
10
|
+
exports.enqueueTask = enqueueTask;
|
|
10
11
|
exports.setIamPolicy = setIamPolicy;
|
|
11
12
|
exports.getIamPolicy = getIamPolicy;
|
|
12
13
|
exports.setEnqueuer = setEnqueuer;
|
|
@@ -77,6 +78,9 @@ async function purgeQueue(name) {
|
|
|
77
78
|
async function deleteQueue(name) {
|
|
78
79
|
await client.delete(name);
|
|
79
80
|
}
|
|
81
|
+
async function enqueueTask(queueName, task) {
|
|
82
|
+
await client.post(`${queueName}/tasks`, { task });
|
|
83
|
+
}
|
|
80
84
|
async function setIamPolicy(name, policy) {
|
|
81
85
|
const res = await client.post(`${name}:setIamPolicy`, {
|
|
82
86
|
policy,
|