From e939a2b7e9cd6cfae302eb92ab9ac4e927f88050 Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Mon, 19 Dec 2022 02:30:00 -0800 Subject: [PATCH] List Params support in CEL and .env parsing (#5137) --- src/deploy/functions/build.ts | 22 +- src/deploy/functions/cel.ts | 142 +++++++++++-- src/deploy/functions/params.ts | 199 +++++++++++++++++- src/deploy/functions/prepare.ts | 5 +- .../functions/runtimes/discovery/parsing.ts | 11 +- .../functions/runtimes/discovery/v1alpha1.ts | 4 +- src/test/deploy/functions/cel.spec.ts | 153 +++++++++++++- .../runtimes/discovery/v1alpha1.spec.ts | 42 ++++ 8 files changed, 541 insertions(+), 37 deletions(-) diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 0032602da6b..432a73d3f3d 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -52,8 +52,9 @@ export interface RequiredApi { // expressions. // `Expression == Expression` is an Expression // `Expression ? Expression : Expression` is an Expression -export type Expression = string; // eslint-disable-line +export type Expression = string; // eslint-disable-line export type Field = T | Expression | null; +export type ListField = Expression | (string | Expression)[] | null; // A service account must either: // 1. Be a project-relative email that ends with "@" (e.g. database-users@) @@ -237,7 +238,7 @@ export type Endpoint = Triggered & { // defaults to ["us-central1"], overridable in firebase-tools with // process.env.FIREBASE_FUNCTIONS_DEFAULT_REGION - region?: string[]; + region?: ListField; // The Cloud project associated with this endpoint. project: string; @@ -317,6 +318,7 @@ function envWithTypes( string: true, boolean: true, number: true, + list: true, }; for (const param of definedParams) { if (param.name === envName) { @@ -325,18 +327,28 @@ function envWithTypes( string: true, boolean: false, number: false, + list: false, }; } else if (param.type === "int") { providedType = { string: false, boolean: false, number: true, + list: false, }; } else if (param.type === "boolean") { providedType = { string: false, boolean: true, number: false, + list: false, + }; + } else if (param.type === "list") { + providedType = { + string: false, + boolean: false, + number: false, + list: true, }; } } @@ -420,9 +432,11 @@ export function toBackend( continue; } - let regions = bdEndpoint.region; - if (typeof regions === "undefined") { + let regions: string[] = []; + if (!bdEndpoint.region) { regions = [api.functionsDefaultRegion]; + } else { + regions = params.resolveList(bdEndpoint.region, paramValues); } for (const region of regions) { const trigger = discoverTrigger(bdEndpoint, region, r); diff --git a/src/deploy/functions/cel.ts b/src/deploy/functions/cel.ts index 96286124551..9e694eb3acd 100644 --- a/src/deploy/functions/cel.ts +++ b/src/deploy/functions/cel.ts @@ -10,8 +10,8 @@ type TernaryExpression = CelExpression; type LiteralTernaryExpression = CelExpression; type DualTernaryExpression = CelExpression; -type Literal = string | number | boolean; -type L = "string" | "number" | "boolean"; +type Literal = string | number | boolean | string[]; +type L = "string" | "number" | "boolean" | "string[]"; const paramRegexp = /params\.(\S+)/; const CMP = /((?:!=)|(?:==)|(?:>=)|(?:<=)|>|<)/.source; // !=, ==, >=, <=, >, < @@ -28,6 +28,15 @@ const ternaryRegexp = new RegExp( ); const literalTernaryRegexp = /{{ params\.(\S+) \? (.+) : (.+) }/; +/** + * An array equality test for use on resolved list literal ParamValues only; + * skips a lot of the null/undefined/object-y/nested-list checks that something + * like Underscore's isEqual() would make because args have to be string[]. + */ +function listEquals(a: string[], b: string[]): boolean { + return a.every((item) => b.includes(item)) && b.every((item) => a.includes(item)); +} + /** * Determines if something is a string that looks vaguely like a CEL expression. * No guarantees as to whether it'll actually evaluate. @@ -75,6 +84,10 @@ export function resolveExpression( expr: CelExpression, params: Record ): Literal { + // N.B: List literals [] can contain CEL inside them, so we need to process them + // first and resolve them. This isn't (and can't be) recursive, but the fact that + // we only support string[] types mostly saves us here. + expr = preprocessLists(wantType, expr, params); // N.B: Since some of these regexps are supersets of others--anything that is // params\.(\S+) is also (.+)--the order in which they are tested matters if (isIdentityExpression(expr)) { @@ -94,11 +107,72 @@ export function resolveExpression( } } +/** + * Replaces all lists in a CEL expression string, which can contain string-type CEL + * subexpressions or references to params, with their literal resolved values. + * Not recursive. + */ +function preprocessLists( + wantType: L, + expr: CelExpression, + params: Record +): CelExpression { + let rv = expr; + const listMatcher = /\[[^\[\]]*\]/g; + let match: RegExpMatchArray | null; + while ((match = listMatcher.exec(expr)) != null) { + const list = match[0]; + const resolved = resolveList("string", list, params); + rv = rv.replace(list, JSON.stringify(resolved)); + } + return rv; +} + +/** + * A List in Functions CEL is a []-bracketed string with comma-seperated values that can be: + * - A double quoted string literal + * - A reference to a param value (params.FOO) which must resolve with type string + * - A sub-CEL expression {{ params.BAR == 0 ? "a" : "b" }} which must resolve with type string + */ +function resolveList( + wantType: "string", + list: string, + params: Record +): string[] { + if (!list.startsWith("[") || !list.endsWith("]")) { + throw new ExprParseError("Invalid list: must start with '[' and end with ']'"); + } else if (list === "[]") { + return []; + } + const rv: string[] = []; + const entries = list.slice(1, -1).split(","); + + for (const entry of entries) { + const trimmed = entry.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + rv.push(trimmed.slice(1, -1)); + } else if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) { + rv.push(resolveExpression("string", trimmed, params) as string); + } else { + const paramMatch = paramRegexp.exec(trimmed); + if (!paramMatch) { + throw new ExprParseError(`Malformed list component ${trimmed}`); + } else if (!(paramMatch[1] in params)) { + throw new ExprParseError(`List expansion referenced nonexistent param ${paramMatch[1]}`); + } + rv.push(resolveParamListOrLiteral("string", trimmed, params) as string); + } + } + + return rv; +} + function assertType(wantType: L, paramName: string, paramValue: ParamValue) { if ( (wantType === "string" && !paramValue.legalString) || (wantType === "number" && !paramValue.legalNumber) || - (wantType === "boolean" && !paramValue.legalBoolean) + (wantType === "boolean" && !paramValue.legalBoolean) || + (wantType === "string[]" && !paramValue.legalList) ) { throw new ExprParseError(`Illegal type coercion of param ${paramName} to type ${wantType}`); } @@ -111,6 +185,8 @@ function readParamValue(wantType: L, paramName: string, paramValue: ParamValue): return paramValue.asNumber(); } else if (wantType === "boolean") { return paramValue.asBoolean(); + } else if (wantType === "string[]") { + return paramValue.asList(); } else { assertExhaustive(wantType); } @@ -154,9 +230,9 @@ function resolveComparison( const test = function (a: Literal, b: Literal): boolean { switch (cmp) { case "!=": - return a !== b; + return Array.isArray(a) ? !listEquals(a, b as string[]) : a !== b; case "==": - return a === b; + return Array.isArray(a) ? listEquals(a, b as string[]) : a === b; case ">=": return a >= b; case "<=": @@ -187,6 +263,14 @@ function resolveComparison( } else if (lhsVal.legalBoolean) { rhs = resolveLiteral("boolean", match[3]); return test(lhsVal.asBoolean(), rhs); + } else if (lhsVal.legalList) { + if (!["==", "!="].includes(cmp)) { + throw new ExprParseError( + `Unsupported comparison operation ${cmp} on list operands in expression ${expr}` + ); + } + rhs = resolveLiteral("string[]", match[3]); + return test(lhsVal.asList(), rhs); } else { throw new ExprParseError( `Could not infer type of param ${lhsName} used in comparison operation` @@ -210,9 +294,9 @@ function resolveDualComparison( const test = function (a: Literal, b: Literal): boolean { switch (cmp) { case "!=": - return a !== b; + return Array.isArray(a) ? !listEquals(a, b as string[]) : a !== b; case "==": - return a === b; + return Array.isArray(a) ? listEquals(a, b as string[]) : a === b; case ">=": return a >= b; case "<=": @@ -263,6 +347,18 @@ function resolveDualComparison( ); } return test(lhsVal.asBoolean(), rhsVal.asBoolean()); + } else if (lhsVal.legalList) { + if (!rhsVal.legalList) { + throw new ExprParseError( + `CEL comparison expression ${expr} has type mismatch between the operands` + ); + } + if (!["==", "!="].includes(cmp)) { + throw new ExprParseError( + `Unsupported comparison operation ${cmp} on list operands in expression ${expr}` + ); + } + return test(lhsVal.asList(), rhsVal.asList()); } else { throw new ExprParseError( `could not infer type of param ${lhsName} used in comparison operation` @@ -286,9 +382,9 @@ function resolveTernary( const comparisonExpr = `{{ params.${match[1]} ${match[2]} ${match[3]} }}`; const isTrue = resolveComparison(comparisonExpr, params); if (isTrue) { - return resolveParamOrLiteral(wantType, match[4], params); + return resolveParamListOrLiteral(wantType, match[4], params); } else { - return resolveParamOrLiteral(wantType, match[5], params); + return resolveParamListOrLiteral(wantType, match[5], params); } } @@ -304,13 +400,12 @@ function resolveDualTernary( if (!match) { throw new ExprParseError("Malformed CEL ternary expression '" + expr + "'"); } - const comparisonExpr = `{{ params.${match[1]} ${match[2]} params.${match[3]} }}`; const isTrue = resolveDualComparison(comparisonExpr, params); if (isTrue) { - return resolveParamOrLiteral(wantType, match[4], params); + return resolveParamListOrLiteral(wantType, match[4], params); } else { - return resolveParamOrLiteral(wantType, match[5], params); + return resolveParamListOrLiteral(wantType, match[5], params); } } @@ -342,13 +437,13 @@ function resolveLiteralTernary( } if (paramValue.asBoolean()) { - return resolveParamOrLiteral(wantType, match[2], params); + return resolveParamListOrLiteral(wantType, match[2], params); } else { - return resolveParamOrLiteral(wantType, match[3], params); + return resolveParamListOrLiteral(wantType, match[3], params); } } -function resolveParamOrLiteral( +function resolveParamListOrLiteral( wantType: L, field: string, params: Record @@ -371,7 +466,22 @@ function resolveLiteral(wantType: L, value: string): Literal { ); } - if (wantType === "number") { + if (wantType === "string[]") { + // N.B: value being a literal list that can just be JSON.parsed should be guaranteed + // by the preprocessLists() invocation at the beginning of CEL resolution + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + throw new ExprParseError(`CEL tried to read non-list ${JSON.stringify(parsed)} as a list`); + } + for (const shouldBeString of parsed) { + if (typeof shouldBeString !== "string") { + throw new ExprParseError( + `Evaluated CEL list ${JSON.stringify(parsed)} contained non-string values` + ); + } + } + return parsed as string[]; + } else if (wantType === "number") { if (isNaN(+value)) { throw new ExprParseError("CEL literal " + value + " does not seem to be a number"); } diff --git a/src/deploy/functions/params.ts b/src/deploy/functions/params.ts index ef67bcb72a5..18c000e5683 100644 --- a/src/deploy/functions/params.ts +++ b/src/deploy/functions/params.ts @@ -66,6 +66,26 @@ export function resolveString( return output; } +/** + * Resolves a FieldList in a Build to an an actual string[] value. + * FieldLists can be a list of string | Expression, or a single + * Expression. + */ +export function resolveList( + from: build.ListField, + paramValues: Record +): string[] { + if (!from) { + return []; + } else if (Array.isArray(from)) { + return from.map((entry) => resolveString(entry, paramValues)); + } else if (typeof from === "string") { + return resolveExpression("string[]", from, paramValues) as string[]; + } else { + assertExhaustive(from); + } +} + /** * Resolves a boolean field in a Build to an an actual boolean value. * Fields can be literal or an expression written in a subset of the CEL specification. @@ -81,9 +101,9 @@ export function resolveBoolean( return resolveExpression("boolean", from, paramValues) as boolean; } -type ParamInput = TextInput | SelectInput | ResourceInput; +type ParamInput = TextInput | SelectInput | MultiSelectInput | ResourceInput; -type ParamBase = { +type ParamBase = { // name of the param. Will be exposed as an environment variable with this name name: string; @@ -123,6 +143,12 @@ export function isSelectInput(input: ParamInput): input is SelectInput export function isResourceInput(input: ParamInput): input is ResourceInput { return {}.hasOwnProperty.call(input, "resource"); } +/** + * Determines whether an Input field value can be coerced to MultiSelectInput. + */ +export function isMultiSelectInput(input: ParamInput): input is MultiSelectInput { + return {}.hasOwnProperty.call(input, "multiSelect"); +} export interface StringParam extends ParamBase { type: "string"; @@ -136,6 +162,12 @@ export interface BooleanParam extends ParamBase { type: "boolean"; } +export interface ListParam extends ParamBase { + type: "list"; + + delimiter?: string; +} + export interface TextInput { // eslint-disable-line text: { example?: string; @@ -172,6 +204,12 @@ interface ResourceInput { }; } +interface MultiSelectInput { + multiSelect: { + options: Array>; + }; +} + interface SecretParam { type: "secret"; @@ -187,8 +225,8 @@ interface SecretParam { description?: string; } -export type Param = StringParam | IntParam | BooleanParam | SecretParam; -type RawParamValue = string | number | boolean; +export type Param = StringParam | IntParam | BooleanParam | ListParam | SecretParam; +type RawParamValue = string | number | boolean | string[]; /** * A type which contains the resolved value of a param, and metadata ensuring @@ -206,21 +244,43 @@ export class ParamValue { legalBoolean: boolean; // Whether this param value can be sensibly interpreted as a number legalNumber: boolean; + // Whether this param value can be sensibly interpreted as a list + legalList: boolean; + // What delimiter to use between fields when reading/writing to .env format + delimiter: string; constructor( private readonly rawValue: string, readonly internal: boolean, - types: { string?: boolean; boolean?: boolean; number?: boolean } + types: { string?: boolean; boolean?: boolean; number?: boolean; list?: boolean } ) { this.legalString = types.string || false; this.legalBoolean = types.boolean || false; this.legalNumber = types.number || false; + this.legalList = types.list || false; + this.delimiter = ","; + } + + static fromList(ls: string[], delimiter = ","): ParamValue { + const pv = new ParamValue(ls.join(delimiter), false, { list: true }); + pv.setDelimiter(delimiter); + return pv; } + setDelimiter(delimiter: string) { + this.delimiter = delimiter; + } + + // Returns this param's representation as it should be in .env files toString(): string { return this.rawValue; } + // Returns this param's representatiom as it should be in process.env during runtime + toSDK(): string { + return this.legalList ? JSON.stringify(this.asList()) : this.toString(); + } + asString(): string { return this.rawValue; } @@ -229,6 +289,10 @@ export class ParamValue { return ["true", "y", "yes", "1"].includes(this.rawValue); } + asList(): string[] { + return this.rawValue.split(this.delimiter); + } + asNumber(): number { return +this.rawValue; } @@ -261,6 +325,8 @@ function resolveDefaultCEL( return resolveString(expr, currentEnv); case "int": return resolveInt(expr, currentEnv); + case "list": + return resolveList(expr, currentEnv); default: throw new FirebaseError( "Build specified parameter with default " + expr + " of unsupported type" @@ -278,6 +344,8 @@ function canSatisfyParam(param: Param, value: RawParamValue): boolean { return typeof value === "number" && Number.isInteger(value); } else if (param.type === "boolean") { return typeof value === "boolean"; + } else if (param.type === "list") { + return Array.isArray(value); } else if (param.type === "secret") { return false; } @@ -436,6 +504,9 @@ async function promptParam( } else if (param.type === "boolean") { const provided = await promptBooleanParam(param, resolvedDefault as boolean | undefined); return new ParamValue(provided.toString(), false, { boolean: true }); + } else if (param.type === "list") { + const provided = await promptList(param, projectId, resolvedDefault as string[] | undefined); + return ParamValue.fromList(provided, param.delimiter); } else if (param.type === "secret") { throw new FirebaseError( `Somehow ended up trying to interactively prompt for secret parameter ${param.name}, which should never happen.` @@ -444,6 +515,52 @@ async function promptParam( assertExhaustive(param); } +async function promptList( + param: ListParam, + projectId: string, + resolvedDefault?: string[] +): Promise { + if (!param.input) { + const defaultToText: TextInput = { text: {} }; + param.input = defaultToText; + } + let prompt: string; + + if (isSelectInput(param.input)) { + throw new FirebaseError("List params cannot have non-list selector inputs"); + } else if (isMultiSelectInput(param.input)) { + prompt = `Select a value for ${param.label || param.name}:`; + if (param.description) { + prompt += ` \n(${param.description})`; + } + prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. "; + return promptSelectMultiple( + prompt, + param.input, + resolvedDefault, + (res: string[]) => res + ); + } else if (isTextInput(param.input)) { + prompt = `Enter a list of strings (delimiter: ${param.delimiter ? param.delimiter : ","}) for ${ + param.label || param.name + }:`; + if (param.description) { + prompt += ` \n(${param.description})`; + } + return promptText(prompt, param.input, resolvedDefault, (res: string): string[] => { + return res.split(param.delimiter || ","); + }); + } else if (isResourceInput(param.input)) { + prompt = `Select values for ${param.label || param.name}:`; + if (param.description) { + prompt += ` \n(${param.description})`; + } + return promptResourceStrings(prompt, param.input, projectId); + } else { + assertExhaustive(param.input); + } +} + async function promptBooleanParam( param: BooleanParam, resolvedDefault?: boolean @@ -462,6 +579,8 @@ async function promptBooleanParam( } prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. "; return promptSelect(prompt, param.input, resolvedDefault, isTruthyInput); + } else if (isMultiSelectInput(param.input)) { + throw new FirebaseError("Non-list params cannot have multi selector inputs"); } else if (isTextInput(param.input)) { prompt = `Enter a boolean value for ${param.label || param.name}:`; if (param.description) { @@ -492,6 +611,8 @@ async function promptStringParam( prompt += ` \n(${param.description})`; } return promptResourceString(prompt, param.input, projectId, resolvedDefault); + } else if (isMultiSelectInput(param.input)) { + throw new FirebaseError("Non-list params cannot have multi selector inputs"); } else if (isSelectInput(param.input)) { prompt = `Select a value for ${param.label || param.name}:`; if (param.description) { @@ -532,8 +653,9 @@ async function promptIntParam(param: IntParam, resolvedDefault?: number): Promis } return +res; }); - } - if (isTextInput(param.input)) { + } else if (isMultiSelectInput(param.input)) { + throw new FirebaseError("Non-list params cannot have multi selector inputs"); + } else if (isTextInput(param.input)) { prompt = `Enter an integer value for ${param.label || param.name}:`; if (param.description) { prompt += ` \n(${param.description})`; @@ -583,7 +705,39 @@ async function promptResourceString( } } +async function promptResourceStrings( + prompt: string, + input: ResourceInput, + projectId: string +): Promise { + const notFound = new FirebaseError(`No instances of ${input.resource.type} found.`); + switch (input.resource.type) { + case "storage.googleapis.com/Bucket": + const buckets = await listBuckets(projectId); + if (buckets.length === 0) { + throw notFound; + } + const forgedInput: MultiSelectInput = { + multiSelect: { + options: buckets.map((bucketName: string): SelectOptions => { + return { label: bucketName, value: bucketName }; + }), + }, + }; + return promptSelectMultiple(prompt, forgedInput, undefined, (res: string[]) => res); + default: + logger.warn( + `Warning: unknown resource type ${input.resource.type}; defaulting to raw text input...` + ); + return promptText(prompt, { text: {} }, undefined, (res: string) => res.split(",")); + } +} + type retryInput = { message: string }; +function shouldRetry(obj: any): obj is retryInput { + return typeof obj === "object" && (obj as retryInput).message !== undefined; +} + async function promptText( prompt: string, input: TextInput, @@ -609,7 +763,7 @@ async function promptText( // is wrong--it will return the type of the default if selected. Remove this // hack once we fix the prompt.ts metaprogramming. const converted = converter(res.toString()); - if (typeof converted === "object") { + if (shouldRetry(converted)) { logger.error(converted.message); return promptText(prompt, input, resolvedDefault, converter); } @@ -636,9 +790,36 @@ async function promptSelect( }), }); const converted = converter(response); - if (typeof converted === "object") { + if (shouldRetry(converted)) { logger.error(converted.message); return promptSelect(prompt, input, resolvedDefault, converter); } return converted; } + +async function promptSelectMultiple( + prompt: string, + input: MultiSelectInput, + resolvedDefault: T[] | undefined, + converter: (res: string[]) => T[] | retryInput +): Promise { + const response = await promptOnce({ + name: "input", + type: "checkbox", + default: resolvedDefault, + message: prompt, + choices: input.multiSelect.options.map((option: SelectOptions): ListItem => { + return { + checked: false, + name: option.label, + value: option.value.toString(), + }; + }), + }); + const converted = converter(response); + if (shouldRetry(converted)) { + logger.error(converted.message); + return promptSelectMultiple(prompt, input, resolvedDefault, converter); + } + return converted; +} diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index ca9d9079bdd..70f6713d6a8 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -122,11 +122,12 @@ export async function prepare( let hasEnvsFromParams = false; wantBackend.environmentVariables = envs; for (const envName of Object.keys(resolvedEnvs)) { - const envValue = resolvedEnvs[envName]?.toString(); + const isList = resolvedEnvs[envName]?.legalList; + const envValue = resolvedEnvs[envName]?.toSDK(); if ( envValue && !resolvedEnvs[envName].internal && - !Object.prototype.hasOwnProperty.call(wantBackend.environmentVariables, envName) + (!Object.prototype.hasOwnProperty.call(wantBackend.environmentVariables, envName) || isList) ) { wantBackend.environmentVariables[envName] = envValue; hasEnvsFromParams = true; diff --git a/src/deploy/functions/runtimes/discovery/parsing.ts b/src/deploy/functions/runtimes/discovery/parsing.ts index 396af1e15af..0a33e18174f 100644 --- a/src/deploy/functions/runtimes/discovery/parsing.ts +++ b/src/deploy/functions/runtimes/discovery/parsing.ts @@ -34,6 +34,7 @@ export type FieldType = | `Field${NullSuffix}` | `Field${NullSuffix}` | `Field${NullSuffix}` + | `List${NullSuffix}` | ((t: T) => boolean); export type Schema = { @@ -49,7 +50,7 @@ export function requireKeys(prefix: string, yaml: T, ...keys: } for (const key of keys) { if (!yaml[key]) { - throw new FirebaseError(`Expected key ${prefix + key}`); + throw new FirebaseError(`Expected key ${prefix + key.toString()}`); } } } @@ -106,6 +107,14 @@ export function assertKeyTypes( } continue; } + if (schemaType === "List") { + if (typeof value !== "string" && !Array.isArray(value)) { + throw new FirebaseError( + `Expected ${fullKey} to be a field list (array or list expression); was ${typeof value}` + ); + } + continue; + } if (value === null) { if (schemaType.endsWith("?")) { diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 5b2369c76a4..8cc49964fd0 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -64,7 +64,7 @@ export type WireEndpoint = build.Triggered & // We now use "serviceAccount" but maintain backwards compatability in the // wire format for the time being. serviceAccountEmail?: string | null; - region?: string[]; + region?: build.ListField; entryPoint: string; platform?: build.FunctionsPlatform; secretEnvironmentVariables?: Array | null; @@ -122,7 +122,7 @@ function parseRequiredAPIs(manifest: WireManifest): build.RequiredApi[] { function assertBuildEndpoint(ep: WireEndpoint, id: string): void { const prefix = `endpoints[${id}]`; assertKeyTypes(prefix, ep, { - region: "array", + region: "List", platform: (platform) => build.AllFunctionsPlatforms.includes(platform), entryPoint: "string", omit: "Field?", diff --git a/src/test/deploy/functions/cel.spec.ts b/src/test/deploy/functions/cel.spec.ts index 98d6799aa06..1083953131c 100644 --- a/src/test/deploy/functions/cel.spec.ts +++ b/src/test/deploy/functions/cel.spec.ts @@ -3,16 +3,163 @@ import { resolveExpression, ExprParseError } from "../../../deploy/functions/cel import { ParamValue } from "../../../deploy/functions/params"; function stringV(value: string): ParamValue { - return new ParamValue(value, false, { string: true, number: false, boolean: false }); + return new ParamValue(value, false, { string: true, number: false, boolean: false, list: false }); } function numberV(value: number): ParamValue { - return new ParamValue(value.toString(), false, { string: false, number: true, boolean: false }); + return new ParamValue(value.toString(), false, { + string: false, + number: true, + boolean: false, + list: false, + }); } function boolV(value: boolean): ParamValue { - return new ParamValue(value.toString(), false, { string: false, number: false, boolean: true }); + return new ParamValue(value.toString(), false, { + string: false, + number: false, + boolean: true, + list: false, + }); +} +function listV(value: string[]): ParamValue { + return ParamValue.fromList(value); } describe("CEL evaluation", () => { + describe("String list resolution", () => { + it("can pull lists directly out of paramvalues", () => { + expect( + resolveExpression("string[]", "{{ params.FOO }}", { + FOO: listV(["1"]), + }) + ).to.deep.equal(["1"]); + }); + + it("can handle literals in a list", () => { + expect( + resolveExpression("string[]", '{{ params.FOO == params.FOO ? ["asdf"] : [] }}', { + FOO: numberV(1), + }) + ).to.deep.equal(["asdf"]); + }); + + it("can handle CEL expressions in a list", () => { + expect( + resolveExpression("string[]", "{{ params.FOO == params.FOO ? [{{ params.BAR }}] : [] }}", { + FOO: numberV(1), + BAR: stringV("asdf"), + }) + ).to.deep.equal(["asdf"]); + }); + + it("can handle direct references to string params in a list", () => { + expect( + resolveExpression("string[]", "{{ params.FOO == params.FOO ? [params.BAR] : [] }}", { + FOO: numberV(1), + BAR: stringV("asdf"), + }) + ).to.deep.equal(["asdf"]); + }); + + it("can handle a list with multiple elements", () => { + expect( + resolveExpression( + "string[]", + '{{ params.FOO == params.FOO ? [ "foo", params.BAR, {{ params.BAR }} ] : [] }}', + { + FOO: numberV(1), + BAR: stringV("asdf"), + } + ) + ).to.deep.equal(["foo", "asdf", "asdf"]); + }); + + it("isn't picky about whitespace around the commas", () => { + expect( + resolveExpression( + "string[]", + '{{ params.FOO == params.FOO ? ["foo ",params.BAR ,{{ params.BAR }}] : [] }}', + { + FOO: numberV(1), + BAR: stringV("asdf"), + } + ) + ).to.deep.equal(["foo ", "asdf", "asdf"]); + }); + + it("can do == comparisons between lists", () => { + expect( + resolveExpression("boolean", "{{ params.FOO == params.FOO }}", { + FOO: listV(["a", "2", "false"]), + }) + ).to.be.true; + expect( + resolveExpression("boolean", '{{ params.FOO == ["a", "2", "false"] }}', { + FOO: listV(["a", "2", "false"]), + }) + ).to.be.true; + expect( + resolveExpression("boolean", "{{ params.FOO != params.FOO }}", { + FOO: listV(["a", "2", "false"]), + }) + ).to.be.false; + expect( + resolveExpression("boolean", '{{ params.FOO != ["a", "2", "false"] }}', { + FOO: listV(["a", "2", "false"]), + }) + ).to.be.false; + expect( + resolveExpression("boolean", "{{ params.FOO == params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.be.false; + expect( + resolveExpression("boolean", '{{ params.FOO == ["a", "2", "false"] }}', { + FOO: listV(["b", "-2", "true"]), + }) + ).to.be.false; + expect( + resolveExpression("boolean", "{{ params.FOO != params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.be.true; + expect( + resolveExpression("boolean", '{{ params.FOO != ["a", "2", "false"] }}', { + FOO: listV(["b", "-2", "true"]), + }) + ).to.be.true; + }); + + it("throws if asked to do type comparisons between lists", () => { + expect(() => + resolveExpression("boolean", "{{ params.FOO > params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.throw(ExprParseError); + expect(() => + resolveExpression("boolean", "{{ params.FOO >= params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.throw(ExprParseError); + expect(() => + resolveExpression("boolean", "{{ params.FOO < params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.throw(ExprParseError); + expect(() => + resolveExpression("boolean", "{{ params.FOO <= params.BAR }}", { + FOO: listV(["a", "2", "false"]), + BAR: listV(["b", "-2", "true"]), + }) + ).to.throw(ExprParseError); + }); + }); + describe("Identity expressions", () => { it("raises when the referenced parameter does not exist", () => { expect(() => { diff --git a/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index f38610358ae..3793e69a667 100644 --- a/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/test/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -609,6 +609,48 @@ describe("buildFromV1Alpha", () => { expect(parsed).to.deep.equal(expected); }); + it("allows both CEL and lists containing CEL in FieldList typed keys", () => { + const yamlCEL: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: { + id: { + ...MIN_WIRE_ENDPOINT, + httpsTrigger: {}, + region: "{{ params.REGION }}", + }, + }, + }; + const parsedCEL = v1alpha1.buildFromV1Alpha1(yamlCEL, PROJECT, REGION, RUNTIME); + const expectedCEL: build.Build = build.of({ + id: { + ...DEFAULTED_ENDPOINT, + region: "{{ params.REGION }}", + httpsTrigger: {}, + }, + }); + expect(parsedCEL).to.deep.equal(expectedCEL); + + const yamlList: v1alpha1.WireManifest = { + specVersion: "v1alpha1", + endpoints: { + id: { + ...MIN_WIRE_ENDPOINT, + httpsTrigger: {}, + region: ["{{ params.FOO }}", "BAR", "params.BAZ"], + }, + }, + }; + const parsedList = v1alpha1.buildFromV1Alpha1(yamlList, PROJECT, REGION, RUNTIME); + const expectedList: build.Build = build.of({ + id: { + ...DEFAULTED_ENDPOINT, + region: ["{{ params.FOO }}", "BAR", "params.BAZ"], + httpsTrigger: {}, + }, + }); + expect(parsedList).to.deep.equal(expectedList); + }); + it("copies schedules", () => { const scheduleTrigger: build.ScheduleTrigger = { schedule: "every 5 minutes",