Skip to content

Commit 863eeec

Browse files
authored
Fix broken Functions CLI experience for projects with incomplete GCF 2nd Gen functions. (#5684)
The codebase assumes that "serviceConfig" property must be present in all 2nd Gen Functions in GCF. This is an incorrect assumption - 2nd Gen GCF functions may be missing a Cloud Run service if the build never succeeded. Also refactors `backend` module a bit to avoid calling out to Cloud Run to retrieve concurrency & cpu config - it's available on the GCF response now! Fixes #4800
1 parent cc4d85c commit 863eeec

File tree

7 files changed

+181
-183
lines changed

7 files changed

+181
-183
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
- Default emulators:start to use fast dev-mode for Nuxt3 applications (#5551)
2+
- Fix broken Functions CLI experience for projects with incomplete GCF 2nd Gen functions (#5684)
23
- Disable GCF breaking change to automatically run npm build scripts as part of function deploy (#5687)
34
- Add experimental support for deploying Astro applications to Hosting (#5527)

src/deploy/functions/backend.ts

+3-16
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as gcf from "../../gcp/cloudfunctions";
22
import * as gcfV2 from "../../gcp/cloudfunctionsv2";
3-
import * as run from "../../gcp/run";
43
import * as utils from "../../utils";
54
import * as runtimes from "./runtimes";
65
import { FirebaseError } from "../../error";
76
import { Context } from "./args";
8-
import { flattenArray, zip } from "../../functional";
7+
import { flattenArray } from "../../functional";
98

109
/** Retry settings for a ScheduleSpec. */
1110
export interface ScheduleRetryConfig {
@@ -532,20 +531,8 @@ async function loadExistingBackend(ctx: Context): Promise<void> {
532531
let gcfV2Results;
533532
try {
534533
gcfV2Results = await gcfV2.listAllFunctions(ctx.projectId);
535-
const runResults = await Promise.all(
536-
gcfV2Results.functions.map((fn) => run.getService(fn.serviceConfig.service!))
537-
);
538-
for (const [apiFunction, runService] of zip(gcfV2Results.functions, runResults)) {
539-
// I don't know why but code complete knows apiFunction is a gcfv2.CloudFunction
540-
// and the compiler thinks it's type {}.
541-
const endpoint = gcfV2.endpointFromFunction(apiFunction as any);
542-
endpoint.concurrency = runService.spec.template.spec.containerConcurrency || 1;
543-
// N.B. We don't generally do anything with multiple containers, but we
544-
// might have to figure out WTF to do here if we're updating multiple containers
545-
// and our only reference point is the image. Hopefully by then we'll be
546-
// on the next gen infrastructure and have state we can refer back to.
547-
endpoint.cpu = +runService.spec.template.spec.containers[0].resources.limits.cpu;
548-
534+
for (const apiFunction of gcfV2Results.functions) {
535+
const endpoint = gcfV2.endpointFromFunction(apiFunction);
549536
ctx.existingBackend.endpoints[endpoint.region] =
550537
ctx.existingBackend.endpoints[endpoint.region] || {};
551538
ctx.existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint;

src/deploy/functions/release/fabricator.ts

+26-10
Original file line numberDiff line numberDiff line change
@@ -347,16 +347,24 @@ export class Fabricator {
347347
const resultFunction = await this.functionExecutor
348348
.run(async () => {
349349
const op: { name: string } = await gcfV2.createFunction(apiFunction);
350-
return await poller.pollOperation<gcfV2.CloudFunction>({
350+
return await poller.pollOperation<gcfV2.OutputCloudFunction>({
351351
...gcfV2PollerOptions,
352352
pollerName: `create-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`,
353353
operationResourceName: op.name,
354354
});
355355
})
356-
.catch(rethrowAs<gcfV2.CloudFunction>(endpoint, "create"));
357-
358-
endpoint.uri = resultFunction.serviceConfig.uri;
359-
const serviceName = resultFunction.serviceConfig.service!;
356+
.catch(rethrowAs<gcfV2.OutputCloudFunction>(endpoint, "create"));
357+
358+
endpoint.uri = resultFunction.serviceConfig?.uri;
359+
const serviceName = resultFunction.serviceConfig?.service;
360+
if (!serviceName) {
361+
logger.debug("Result function unexpectedly didn't have a service name.");
362+
utils.logLabeledWarning(
363+
"functions",
364+
"Updated function is not associated with a service. This deployment is in an unexpected state - please re-deploy your functions."
365+
);
366+
return;
367+
}
360368
if (backend.isHttpsTriggered(endpoint)) {
361369
const invoker = endpoint.httpsTrigger.invoker || ["public"];
362370
if (!invoker.includes("private")) {
@@ -455,16 +463,24 @@ export class Fabricator {
455463
const resultFunction = await this.functionExecutor
456464
.run(async () => {
457465
const op: { name: string } = await gcfV2.updateFunction(apiFunction);
458-
return await poller.pollOperation<gcfV2.CloudFunction>({
466+
return await poller.pollOperation<gcfV2.OutputCloudFunction>({
459467
...gcfV2PollerOptions,
460468
pollerName: `update-${endpoint.codebase}-${endpoint.region}-${endpoint.id}`,
461469
operationResourceName: op.name,
462470
});
463471
})
464-
.catch(rethrowAs<gcfV2.CloudFunction>(endpoint, "update"));
465-
466-
endpoint.uri = resultFunction.serviceConfig.uri;
467-
const serviceName = resultFunction.serviceConfig.service!;
472+
.catch(rethrowAs<gcfV2.OutputCloudFunction>(endpoint, "update"));
473+
474+
endpoint.uri = resultFunction.serviceConfig?.uri;
475+
const serviceName = resultFunction.serviceConfig?.service;
476+
if (!serviceName) {
477+
logger.debug("Result function unexpectedly didn't have a service name.");
478+
utils.logLabeledWarning(
479+
"functions",
480+
"Updated function is not associated with a service. This deployment is in an unexpected state - please re-deploy your functions."
481+
);
482+
return;
483+
}
468484
let invoker: string[] | undefined;
469485
if (backend.isHttpsTriggered(endpoint)) {
470486
invoker = endpoint.httpsTrigger.invoker === null ? ["public"] : endpoint.httpsTrigger.invoker;

src/gcp/cloudfunctionsv2.ts

+90-77
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
CODEBASE_LABEL,
1919
HASH_LABEL,
2020
} from "../functions/constants";
21+
import { RequireKeys } from "../metaprogramming";
2122

2223
export const API_VERSION = "v2";
2324

@@ -31,17 +32,6 @@ export type VpcConnectorEgressSettings = "PRIVATE_RANGES_ONLY" | "ALL_TRAFFIC";
3132
export type IngressSettings = "ALLOW_ALL" | "ALLOW_INTERNAL_ONLY" | "ALLOW_INTERNAL_AND_GCLB";
3233
export type FunctionState = "ACTIVE" | "FAILED" | "DEPLOYING" | "DELETING" | "UNKONWN";
3334

34-
// The GCFv2 funtion type has many inner types which themselves have output-only fields:
35-
// eventTrigger.trigger
36-
// buildConfig.config
37-
// buildConfig.workerPool
38-
// serviceConfig.service
39-
// serviceConfig.uri
40-
//
41-
// Because Omit<> doesn't work with nested property addresses, we're making those fields optional.
42-
// An alternative would be to name the types OutputCloudFunction/CloudFunction or CloudFunction/InputCloudFunction.
43-
export type OutputOnlyFields = "state" | "updateTime";
44-
4535
// Values allowed for the operator field in EventFilter
4636
export type EventFilterOperator = "match-path-pattern";
4737

@@ -153,17 +143,26 @@ export interface EventTrigger {
153143
channel?: string;
154144
}
155145

156-
export interface CloudFunction {
146+
interface CloudFunctionBase {
157147
name: string;
158148
description?: string;
159149
buildConfig: BuildConfig;
160-
serviceConfig: ServiceConfig;
150+
serviceConfig?: ServiceConfig;
161151
eventTrigger?: EventTrigger;
162-
state: FunctionState;
163-
updateTime: Date;
164152
labels?: Record<string, string> | null;
165153
}
166154

155+
export type OutputCloudFunction = CloudFunctionBase & {
156+
state: FunctionState;
157+
updateTime: Date;
158+
serviceConfig?: RequireKeys<ServiceConfig, "service" | "uri">;
159+
};
160+
161+
export type InputCloudFunction = CloudFunctionBase & {
162+
// serviceConfig is required.
163+
serviceConfig: ServiceConfig;
164+
};
165+
167166
export interface OperationMetadata {
168167
createTime: string;
169168
endTime: string;
@@ -181,13 +180,13 @@ export interface Operation {
181180
metadata?: OperationMetadata;
182181
done: boolean;
183182
error?: { code: number; message: string; details: unknown };
184-
response?: CloudFunction;
183+
response?: OutputCloudFunction;
185184
}
186185

187186
// Private API interface for ListFunctionsResponse. listFunctions returns
188187
// a CloudFunction[]
189188
interface ListFunctionsResponse {
190-
functions: CloudFunction[];
189+
functions: OutputCloudFunction[];
191190
unreachable: string[];
192191
}
193192

@@ -292,9 +291,7 @@ export async function generateUploadUrl(
292291
/**
293292
* Creates a new Cloud Function.
294293
*/
295-
export async function createFunction(
296-
cloudFunction: Omit<CloudFunction, OutputOnlyFields>
297-
): Promise<Operation> {
294+
export async function createFunction(cloudFunction: InputCloudFunction): Promise<Operation> {
298295
// the API is a POST to the collection that owns the function name.
299296
const components = cloudFunction.name.split("/");
300297
const functionId = components.splice(-1, 1)[0];
@@ -325,17 +322,20 @@ export async function getFunction(
325322
projectId: string,
326323
location: string,
327324
functionId: string
328-
): Promise<CloudFunction> {
325+
): Promise<OutputCloudFunction> {
329326
const name = `projects/${projectId}/locations/${location}/functions/${functionId}`;
330-
const res = await client.get<CloudFunction>(name);
327+
const res = await client.get<OutputCloudFunction>(name);
331328
return res.body;
332329
}
333330

334331
/**
335332
* List all functions in a region.
336333
* Customers should generally use backend.existingBackend.
337334
*/
338-
export async function listFunctions(projectId: string, region: string): Promise<CloudFunction[]> {
335+
export async function listFunctions(
336+
projectId: string,
337+
region: string
338+
): Promise<OutputCloudFunction[]> {
339339
const res = await listFunctionsInternal(projectId, region);
340340
if (res.unreachable.includes(region)) {
341341
throw new FirebaseError(`Cloud Functions region ${region} is unavailable`);
@@ -356,7 +356,7 @@ async function listFunctionsInternal(
356356
region: string
357357
): Promise<ListFunctionsResponse> {
358358
type Response = ListFunctionsResponse & { nextPageToken?: string };
359-
const functions: CloudFunction[] = [];
359+
const functions: OutputCloudFunction[] = [];
360360
const unreacahble = new Set<string>();
361361
let pageToken = "";
362362
while (true) {
@@ -386,9 +386,7 @@ async function listFunctionsInternal(
386386
* Updates a Cloud Function.
387387
* Customers can force a field to be deleted by setting that field to `undefined`
388388
*/
389-
export async function updateFunction(
390-
cloudFunction: Omit<CloudFunction, OutputOnlyFields>
391-
): Promise<Operation> {
389+
export async function updateFunction(cloudFunction: InputCloudFunction): Promise<Operation> {
392390
// Keys in labels and environmentVariables and secretEnvironmentVariables are user defined, so we don't recurse
393391
// for field masks.
394392
const fieldMasks = proto.fieldMasks(
@@ -439,7 +437,7 @@ export async function deleteFunction(cloudFunction: string): Promise<Operation>
439437
export function functionFromEndpoint(
440438
endpoint: backend.Endpoint,
441439
source: StorageSource
442-
): Omit<CloudFunction, OutputOnlyFields> {
440+
): InputCloudFunction {
443441
if (endpoint.platform !== "gcfv2") {
444442
throw new FirebaseError(
445443
"Trying to create a v2 CloudFunction with v1 API. This should never happen"
@@ -453,7 +451,7 @@ export function functionFromEndpoint(
453451
);
454452
}
455453

456-
const gcfFunction: Omit<CloudFunction, OutputOnlyFields> = {
454+
const gcfFunction: InputCloudFunction = {
457455
name: backend.functionName(endpoint),
458456
buildConfig: {
459457
runtime: endpoint.runtime,
@@ -601,7 +599,7 @@ export function functionFromEndpoint(
601599
/**
602600
* Generate a versionless Endpoint object from a v2 Cloud Function API object.
603601
*/
604-
export function endpointFromFunction(gcfFunction: CloudFunction): backend.Endpoint {
602+
export function endpointFromFunction(gcfFunction: OutputCloudFunction): backend.Endpoint {
605603
const [, project, , region, , id] = gcfFunction.name.split("/");
606604
let trigger: backend.Triggered;
607605
if (gcfFunction.labels?.["deployment-scheduled"] === "true") {
@@ -671,63 +669,78 @@ export function endpointFromFunction(gcfFunction: CloudFunction): backend.Endpoi
671669
...trigger,
672670
entryPoint: gcfFunction.buildConfig.entryPoint,
673671
runtime: gcfFunction.buildConfig.runtime,
674-
uri: gcfFunction.serviceConfig.uri,
675672
};
676-
proto.copyIfPresent(
677-
endpoint,
678-
gcfFunction.serviceConfig,
679-
"ingressSettings",
680-
"environmentVariables",
681-
"secretEnvironmentVariables",
682-
"timeoutSeconds"
683-
);
684-
proto.renameIfPresent(
685-
endpoint,
686-
gcfFunction.serviceConfig,
687-
"serviceAccount",
688-
"serviceAccountEmail"
689-
);
690-
proto.convertIfPresent(
691-
endpoint,
692-
gcfFunction.serviceConfig,
693-
"availableMemoryMb",
694-
"availableMemory",
695-
(prod) => {
696-
if (prod === null) {
697-
logger.debug("Prod should always return a valid memory amount");
698-
return prod as never;
673+
if (gcfFunction.serviceConfig) {
674+
proto.copyIfPresent(
675+
endpoint,
676+
gcfFunction.serviceConfig,
677+
"ingressSettings",
678+
"environmentVariables",
679+
"secretEnvironmentVariables",
680+
"timeoutSeconds",
681+
"uri"
682+
);
683+
proto.renameIfPresent(
684+
endpoint,
685+
gcfFunction.serviceConfig,
686+
"serviceAccount",
687+
"serviceAccountEmail"
688+
);
689+
proto.convertIfPresent(
690+
endpoint,
691+
gcfFunction.serviceConfig,
692+
"availableMemoryMb",
693+
"availableMemory",
694+
(prod) => {
695+
if (prod === null) {
696+
logger.debug("Prod should always return a valid memory amount");
697+
return prod as never;
698+
}
699+
const mem = mebibytes(prod);
700+
if (!backend.isValidMemoryOption(mem)) {
701+
logger.warn("Converting a function to an endpoint with an invalid memory option", mem);
702+
}
703+
return mem as backend.MemoryOptions;
699704
}
700-
const mem = mebibytes(prod);
701-
if (!backend.isValidMemoryOption(mem)) {
702-
logger.warn("Converting a function to an endpoint with an invalid memory option", mem);
705+
);
706+
proto.convertIfPresent(endpoint, gcfFunction.serviceConfig, "cpu", "availableCpu", (cpu) => {
707+
let cpuVal: number | null = Number(cpu);
708+
if (Number.isNaN(cpuVal)) {
709+
cpuVal = null;
703710
}
704-
return mem as backend.MemoryOptions;
705-
}
706-
);
707-
proto.renameIfPresent(endpoint, gcfFunction.serviceConfig, "minInstances", "minInstanceCount");
708-
proto.renameIfPresent(endpoint, gcfFunction.serviceConfig, "maxInstances", "maxInstanceCount");
709-
proto.copyIfPresent(endpoint, gcfFunction, "labels");
710-
if (gcfFunction.serviceConfig.vpcConnector) {
711-
endpoint.vpc = { connector: gcfFunction.serviceConfig.vpcConnector };
711+
return cpuVal;
712+
});
713+
proto.renameIfPresent(endpoint, gcfFunction.serviceConfig, "minInstances", "minInstanceCount");
714+
proto.renameIfPresent(endpoint, gcfFunction.serviceConfig, "maxInstances", "maxInstanceCount");
712715
proto.renameIfPresent(
713-
endpoint.vpc,
716+
endpoint,
714717
gcfFunction.serviceConfig,
715-
"egressSettings",
716-
"vpcConnectorEgressSettings"
718+
"concurrency",
719+
"maxInstanceRequestConcurrency"
717720
);
721+
proto.copyIfPresent(endpoint, gcfFunction, "labels");
722+
if (gcfFunction.serviceConfig.vpcConnector) {
723+
endpoint.vpc = { connector: gcfFunction.serviceConfig.vpcConnector };
724+
proto.renameIfPresent(
725+
endpoint.vpc,
726+
gcfFunction.serviceConfig,
727+
"egressSettings",
728+
"vpcConnectorEgressSettings"
729+
);
730+
}
731+
const serviceName = gcfFunction.serviceConfig.service;
732+
if (!serviceName) {
733+
logger.debug(
734+
"Got a v2 function without a service name." +
735+
"Maybe we've migrated to using the v2 API everywhere and missed this code"
736+
);
737+
} else {
738+
endpoint.runServiceId = utils.last(serviceName.split("/"));
739+
}
718740
}
719741
endpoint.codebase = gcfFunction.labels?.[CODEBASE_LABEL] || projectConfig.DEFAULT_CODEBASE;
720742
if (gcfFunction.labels?.[HASH_LABEL]) {
721743
endpoint.hash = gcfFunction.labels[HASH_LABEL];
722744
}
723-
const serviceName = gcfFunction.serviceConfig.service;
724-
if (!serviceName) {
725-
logger.debug(
726-
"Got a v2 function without a service name." +
727-
"Maybe we've migrated to using the v2 API everywhere and missed this code"
728-
);
729-
} else {
730-
endpoint.runServiceId = utils.last(serviceName.split("/"));
731-
}
732745
return endpoint;
733746
}

0 commit comments

Comments
 (0)