diff --git a/docs/guides/variables-and-templating.md b/docs/guides/variables-and-templating.md index e0a3dfbbda..7ef3dfebfc 100644 --- a/docs/guides/variables-and-templating.md +++ b/docs/guides/variables-and-templating.md @@ -6,7 +6,7 @@ This guide introduces the templating capabilities available in Garden configurat String configuration values in `garden.yml` can be templated to inject variables, information about the user's environment, references to other modules/services and more. -The syntax for templated strings is `${some.key}`. The key is looked up from the context available when resolving the string. The context depends on which top-level key the configuration value belongs to (`project` or `module`). +The basic syntax for templated strings is `${some.key}`. The key is looked up from the context available when resolving the string. The context depends on which top-level key the configuration value belongs to (`project` or `module`). For example, for one service you might want to reference something from another module and expose it as an environment variable: @@ -96,6 +96,39 @@ services: ... ``` +### Nested lookups and maps + +In addition to dot-notation for key lookups, we also support bracketed lookups, e.g. `${some["key"]}` and `${some-array[0]}`. + +This style offer nested template resolution, which is quite powerful, because you can use the output of one expression to choose a key in a parent expression. + +For example, you can declare a mapping variable for your project, and look up values by another variable such as the current environment name. To illustrate, here's an excerpt from a project config with a mapping variable: + +```yaml +kind: Project +... +variables: + - replicas: + dev: 1 + prod: 3 + ... +``` + +And here that variable is used in a module: + +```yaml +kind: Module +type: container +... +services: + replicas: ${var.replicas["${environment.name}"]} + ... +``` + +When the nested expression is a simple key lookup like above, you can also just use the nested key directly, e.g. `${var.replicas[environment.name]}`. + +You can even use one variable to index another variable, e.g. `${var.a[var.b]}`. + ### Optional values In some cases, you may want to provide configuration values only for certain cases, e.g. only for specific environments. By default, an error is thrown when a template string resolves to an undefined value, but you can explicitly allow that by adding a `?` after the template. diff --git a/garden-service/src/config/config-context.ts b/garden-service/src/config/config-context.ts index 330b381248..28c54f5e70 100644 --- a/garden-service/src/config/config-context.ts +++ b/garden-service/src/config/config-context.ts @@ -22,6 +22,7 @@ import { getProviderUrl, getModuleTypeUrl } from "../docs/common" import { Module } from "../types/module" import { ModuleConfig } from "./module" import { ModuleVersion } from "../vcs/vcs" +import { isPrimitive } from "util" export type ContextKey = string[] @@ -109,8 +110,15 @@ export abstract class ConfigContext { nestedNodePath = nodePath.concat(lookupPath) const stackEntry = nestedNodePath.join(".") - if (nextKey.startsWith("_")) { + if (typeof nextKey === "string" && nextKey.startsWith("_")) { value = undefined + } else if (isPrimitive(value)) { + throw new ConfigurationError(`Attempted to look up key ${JSON.stringify(nextKey)} on a ${typeof value}.`, { + value, + nodePath, + fullPath, + opts, + }) } else { value = value instanceof Map ? value.get(nextKey) : value[nextKey] } diff --git a/garden-service/src/template-string-parser.pegjs b/garden-service/src/template-string-parser.pegjs index edf06835ed..a20ad98dfd 100644 --- a/garden-service/src/template-string-parser.pegjs +++ b/garden-service/src/template-string-parser.pegjs @@ -42,14 +42,6 @@ FormatEnd = __ "}?" / __ "}" -KeySeparator - = "." - -Key - = head:Identifier tail:(KeySeparator Identifier)* { - return [["", head]].concat(tail).map(p => p[1]) - } - Prefix = !FormatStart (. ! FormatStart)* . { return text() } @@ -58,6 +50,27 @@ Suffix // ---- expressions ----- // Reduced and adapted from: https://github.com/pegjs/pegjs/blob/master/examples/javascript.pegjs +MemberExpression + = head:Identifier + tail:( + "[" __ e:PrimaryExpression __ "]" { + if (e.resolved && !options.isPrimitive(e.resolved)) { + const _error = new options.TemplateStringError( + `Expression in bracket must resolve to a primitive (got ${typeof e}).`, + { text: e.resolved } + ) + return { _error } + } + return e + } + / "." e:Identifier { + return e + } + )* + { + return [head].concat(tail) + } + PrimaryExpression = v:NonStringLiteral { return v @@ -66,7 +79,13 @@ PrimaryExpression // Allow nested template strings in literals return options.resolveNested(v) } - / key:Key { + / key:MemberExpression { + for (const part of key) { + if (part._error) { + return part + } + } + key = key.map((part) => part.resolved || part) return options.getKey(key, { allowUndefined: true }) } / "(" __ e:Expression __ ")" { @@ -215,6 +234,7 @@ IdentifierName "identifier" = head:IdentifierStart tail:IdentifierPart* { return head + tail.join("") } + / Integer IdentifierStart = UnicodeLetter @@ -312,7 +332,10 @@ ExponentIndicator = "e"i SignedInteger - = [+-]? DecimalDigit+ + = [+-]? Integer + +Integer + = DecimalDigit+ HexIntegerLiteral = "0x"i digits:$HexDigit+ { diff --git a/garden-service/src/template-string.ts b/garden-service/src/template-string.ts index 875b1f7034..e6ef307f66 100644 --- a/garden-service/src/template-string.ts +++ b/garden-service/src/template-string.ts @@ -11,7 +11,7 @@ import { deepMap } from "./util/util" import { GardenBaseError, ConfigurationError } from "./exceptions" import { ConfigContext, ContextResolveOpts, ScanContext, ContextResolveOutput } from "./config/config-context" import { difference, flatten, uniq, isPlainObject, isNumber } from "lodash" -import { Primitive, StringMap } from "./config/common" +import { Primitive, StringMap, isPrimitive } from "./config/common" import { profile } from "./util/profiling" import { dedent, deline } from "./util/string" @@ -66,6 +66,7 @@ export function resolveTemplateString(string: string, context: ConfigContext, op TemplateStringError, allowUndefined: opts.allowUndefined, optionalSuffix: "}?", + isPrimitive, }) const outputs: ResolvedClause[] = parsed.map((p: any) => { diff --git a/garden-service/test/unit/src/template-string.ts b/garden-service/test/unit/src/template-string.ts index 7cd89313c0..cae0e09eab 100644 --- a/garden-service/test/unit/src/template-string.ts +++ b/garden-service/test/unit/src/template-string.ts @@ -442,6 +442,77 @@ describe("resolveTemplateString", async () => { expect(res).to.equal(21) }) + it("should handle member lookup with bracket notation", async () => { + const res = resolveTemplateString("${foo['bar']}", new TestContext({ foo: { bar: true } })) + expect(res).to.equal(true) + }) + + it("should handle numeric member lookup with bracket notation", async () => { + const res = resolveTemplateString("${foo[1]}", new TestContext({ foo: [false, true] })) + expect(res).to.equal(true) + }) + + it("should handle consecutive member lookups with bracket notation", async () => { + const res = resolveTemplateString("${foo['bar']['baz']}", new TestContext({ foo: { bar: { baz: true } } })) + expect(res).to.equal(true) + }) + + it("should handle dot member after bracket member", async () => { + const res = resolveTemplateString("${foo['bar'].baz}", new TestContext({ foo: { bar: { baz: true } } })) + expect(res).to.equal(true) + }) + + it("should handle template expression within brackets", async () => { + const res = resolveTemplateString( + "${foo['${bar}']}", + new TestContext({ + foo: { baz: true }, + bar: "baz", + }) + ) + expect(res).to.equal(true) + }) + + it("should handle identifiers within brackets", async () => { + const res = resolveTemplateString( + "${foo[bar]}", + new TestContext({ + foo: { baz: true }, + bar: "baz", + }) + ) + expect(res).to.equal(true) + }) + + it("should handle nested identifiers within brackets", async () => { + const res = resolveTemplateString( + "${foo[a.b]}", + new TestContext({ + foo: { baz: true }, + a: { b: "baz" }, + }) + ) + expect(res).to.equal(true) + }) + + it("should throw if bracket expression resolves to a non-primitive", async () => { + return expectError( + () => resolveTemplateString("${foo[bar]}", new TestContext({ foo: {}, bar: {} })), + (err) => + expect(err.message).to.equal( + "Invalid template string ${foo[bar]}: Expression in bracket must resolve to a primitive (got object)." + ) + ) + }) + + it("should throw if attempting to index a primitive with brackets", async () => { + return expectError( + () => resolveTemplateString("${foo[bar]}", new TestContext({ foo: 123, bar: "baz" })), + (err) => + expect(err.message).to.equal('Invalid template string ${foo[bar]}: Attempted to look up key "baz" on a number.') + ) + }) + it("should throw when using >= on non-numeric terms", async () => { return expectError( () => resolveTemplateString("${a >= b}", new TestContext({ a: 123, b: "foo" })), @@ -485,6 +556,16 @@ describe("resolveTemplateString", async () => { expect(res).to.equal(true) }) + it("should handle numeric indices on arrays", () => { + const res = resolveTemplateString("${foo.1}", new TestContext({ foo: [false, true] })) + expect(res).to.equal(true) + }) + + it("should resolve keys on objects in arrays", () => { + const res = resolveTemplateString("${foo.1.bar}", new TestContext({ foo: [{}, { bar: true }] })) + expect(res).to.equal(true) + }) + it("should correctly propagate errors from nested contexts", async () => { await expectError( () => resolveTemplateString("${nested.missing}", new TestContext({ nested: new TestContext({}) })),