diff --git a/docs/reference/template-strings.md b/docs/reference/template-strings.md index 58eee616c4..a4913873ed 100644 --- a/docs/reference/template-strings.md +++ b/docs/reference/template-strings.md @@ -43,6 +43,18 @@ lookups of keys. However, it is possible to do nested templating. For a somewhat There the name of the module is pulled from the project/environment configuration, and used to find the appropriate key under the `modules` configuration context. +You can also do simple OR statements: + + +```yaml + # ... + env: + PROFILE_NAME: ${local.env.PROFILE || "default"} + LOG_LEVEL: ${local.env.LOG_LEVEL || variables.default-log-level} +``` + +This allows you to easily set default values when certain template keys are not available. + ## Reference ### Project configuration context @@ -51,7 +63,7 @@ The following keys are available in template strings under the `project` key in ```yaml -local: +local: # A map of all local environment variables (see # https://nodejs.org/api/process.html#process_process_env). env: {} @@ -70,7 +82,7 @@ The following keys are available in template strings under the `module` key in ```yaml -local: +local: # A map of all local environment variables (see # https://nodejs.org/api/process.html#process_process_env). env: {} @@ -83,7 +95,7 @@ local: platform: # Information about the environment that Garden is running against. -environment: +environment: # The name of the environment Garden is running against. # # Example: "local" @@ -97,7 +109,7 @@ environment: # path: /home/me/code/my-project/my-module # version: v17ad4cb3fd # -modules: +modules: {} # A map of all configured plugins/providers for this environment and their configuration. @@ -107,7 +119,7 @@ modules: # name: local-kubernetes # context: my-kube-context # -providers: +providers: {} # A map of all variables defined in the project configuration. @@ -116,6 +128,6 @@ providers: # team-name: bananaramallama # some-service-endpoint: 'https://someservice.com/api/v2' # -variables: +variables: {} ``` diff --git a/garden-service/gulpfile.ts b/garden-service/gulpfile.ts index b3df1d293a..b3bc576e05 100644 --- a/garden-service/gulpfile.ts +++ b/garden-service/gulpfile.ts @@ -40,6 +40,10 @@ module.exports = (gulp) => { .pipe(gulp.dest(destDir)), ) + gulp.task("pegjs-watch", () => + gulp.watch(pegjsSources, gulp.series(["pegjs"])), + ) + gulp.task("tsc", () => tsProject.src() .pipe(sourcemaps.init()) diff --git a/garden-service/src/config/config-context.ts b/garden-service/src/config/config-context.ts index b1b597b079..2fdd7c0192 100644 --- a/garden-service/src/config/config-context.ts +++ b/garden-service/src/config/config-context.ts @@ -18,11 +18,16 @@ import { ModuleVersion } from "../vcs/base" export type ContextKey = string[] +export interface ContextResolveOpts { + allowUndefined?: boolean + // a list of previously resolved paths, used to detect circular references + stack?: string[] +} + export interface ContextResolveParams { key: ContextKey nodePath: ContextKey - // a list of previously resolved paths, used to detect circular references - stack?: string[] + opts: ContextResolveOpts } export function schema(joiSchema: Joi.Schema) { @@ -46,7 +51,7 @@ export abstract class ConfigContext { return Joi.object().keys(schemas).required() } - async resolve({ key, nodePath, stack }: ContextResolveParams): Promise { + async resolve({ key, nodePath, opts }: ContextResolveParams): Promise { const path = key.join(".") const fullPath = nodePath.concat(key).join(".") @@ -57,15 +62,15 @@ export abstract class ConfigContext { return resolved } - stack = [...stack || []] + opts.stack = [...opts.stack || []] - if (stack.includes(fullPath)) { + if (opts.stack.includes(fullPath)) { throw new ConfigurationError( - `Circular reference detected when resolving key ${path} (${stack.join(" -> ")})`, + `Circular reference detected when resolving key ${path} (${opts.stack.join(" -> ")})`, { nodePath, fullPath, - stack, + opts, }, ) } @@ -94,15 +99,15 @@ export abstract class ConfigContext { // handle nested contexts if (value instanceof ConfigContext) { const nestedKey = remainder - stack.push(stackEntry) - value = await value.resolve({ key: nestedKey, nodePath: nestedNodePath, stack }) + opts.stack.push(stackEntry) + value = await value.resolve({ key: nestedKey, nodePath: nestedNodePath, opts }) break } // handle templated strings in context variables if (isString(value)) { - stack.push(stackEntry) - value = await resolveTemplateString(value, this._rootContext, stack) + opts.stack.push(stackEntry) + value = await resolveTemplateString(value, this._rootContext, opts) } if (value === undefined) { @@ -111,11 +116,15 @@ export abstract class ConfigContext { } if (value === undefined) { - throw new ConfigurationError(`Could not find key: ${path}`, { - nodePath, - fullPath, - stack, - }) + if (opts.allowUndefined) { + return + } else { + throw new ConfigurationError(`Could not find key: ${path}`, { + nodePath, + fullPath, + opts, + }) + } } if (!isPrimitive(value)) { diff --git a/garden-service/src/template-string-parser.pegjs b/garden-service/src/template-string-parser.pegjs index c342cafb3d..1c82ebd807 100644 --- a/garden-service/src/template-string-parser.pegjs +++ b/garden-service/src/template-string-parser.pegjs @@ -13,15 +13,37 @@ TemplateString / $(.*) { return [text()] } NestedTemplateString - = a:(FormatString)+ b:NestedTemplateString? { return [...a, ...(b || [])] } - / a:Prefix b:(FormatString)+ c:NestedTemplateString? { return [a, ...b, ...(c || [])] } + = a:(FormatString)+ b:NestedTemplateString? { + return [...a, ...(b || [])] + } + / a:Prefix b:(FormatString)+ c:NestedTemplateString? { + return [a, ...b, ...(c || [])] + } / InvalidFormatString / Suffix { return [text()] } FormatString - = FormatStart head:Identifier tail:(KeySeparator Identifier)* FormatEnd { - const parts = [["", head]].concat(tail).map(p => p[1]) - return options.getKey(parts) + = FormatStart key:Key FormatEnd { + return options.getKey(key) + } + / FormatStart a:Key Or b:Key FormatEnd { + return options.resolve(a, { allowUndefined: true }) + .then(result => { + return result || options.resolve(b, { allowUndefined: false }) + }) + } + / FormatStart a:Key Or b:StringLiteral FormatEnd { + return options.resolve(a, { allowUndefined: true }) + .then(result => { + return result || b + }) + } + // These would be odd in configuration, but there's no reason to throw if it comes up. + / FormatStart a:StringLiteral Or b:StringLiteral FormatEnd { + return a + } + / FormatStart a:StringLiteral FormatEnd { + return a } / FormatStart s:NestedTemplateString FormatEnd { return options.resolve(s) @@ -29,14 +51,14 @@ FormatString InvalidFormatString = Prefix? FormatStart .* { - throw new options.TemplateStringError("Invalid template string: ..." + text()) + throw new options.TemplateStringError("Invalid template string: " + text()) } FormatStart - = "${" + = ws "${" ws FormatEnd - = "}" + = ws "}" ws Identifier = [a-zA-Z][a-zA-Z0-9_\-]* { return text() } @@ -44,8 +66,68 @@ Identifier KeySeparator = "." +Key + = head:Identifier tail:(KeySeparator Identifier)* { + return [["", head]].concat(tail).map(p => p[1]) + } + +Or + = ws "||" ws + +// Some of the below is based on https://github.com/pegjs/pegjs/blob/master/examples/json.pegjs +ws "whitespace" = [ \t\n\r]* + +StringLiteral + = ws '"' chars:DoubleQuotedChar* '"' ws { return chars.join(""); } + / ws "'" chars:SingleQuotedChar* "'" ws { return chars.join(""); } + +Escape + = "\\" + +DoubleQuotedChar + = [^\0-\x1F\x22\x5C] + / Escape + sequence:( + '"' + / "\\" + / "/" + / "b" { return "\b"; } + / "f" { return "\f"; } + / "n" { return "\n"; } + / "r" { return "\r"; } + / "t" { return "\t"; } + / "u" digits:$(HEXDIG HEXDIG HEXDIG HEXDIG) { + return String.fromCharCode(parseInt(digits, 16)); + } + ) + { return sequence; } + +SingleQuotedChar + = [^\0-\x1F\x27\x5C] + / Escape + sequence:( + "'" + / "\\" + / "/" + / "b" { return "\b"; } + / "f" { return "\f"; } + / "n" { return "\n"; } + / "r" { return "\r"; } + / "t" { return "\t"; } + / "u" digits:$(HEXDIG HEXDIG HEXDIG HEXDIG) { + return String.fromCharCode(parseInt(digits, 16)); + } + ) + { return sequence; } + Prefix = !FormatStart (. ! FormatStart)* . { return text() } Suffix = !FormatEnd (. ! FormatEnd)* . { return text() } + +// ----- Core ABNF Rules ----- + +// See RFC 4234, Appendix B (http://tools.ietf.org/html/rfc4234). +DIGIT = [0-9] +HEXDIG = [0-9a-f]i diff --git a/garden-service/src/template-string.ts b/garden-service/src/template-string.ts index eccc9c61a5..9218c361ca 100644 --- a/garden-service/src/template-string.ts +++ b/garden-service/src/template-string.ts @@ -11,7 +11,7 @@ import { resolve } from "path" import Bluebird = require("bluebird") import { asyncDeepMap } from "./util/util" import { GardenBaseError } from "./exceptions" -import { ConfigContext } from "./config/config-context" +import { ConfigContext, ContextResolveOpts } from "./config/config-context" export type StringOrStringPromise = Promise | string @@ -45,14 +45,14 @@ async function getParser() { * The context should be a ConfigContext instance. The optional `stack` parameter is used to detect circular * dependencies when resolving context variables. */ -export async function resolveTemplateString(string: string, context: ConfigContext, stack?: string[]) { +export async function resolveTemplateString(string: string, context: ConfigContext, opts: ContextResolveOpts = {}) { const parser = await getParser() const parsed = parser.parse(string, { - getKey: async (key: string[]) => context.resolve({ key, nodePath: [], stack }), + getKey: async (key: string[]) => context.resolve({ key, nodePath: [], opts }), // need this to allow nested template strings - resolve: async (parts: StringOrStringPromise[]) => { + resolve: async (parts: StringOrStringPromise[], resolveOpts?: ContextResolveOpts) => { const s = (await Bluebird.all(parts)).join("") - return resolveTemplateString(`\$\{${s}\}`, context, stack) + return resolveTemplateString(`\$\{${s}\}`, context, { ...opts, ...resolveOpts || {} }) }, TemplateStringError, }) diff --git a/garden-service/test/unit/src/config/config-context.ts b/garden-service/test/unit/src/config/config-context.ts index b66b798546..5dd450f684 100644 --- a/garden-service/test/unit/src/config/config-context.ts +++ b/garden-service/test/unit/src/config/config-context.ts @@ -31,7 +31,7 @@ describe("ConfigContext", () => { describe("resolve", () => { // just a shorthand to aid in testing function resolveKey(c: ConfigContext, key: ContextKey) { - return c.resolve({ key, nodePath: [] }) + return c.resolve({ key, nodePath: [], opts: {} }) } it("should resolve simple keys", async () => { @@ -103,15 +103,15 @@ describe("ConfigContext", () => { }) const key = ["nested", "key"] const stack = [key.join(".")] - await expectError(() => c.resolve({ key, nodePath: [], stack }), "configuration") + await expectError(() => c.resolve({ key, nodePath: [], opts: { stack } }), "configuration") }) it("should detect a circular reference from a nested context", async () => { class NestedContext extends ConfigContext { - async resolve({ key, nodePath, stack }: ContextResolveParams) { + async resolve({ key, nodePath, opts }: ContextResolveParams) { const circularKey = nodePath.concat(key) - stack!.push(circularKey.join(".")) - return c.resolve({ key: circularKey, nodePath: [], stack }) + opts.stack!.push(circularKey.join(".")) + return c.resolve({ key: circularKey, nodePath: [], opts }) } } const c = new TestContext({ @@ -214,13 +214,13 @@ describe("ProjectConfigContext", () => { it("should should resolve local env variables", async () => { process.env.TEST_VARIABLE = "foo" const c = new ProjectConfigContext() - expect(await c.resolve({ key: ["local", "env", "TEST_VARIABLE"], nodePath: [] })).to.equal("foo") + expect(await c.resolve({ key: ["local", "env", "TEST_VARIABLE"], nodePath: [], opts: {} })).to.equal("foo") delete process.env.TEST_VARIABLE }) it("should should resolve the local platform", async () => { const c = new ProjectConfigContext() - expect(await c.resolve({ key: ["local", "platform"], nodePath: [] })).to.equal(process.platform) + expect(await c.resolve({ key: ["local", "platform"], nodePath: [], opts: {} })).to.equal(process.platform) }) }) @@ -240,38 +240,38 @@ describe("ModuleConfigContext", () => { it("should should resolve local env variables", async () => { process.env.TEST_VARIABLE = "foo" - expect(await c.resolve({ key: ["local", "env", "TEST_VARIABLE"], nodePath: [] })).to.equal("foo") + expect(await c.resolve({ key: ["local", "env", "TEST_VARIABLE"], nodePath: [], opts: {} })).to.equal("foo") delete process.env.TEST_VARIABLE }) it("should should resolve the local platform", async () => { - expect(await c.resolve({ key: ["local", "platform"], nodePath: [] })).to.equal(process.platform) + expect(await c.resolve({ key: ["local", "platform"], nodePath: [], opts: {} })).to.equal(process.platform) }) it("should should resolve the environment config", async () => { - expect(await c.resolve({ key: ["environment", "name"], nodePath: [] })).to.equal(garden.environment.name) + expect(await c.resolve({ key: ["environment", "name"], nodePath: [], opts: {} })).to.equal(garden.environment.name) }) it("should should resolve the path of a module", async () => { const path = join(garden.projectRoot, "module-a") - expect(await c.resolve({ key: ["modules", "module-a", "path"], nodePath: [] })).to.equal(path) + expect(await c.resolve({ key: ["modules", "module-a", "path"], nodePath: [], opts: {} })).to.equal(path) }) it("should should resolve the version of a module", async () => { const { versionString } = await garden.resolveVersion("module-a", []) - expect(await c.resolve({ key: ["modules", "module-a", "version"], nodePath: [] })).to.equal(versionString) + expect(await c.resolve({ key: ["modules", "module-a", "version"], nodePath: [], opts: {} })).to.equal(versionString) }) it("should should resolve the outputs of a module", async () => { - expect(await c.resolve({ key: ["modules", "module-a", "outputs", "foo"], nodePath: [] })).to.equal("bar") + expect(await c.resolve({ key: ["modules", "module-a", "outputs", "foo"], nodePath: [], opts: {} })).to.equal("bar") }) it("should should resolve the version of a module", async () => { const { versionString } = await garden.resolveVersion("module-a", []) - expect(await c.resolve({ key: ["modules", "module-a", "version"], nodePath: [] })).to.equal(versionString) + expect(await c.resolve({ key: ["modules", "module-a", "version"], nodePath: [], opts: {} })).to.equal(versionString) }) it("should should resolve a project variable", async () => { - expect(await c.resolve({ key: ["variables", "some"], nodePath: [] })).to.equal("variable") + expect(await c.resolve({ key: ["variables", "some"], nodePath: [], opts: {} })).to.equal("variable") }) }) diff --git a/garden-service/test/unit/src/template-string.ts b/garden-service/test/unit/src/template-string.ts index 8e196fc071..54215af985 100644 --- a/garden-service/test/unit/src/template-string.ts +++ b/garden-service/test/unit/src/template-string.ts @@ -1,6 +1,7 @@ import { expect } from "chai" import { resolveTemplateString, resolveTemplateStrings } from "../../../src/template-string" import { ConfigContext } from "../../../src/config/config-context" +import { expectError } from "../../helpers" /* tslint:disable:no-invalid-template-strings */ @@ -22,6 +23,11 @@ describe("resolveTemplateString", async () => { expect(res).to.equal("value") }) + it("should optionally allow undefined values", async () => { + const res = await resolveTemplateString("${some}", new TestContext({}), { allowUndefined: true }) + expect(res).to.equal("") + }) + it("should interpolate a format string with a prefix", async () => { const res = await resolveTemplateString("prefix-${some}", new TestContext({ some: "value" })) expect(res).to.equal("prefix-value") @@ -121,7 +127,7 @@ describe("resolveTemplateString", async () => { try { await resolveTemplateString("${some", new TestContext({ some: {} })) } catch (err) { - expect(err.message).to.equal("Invalid template string: ...${some") + expect(err.message).to.equal("Invalid template string: ${some") return } @@ -151,6 +157,64 @@ describe("resolveTemplateString", async () => { ) expect(res).to.equal("123") }) + + it("should handle a single-quoted string", async () => { + const res = await resolveTemplateString( + "${'foo'}", + new TestContext({}), + ) + expect(res).to.equal("foo") + }) + + it("should handle a double-quoted string", async () => { + const res = await resolveTemplateString( + "${\"foo\"}", + new TestContext({}), + ) + expect(res).to.equal("foo") + }) + + it("should handle a conditional between two identifiers", async () => { + const res = await resolveTemplateString( + "${a || b}", + new TestContext({ a: undefined, b: 123 }), + ) + expect(res).to.equal("123") + }) + + it("should handle a conditional between two identifiers without spaces", async () => { + const res = await resolveTemplateString( + "${a||b}", + new TestContext({ a: undefined, b: 123 }), + ) + expect(res).to.equal("123") + }) + + it("should throw if neither key in conditional is valid", async () => { + return expectError( + () => resolveTemplateString( + "${a || b}", + new TestContext({}), + ), + "configuration", + ) + }) + + it("should handle a conditional between an identifier and a string", async () => { + const res = await resolveTemplateString( + "${a || 'b'}", + new TestContext({ a: undefined }), + ) + expect(res).to.equal("b") + }) + + it("should handle a conditional between a string and a string", async () => { + const res = await resolveTemplateString( + "${'a' || 'b'}", + new TestContext({ a: undefined }), + ) + expect(res).to.equal("a") + }) }) describe("resolveTemplateStrings", () => {