From 312e90bf37c7a45bd223d13fdde9a31957736fe9 Mon Sep 17 00:00:00 2001
From: Jon Edvald <edvald@gmail.com>
Date: Fri, 22 Mar 2019 11:52:12 +0100
Subject: [PATCH] feat(config): support simple OR statements in template
 strings

---
 docs/reference/template-strings.md            | 24 +++--
 garden-service/gulpfile.ts                    |  4 +
 garden-service/src/config/config-context.ts   | 41 +++++---
 .../src/template-string-parser.pegjs          | 98 +++++++++++++++++--
 garden-service/src/template-string.ts         | 10 +-
 .../test/unit/src/config/config-context.ts    | 30 +++---
 .../test/unit/src/template-string.ts          | 66 ++++++++++++-
 7 files changed, 222 insertions(+), 51 deletions(-)

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<Primitive> {
+  async resolve({ key, nodePath, opts }: ContextResolveParams): Promise<Primitive | undefined> {
     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> | 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", () => {