Skip to content

Commit

Permalink
improvement(template): allow using objects as tests in conditionals
Browse files Browse the repository at this point in the history
This was blocking some common templating requirements when conditionally
deploying services/tasks, for example. Error messages are also clearer
now in some cases.

Fixes #1563
edvald authored and eysi09 committed Feb 12, 2020
1 parent a89d2e5 commit 98f0689
Showing 11 changed files with 238 additions and 140 deletions.
34 changes: 10 additions & 24 deletions garden-service/src/actions.ts
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@ import { PublishModuleParams, PublishResult } from "./types/plugin/module/publis
import { SetSecretParams, SetSecretResult } from "./types/plugin/provider/setSecret"
import { validateSchema } from "./config/validation"
import { defaultProvider } from "./config/provider"
import { ParameterError, PluginError, ConfigurationError, InternalError, RuntimeError } from "./exceptions"
import { ParameterError, PluginError, InternalError, RuntimeError } from "./exceptions"
import { Garden } from "./garden"
import { LogEntry } from "./logger/log-entry"
import { Module } from "./types/module"
@@ -85,7 +85,7 @@ import { StopPortForwardParams } from "./types/plugin/service/stopPortForward"
import { emptyRuntimeContext, RuntimeContext } from "./runtime-context"
import { GetServiceStatusTask } from "./tasks/get-service-status"
import { getServiceStatuses } from "./tasks/base"
import { getRuntimeTemplateReferences } from "./template-string"
import { getRuntimeTemplateReferences, resolveTemplateStrings } from "./template-string"
import { getPluginBases, getPluginDependencies, getModuleTypeBases } from "./plugins"
import { ConfigureProviderParams, ConfigureProviderResult } from "./types/plugin/provider/configureProvider"
import { Task } from "./types/task"
@@ -780,19 +780,12 @@ export class ActionRouter implements TypeGuard {
if (!runtimeContextIsEmpty && (await getRuntimeTemplateReferences(module)).length > 0) {
log.silly(`Resolving runtime template strings for service '${service.name}'`)
const configContext = await this.garden.getModuleConfigContext(runtimeContext)
const graph = await this.garden.getConfigGraph(log, { configContext })
// We first allow partial resolution on the full config graph, and then resolve the service config itself
// below with allowPartial=false to ensure all required strings are resolved.
const graph = await this.garden.getConfigGraph(log, { configContext, allowPartial: true })
service = await graph.getService(service.name)
module = service.module

// Make sure everything has been resolved in the task config
const remainingRefs = await getRuntimeTemplateReferences(service.config)
if (remainingRefs.length > 0) {
const unresolvedStrings = remainingRefs.map((ref) => `\${${ref.join(".")}}`).join(", ")
throw new ConfigurationError(
`Unable to resolve one or more runtime template values for service '${service.name}': ${unresolvedStrings}`,
{ service, unresolvedStrings }
)
}
service.config = await resolveTemplateStrings(service.config, configContext, { allowPartial: false })
}

const handlerParams = {
@@ -837,19 +830,12 @@ export class ActionRouter implements TypeGuard {
if (runtimeContext && (await getRuntimeTemplateReferences(module)).length > 0) {
log.silly(`Resolving runtime template strings for task '${task.name}'`)
const configContext = await this.garden.getModuleConfigContext(runtimeContext)
const graph = await this.garden.getConfigGraph(log, { configContext })
// We first allow partial resolution on the full config graph, and then resolve the task config itself
// below with allowPartial=false to ensure all required strings are resolved.
const graph = await this.garden.getConfigGraph(log, { configContext, allowPartial: true })
task = await graph.getTask(task.name)
module = task.module

// Make sure everything has been resolved in the task config
const remainingRefs = await getRuntimeTemplateReferences(task.config)
if (remainingRefs.length > 0) {
const unresolvedStrings = remainingRefs.map((ref) => `\${${ref.join(".")}}`).join(", ")
throw new ConfigurationError(
`Unable to resolve one or more runtime template values for task '${task.name}': ${unresolvedStrings}`,
{ task, unresolvedStrings }
)
}
task.config = await resolveTemplateStrings(task.config, configContext, { allowPartial: false })
}

const handlerParams: any = {
96 changes: 56 additions & 40 deletions garden-service/src/config/config-context.ts
Original file line number Diff line number Diff line change
@@ -6,10 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import Joi = require("@hapi/joi")
import username = require("username")
import Joi from "@hapi/joi"
import username from "username"
import { isString } from "lodash"
import { PrimitiveMap, isPrimitive, Primitive, joiIdentifierMap, joiStringMap, joiPrimitive } from "./common"
import { PrimitiveMap, joiIdentifierMap, joiStringMap, joiPrimitive } from "./common"
import { Provider, ProviderConfig } from "./provider"
import { ModuleConfig } from "./module"
import { ConfigurationError } from "../exceptions"
@@ -25,6 +25,9 @@ import chalk = require("chalk")
export type ContextKey = string[]

export interface ContextResolveOpts {
// Allow templates to be partially resolved (used to defer runtime template resolution, for example)
allowPartial?: boolean
// Allow undefined values to be returned without throwing an error
allowUndefined?: boolean
// a list of previously resolved paths, used to detect circular references
stack?: string[]
@@ -36,8 +39,14 @@ export interface ContextResolveParams {
opts: ContextResolveOpts
}

export interface ContextResolveOutput {
message?: string
partial?: boolean
resolved: any
}

export function schema(joiSchema: Joi.Schema) {
return (target, propName: string) => {
return (target: any, propName: string) => {
target.constructor._schemas = { ...(target.constructor._schemas || {}), [propName]: joiSchema }
}
}
@@ -60,15 +69,15 @@ export abstract class ConfigContext {
.required()
}

async resolve({ key, nodePath, opts }: ContextResolveParams): Promise<Primitive | undefined> {
async resolve({ key, nodePath, opts }: ContextResolveParams): Promise<ContextResolveOutput> {
const path = key.join(".")
const fullPath = nodePath.concat(key).join(".")

// if the key has previously been resolved, return it directly
const resolved = this._resolvedValues[path]

if (resolved) {
return resolved
return { resolved }
}

opts.stack = [...(opts.stack || [])]
@@ -86,6 +95,7 @@ export abstract class ConfigContext {

// keep track of which resolvers have been called, in order to detect circular references
let value: any = this
let partial = false
let nextKey = key[0]
let lookupPath: string[] = []
let nestedNodePath = nodePath
@@ -122,8 +132,12 @@ export abstract class ConfigContext {

// handle nested contexts
if (value instanceof ConfigContext) {
opts.stack.push(stackEntry)
value = await value.resolve({ key: remainder, nodePath: nestedNodePath, opts })
if (remainder.length > 0) {
opts.stack.push(stackEntry)
const res = await value.resolve({ key: remainder, nodePath: nestedNodePath, opts })
value = res.resolved
partial = !!res.partial
}
break
}

@@ -139,36 +153,29 @@ export abstract class ConfigContext {
}

if (value === undefined) {
let message = chalk.red(`Could not find key ${chalk.white(nextKey)}`)
if (nestedNodePath.length > 1) {
message += chalk.red(" under ") + chalk.white(nestedNodePath.slice(0, -1).join("."))
}
message += chalk.red(".")

if (opts.allowUndefined) {
return
return { resolved: undefined, message }
} else {
throw new ConfigurationError(
chalk.red(
`Could not find key ${chalk.white(nextKey)} under ${chalk.white(nestedNodePath.slice(0, -1).join("."))}`
),
{
nodePath,
fullPath,
opts,
}
)
throw new ConfigurationError(message, {
nodePath,
fullPath,
opts,
})
}
}

if (!isPrimitive(value)) {
throw new ConfigurationError(
`Config value at '${path}' exists but is not a primitive (string, number, boolean or null)`,
{
value,
path,
fullPath,
}
)
// Cache result, unless it is a partial resolution
if (!partial) {
this._resolvedValues[path] = value
}

this._resolvedValues[path] = value

return value
return { resolved: value }
}
}

@@ -183,7 +190,7 @@ export class ScanContext extends ConfigContext {
async resolve({ key, nodePath }: ContextResolveParams) {
const fullKey = nodePath.concat(key)
this.foundKeys.add(fullKey)
return "${" + fullKey.join(".") + "}"
return { resolved: "${" + fullKey.join(".") + "}" }
}
}

@@ -506,17 +513,26 @@ class RuntimeConfigContext extends ConfigContext {
}
}

async resolve(params: ContextResolveParams) {
// We're customizing the resolver so that we can ignore missing services/tasks and return the template string back
// for later resolution, but fail when an output on a resolved service/task doesn't exist.
const opts = { ...(params.opts || {}), allowUndefined: true }
async resolve(params: ContextResolveParams): Promise<ContextResolveOutput> {
// We're customizing the resolver so that we can defer and return the template string back
// for later resolution, but fail correctly when attempting to resolve the runtime templates.
const opts = { ...(params.opts || {}), allowUndefined: params.opts.allowPartial || params.opts.allowUndefined }
const res = await super.resolve({ ...params, opts })

if (res === undefined) {
const { key, nodePath } = params
const fullKey = nodePath.concat(key)
return "${" + fullKey.join(".") + "}"
if (res.resolved === undefined) {
if (params.opts.allowPartial) {
// If value can't be resolved and allowPartial is set, we defer the resolution by returning another template
// string, that can be resolved later.
const { key, nodePath } = params
const fullKey = nodePath.concat(key)
return { resolved: "${" + fullKey.join(".") + "}", partial: true }
} else {
// If undefined values are allowed, we simply return undefined (We know allowUndefined is set here, because
// otherwise an error would have been thrown by `super.resolve()` above).
return res
}
} else {
// Value successfully resolved
return res
}
}
3 changes: 2 additions & 1 deletion garden-service/src/resolve-module.ts
Original file line number Diff line number Diff line change
@@ -31,7 +31,8 @@ export async function resolveModuleConfig(
opts.configContext = await garden.getModuleConfigContext()
}

config = await resolveTemplateStrings(cloneDeep(config), opts.configContext, opts)
// Allowing partial resolution here, to defer runtime remplate resolution
config = await resolveTemplateStrings(cloneDeep(config), opts.configContext, { allowPartial: true, ...opts })

const moduleTypeDefinitions = await garden.getModuleTypes()
const description = moduleTypeDefinitions[config.type]
10 changes: 5 additions & 5 deletions garden-service/src/template-string-parser.pegjs
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ TemplateString
= a:(FormatString)+ b:TemplateString? { return [...a, ...(b || [])] }
/ a:Prefix b:(FormatString)+ c:TemplateString? { return [a, ...b, ...(c || [])] }
/ InvalidFormatString
/ $(.*) { return text() === "" ? [] : [text()] }
/ $(.*) { return text() === "" ? [] : [{ resolved: text() }] }

FormatString
= FormatStart e:Expression FormatEnd {
@@ -21,7 +21,7 @@ FormatString
}

if (v === undefined && !options.allowUndefined) {
const _error = new options.TemplateStringError("Unable to resolve one or more keys.", {
const _error = new options.TemplateStringError(v.message || "Unable to resolve one or more keys.", {
text: text(),
})
return { _error }
@@ -88,9 +88,9 @@ UnaryExpression
}

if (operator === "typeof") {
return typeof v
return typeof options.getValue(v)
} else if (operator === "!") {
return !v
return !options.getValue(v)
}
})
.catch(_error => {
@@ -175,7 +175,7 @@ ConditionalExpression
return a
}

return t ? c : a
return options.getValue(t) ? c : a
})
.catch(_error => {
return { _error }
Loading

0 comments on commit 98f0689

Please sign in to comment.