From d61e2447a2077d8f9818292aef1b4d6ba309c799 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Tue, 19 Jan 2021 00:00:31 +0100 Subject: [PATCH] improve(config): allow most template strings in templated module inputs Basically we now defer the full resolution on input template strings, with the caveat that input values needed for module identifiers need to be resolvable immediately. This involved quite a bit of added complexity, which is prompting me to seriously a refactor in our template string resolution logic. This does the job for now, but I think we'll soon want to implement at least partial lazy+recursive resolution of template strings, instead of all this fine-grained logic to control what's resolved when. --- core/src/config/config-context.ts | 14 +++ core/src/config/module-template.ts | 50 ++++++----- core/src/garden.ts | 3 + core/src/plugins/templated.ts | 3 + core/src/resolve-module.ts | 61 +++++++++++-- .../module-templates/module-templates.json | 7 +- .../module-templates/modules.garden.yml | 3 +- .../test-projects/module-templates/source.txt | 4 +- .../module-templates/templates.garden.yml | 15 ++-- core/test/helpers.ts | 6 +- core/test/unit/src/config/base.ts | 13 +-- core/test/unit/src/config/module-template.ts | 90 +++++++++++++++---- core/test/unit/src/garden.ts | 53 ++++++++--- docs/reference/module-types/templated.md | 8 ++ 14 files changed, 254 insertions(+), 76 deletions(-) diff --git a/core/src/config/config-context.ts b/core/src/config/config-context.ts index ca230d6727..8c65a5aa74 100644 --- a/core/src/config/config-context.ts +++ b/core/src/config/config-context.ts @@ -225,6 +225,20 @@ export abstract class ConfigContext { } } +/** + * A generic context that just wraps an object. + */ +export class GenericContext extends ConfigContext { + constructor(obj: any) { + super() + Object.assign(this, obj) + } + + static getSchema() { + return joi.object() + } +} + export class ScanContext extends ConfigContext { foundKeys: KeyedSet diff --git a/core/src/config/module-template.ts b/core/src/config/module-template.ts index 50757b0c02..398459cbad 100644 --- a/core/src/config/module-template.ts +++ b/core/src/config/module-template.ts @@ -11,7 +11,7 @@ import { baseModuleSpecSchema, BaseModuleSpec, ModuleConfig } from "./module" import { dedent, deline } from "../util/string" import { GardenResource, prepareModuleResource } from "./base" import { DOCS_BASE_URL } from "../constants" -import { ProjectConfigContext, ModuleTemplateConfigContext } from "./config-context" +import { ProjectConfigContext, ModuleTemplateConfigContext, EnvironmentConfigContext } from "./config-context" import { resolveTemplateStrings } from "../template-string" import { validateWithPath } from "./validation" import { Garden } from "../garden" @@ -108,8 +108,22 @@ export async function resolveTemplatedModule( config: TemplatedModuleConfig, templates: { [name: string]: ModuleTemplateConfig } ) { - // Resolve template strings for fields - const resolved = resolveTemplateStrings(config, new ProjectConfigContext({ ...garden, branch: garden.vcsBranch })) + // Resolve template strings for fields. Note that inputs are partially resolved, and will be fully resolved later + // when resolving the resolving the resulting modules. Inputs that are used in module names must however be resolvable + // immediately. + const templateContext = new EnvironmentConfigContext({ ...garden, branch: garden.vcsBranch }) + const resolvedWithoutInputs = resolveTemplateStrings( + { ...config, spec: omit(config.spec, "inputs") }, + templateContext + ) + const partiallyResolvedInputs = resolveTemplateStrings(config.spec.inputs || {}, templateContext, { + allowPartial: true, + }) + const resolved = { + ...resolvedWithoutInputs, + spec: { ...resolvedWithoutInputs.spec, inputs: partiallyResolvedInputs }, + } + const configType = "templated module " + resolved.name let resolvedSpec = omit(resolved.spec, "build") @@ -119,9 +133,9 @@ export async function resolveTemplatedModule( return { resolvedSpec, modules: [] } } - // Validate + // Validate the module spec resolvedSpec = validateWithPath({ - config: omit(resolved.spec, "build"), + config: resolvedSpec, configType, path: resolved.configPath || resolved.path, schema: templatedModuleSpecSchema(), @@ -141,28 +155,17 @@ export async function resolveTemplatedModule( ) } - // Validate template inputs - resolvedSpec = validateWithPath({ - config: resolvedSpec, - configType, - path: resolved.configPath || resolved.path, - schema: templatedModuleSpecSchema().keys({ inputs: template.inputsSchema }), - projectRoot: garden.projectRoot, - }) - - const inputs = resolvedSpec.inputs || {} - // Prepare modules and resolve templated names const context = new ModuleTemplateConfigContext({ ...garden, branch: garden.vcsBranch, parentName: resolved.name, templateName: template.name, - inputs, + inputs: partiallyResolvedInputs, }) const modules = await Bluebird.map(template.modules || [], async (m) => { - // Run a partial template resolution with the parent+template info and inputs + // Run a partial template resolution with the parent+template info const spec = resolveTemplateStrings(m, context, { allowPartial: true }) let moduleConfig: ModuleConfig @@ -170,8 +173,15 @@ export async function resolveTemplatedModule( try { moduleConfig = prepareModuleResource(spec, resolved.configPath || resolved.path, garden.projectRoot) } catch (error) { + let msg = error.message + + if (spec.name && spec.name.includes && spec.name.includes("${")) { + msg += + ". Note that if a template string is used in the name of a module in a template, then the template string must be fully resolvable at the time of module scanning. This means that e.g. references to other modules or runtime outputs cannot be used." + } + throw new ConfigurationError( - `${templateKind} ${template.name} returned an invalid module (named ${spec.name}) for templated module ${resolved.name}: ${error.message}`, + `${templateKind} ${template.name} returned an invalid module (named ${spec.name}) for templated module ${resolved.name}: ${msg}`, { moduleSpec: spec, parent: resolvedSpec, @@ -195,7 +205,7 @@ export async function resolveTemplatedModule( // Attach metadata moduleConfig.parentName = resolved.name moduleConfig.templateName = template.name - moduleConfig.inputs = inputs + moduleConfig.inputs = partiallyResolvedInputs return moduleConfig }) diff --git a/core/src/garden.ts b/core/src/garden.ts index 4105f4d4e0..bdf21739d3 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -86,6 +86,7 @@ import { resolveModuleTemplate, resolveTemplatedModule, templateKind, + ModuleTemplateConfig, } from "./config/module-template" import { TemplatedModuleConfig } from "./plugins/templated" import { BuildDirRsync } from "./build-staging/rsync" @@ -184,6 +185,7 @@ export class Garden { private actionHelper: ActionRouter public readonly events: EventBus private tools: { [key: string]: PluginTool } + public moduleTemplates: { [name: string]: ModuleTemplateConfig } public readonly production: boolean public readonly projectRoot: string @@ -1056,6 +1058,7 @@ export class Garden { this.log.silly(`Scanned and found ${rawModuleConfigs.length} modules and ${rawWorkflowConfigs.length} workflows`) this.configsScanned = true + this.moduleTemplates = keyBy(moduleTemplates, "name") }) } diff --git a/core/src/plugins/templated.ts b/core/src/plugins/templated.ts index 7e1ea8ef86..8b17286800 100644 --- a/core/src/plugins/templated.ts +++ b/core/src/plugins/templated.ts @@ -12,6 +12,7 @@ import { templateKind } from "../config/module-template" import { joiIdentifier, joi, DeepPrimitiveMap } from "../config/common" import { dedent, naturalList } from "../util/string" import { omit } from "lodash" +import { DOCS_BASE_URL } from "../constants" export interface TemplatedModuleSpec extends ModuleSpec { template: string @@ -31,6 +32,8 @@ export const templatedModuleSpecSchema = () => inputs: joi.object().description( dedent` A map of inputs to pass to the ${templateKind}. These must match the inputs schema of the ${templateKind}. + + Note: You can use template strings for the inputs, but be aware that inputs that are used to generate the resulting module names and other top-level identifiers must be resolvable when scanning for modules, and thus cannot reference other modules or runtime variables. See the [environment configuration context reference](${DOCS_BASE_URL}/reference/template-strings#environment-configuration-context) to see template strings that are safe to use for inputs used to generate module identifiers. ` ), }) diff --git a/core/src/resolve-module.ts b/core/src/resolve-module.ts index 15233a1ee1..b0a06fb3c3 100644 --- a/core/src/resolve-module.ts +++ b/core/src/resolve-module.ts @@ -9,7 +9,7 @@ import { cloneDeep, keyBy } from "lodash" import { validateWithPath } from "./config/validation" import { resolveTemplateStrings, getModuleTemplateReferences, resolveTemplateString } from "./template-string" -import { ContextResolveOpts, ModuleConfigContext } from "./config/config-context" +import { ContextResolveOpts, ModuleConfigContext, GenericContext } from "./config/config-context" import { relative, resolve, posix } from "path" import { Garden } from "./garden" import { ConfigurationError, FilesystemError, PluginError } from "./exceptions" @@ -80,7 +80,7 @@ export class ModuleResolver { } } for (const rawConfig of this.rawConfigs) { - const deps = this.getModuleTemplateDependencies(rawConfig) + const deps = this.getModuleDependenciesFromTemplateStrings(rawConfig) for (const graph of [fullGraph, processingGraph]) { for (const dep of deps) { graph.addNode(dep.name) @@ -178,7 +178,10 @@ export class ModuleResolver { return Object.values(resolvedModules) } - private getModuleTemplateDependencies(rawConfig: ModuleConfig) { + /** + * Returns module configs for each module that is referenced in a ${modules.*} template string in the raw config. + */ + private getModuleDependenciesFromTemplateStrings(rawConfig: ModuleConfig) { const configContext = new ModuleConfigContext({ garden: this.garden, resolvedProviders: this.resolvedProviders, @@ -221,23 +224,63 @@ export class ModuleResolver { */ async resolveModuleConfig(config: ModuleConfig, dependencies: GardenModule[]): Promise { const garden = this.garden - const configContext = new ModuleConfigContext({ - garden: this.garden, + let inputs = {} + + const templateContextParams = { + garden, resolvedProviders: this.resolvedProviders, - moduleName: config.name, dependencies, runtimeContext: this.runtimeContext, parentName: config.parentName, templateName: config.templateName, - inputs: config.inputs, + inputs, partialRuntimeResolution: true, + } + + // First resolve and validate the inputs field, because template module inputs may not be fully resolved at this + // time. + // TODO: This whole complicated procedure could be much improved and simplified by implementing lazy resolution on + // values... I'll be looking into that. - JE + const templateName = config.templateName + + if (templateName) { + const template = this.garden.moduleTemplates[templateName] + + inputs = resolveTemplateStrings( + inputs, + new ModuleConfigContext(templateContextParams), + // Not all inputs may need to be resolvable + { allowPartial: true } + ) + + inputs = validateWithPath({ + config: cloneDeep(config.inputs || {}), + configType: `inputs for module ${config.name}`, + path: config.configPath || config.path, + schema: template.inputsSchema, + projectRoot: garden.projectRoot, + }) + } + + // Now resolve just references to inputs on the config + config = resolveTemplateStrings(cloneDeep(config), new GenericContext({ inputs }), { + allowPartial: true, }) - config = resolveTemplateStrings(cloneDeep(config), configContext, { + // And finally fully resolve the config + const configContext = new ModuleConfigContext({ + ...templateContextParams, + inputs, + moduleName: config.name, + }) + + config = resolveTemplateStrings({ ...config, inputs: {} }, configContext, { allowPartial: false, }) - const moduleTypeDefinitions = await this.garden.getModuleTypes() + config.inputs = inputs + + const moduleTypeDefinitions = await garden.getModuleTypes() const description = moduleTypeDefinitions[config.type] if (!description) { diff --git a/core/test/data/test-projects/module-templates/module-templates.json b/core/test/data/test-projects/module-templates/module-templates.json index 0947b5b2fb..399c98c606 100644 --- a/core/test/data/test-projects/module-templates/module-templates.json +++ b/core/test/data/test-projects/module-templates/module-templates.json @@ -1,6 +1,11 @@ { "type": "object", "properties": { - "foo": { "type": "string" } + "name": { + "type": "string" + }, + "value": { + "type": "string" + } } } \ No newline at end of file diff --git a/core/test/data/test-projects/module-templates/modules.garden.yml b/core/test/data/test-projects/module-templates/modules.garden.yml index 0e37eefdc7..81d8d03f5f 100644 --- a/core/test/data/test-projects/module-templates/modules.garden.yml +++ b/core/test/data/test-projects/module-templates/modules.garden.yml @@ -3,4 +3,5 @@ type: templated template: combo name: foo inputs: - foo: bar + name: test + value: ${providers.test-plugin.outputs.testKey} diff --git a/core/test/data/test-projects/module-templates/source.txt b/core/test/data/test-projects/module-templates/source.txt index 50ae3e2ef1..4c8d4a231e 100644 --- a/core/test/data/test-projects/module-templates/source.txt +++ b/core/test/data/test-projects/module-templates/source.txt @@ -1,3 +1,3 @@ Hello I am file! -input: ${inputs.foo} -module reference: ${modules["${parent.name}-${inputs.foo}-test-a"].path} +input: ${inputs.value} +module reference: ${modules["${parent.name}-${inputs.name}-a"].path} diff --git a/core/test/data/test-projects/module-templates/templates.garden.yml b/core/test/data/test-projects/module-templates/templates.garden.yml index 53675eb430..cb5068b597 100644 --- a/core/test/data/test-projects/module-templates/templates.garden.yml +++ b/core/test/data/test-projects/module-templates/templates.garden.yml @@ -3,27 +3,28 @@ name: combo inputsSchemaPath: "module-templates.json" modules: - type: test - name: ${parent.name}-${inputs.foo}-test-a + name: ${parent.name}-${inputs.name}-a include: [] + extraFlags: ["${inputs.value}"] generateFiles: - targetPath: module-a.log value: "hellow" - type: test - name: ${parent.name}-${inputs.foo}-test-b + name: ${parent.name}-${inputs.name}-b build: - dependencies: ["${parent.name}-${inputs.foo}-test-a"] + dependencies: ["${parent.name}-${inputs.name}-a"] include: [] generateFiles: - targetPath: module-b.log sourcePath: source.txt - type: test - name: ${parent.name}-${inputs.foo}-test-c + name: ${parent.name}-${inputs.name}-c build: - dependencies: ["${parent.name}-${inputs.foo}-test-a"] + dependencies: ["${parent.name}-${inputs.name}-a"] include: [] generateFiles: - targetPath: .garden/subdir/module-c.log value: | Hello I am string! - input: ${inputs.foo} - module reference: ${modules["${parent.name}-${inputs.foo}-test-a"].path} + input: ${inputs.value} + module reference: ${modules["${parent.name}-${inputs.name}-a"].path} diff --git a/core/test/helpers.ts b/core/test/helpers.ts index bf31f237f9..f314444784 100644 --- a/core/test/helpers.ts +++ b/core/test/helpers.ts @@ -159,8 +159,12 @@ export const testPlugin = createGardenPlugin({ return { url: `http://localhost:12345/${page.name}` } }, + async getEnvironmentStatus() { + return { ready: true, outputs: { testKey: "testValue" } } + }, + async prepareEnvironment() { - return { status: { ready: true, outputs: {} } } + return { status: { ready: true, outputs: { testKey: "testValue" } } } }, async setSecret({ key, value }) { diff --git a/core/test/unit/src/config/base.ts b/core/test/unit/src/config/base.ts index 06aa98ab4d..7ca87d758d 100644 --- a/core/test/unit/src/config/base.ts +++ b/core/test/unit/src/config/base.ts @@ -182,8 +182,9 @@ describe("loadConfigResources", () => { modules: [ { type: "test", - name: "${parent.name}-${inputs.foo}-test-a", + name: "${parent.name}-${inputs.name}-a", include: [], + extraFlags: ["${inputs.value}"], generateFiles: [ { targetPath: "module-a.log", @@ -193,10 +194,10 @@ describe("loadConfigResources", () => { }, { type: "test", - name: "${parent.name}-${inputs.foo}-test-b", + name: "${parent.name}-${inputs.name}-b", include: [], build: { - dependencies: ["${parent.name}-${inputs.foo}-test-a"], + dependencies: ["${parent.name}-${inputs.name}-a"], }, generateFiles: [ { @@ -207,16 +208,16 @@ describe("loadConfigResources", () => { }, { type: "test", - name: "${parent.name}-${inputs.foo}-test-c", + name: "${parent.name}-${inputs.name}-c", include: [], build: { - dependencies: ["${parent.name}-${inputs.foo}-test-a"], + dependencies: ["${parent.name}-${inputs.name}-a"], }, generateFiles: [ { targetPath: ".garden/subdir/module-c.log", value: - 'Hello I am string!\ninput: ${inputs.foo}\nmodule reference: ${modules["${parent.name}-${inputs.foo}-test-a"].path}\n', + 'Hello I am string!\ninput: ${inputs.value}\nmodule reference: ${modules["${parent.name}-${inputs.name}-a"].path}\n', }, ], }, diff --git a/core/test/unit/src/config/module-template.ts b/core/test/unit/src/config/module-template.ts index 0dd03c9ec6..8725961ae8 100644 --- a/core/test/unit/src/config/module-template.ts +++ b/core/test/unit/src/config/module-template.ts @@ -20,6 +20,7 @@ import { resolve } from "path" import { joi } from "../../../../src/config/common" import { pathExists, remove } from "fs-extra" import { TemplatedModuleConfig } from "../../../../src/plugins/templated" +import { cloneDeep } from "lodash" describe("module templates", () => { let garden: TestGarden @@ -241,25 +242,6 @@ describe("module templates", () => { ) }) - it("throws if inputs don't match inputs schema", async () => { - const config: TemplatedModuleConfig = { - ...defaults, - spec: { - ...defaults.spec, - inputs: { - foo: 123, - }, - }, - } - await expectError( - () => resolveTemplatedModule(garden, config, templates), - (err) => - expect(stripAnsi(err.message)).to.equal( - "Error validating templated module test (modules.garden.yml): key .inputs.foo must be a string" - ) - ) - }) - it("fully resolves the source path on module files", async () => { const _templates = { test: { @@ -447,5 +429,75 @@ describe("module templates", () => { ) ) }) + + it("resolves project variable references in input fields", async () => { + const _templates: any = { + test: { + ...template, + modules: [ + { + type: "test", + name: "${inputs.name}-test", + }, + ], + }, + } + + const config: TemplatedModuleConfig = cloneDeep(defaults) + config.spec.inputs = { name: "${var.test}" } + garden.variables.test = "test-value" + + const resolved = await resolveTemplatedModule(garden, config, _templates) + + expect(resolved.modules[0].name).to.equal("test-value-test") + }) + + it("passes through unresolvable template strings in inputs field", async () => { + const _templates: any = { + test: { + ...template, + modules: [ + { + type: "test", + name: "test", + }, + ], + }, + } + + const templateString = "version-${modules.foo.version}" + + const config: TemplatedModuleConfig = cloneDeep(defaults) + config.spec.inputs = { version: templateString } + + const resolved = await resolveTemplatedModule(garden, config, _templates) + + expect(resolved.modules[0].inputs?.version).to.equal(templateString) + }) + + it("throws if an unresolvable template string is used for a templated module name", async () => { + const _templates: any = { + test: { + ...template, + modules: [ + { + type: "test", + name: "${inputs.name}-test", + }, + ], + }, + } + + const config: TemplatedModuleConfig = cloneDeep(defaults) + config.spec.inputs = { name: "module-${modules.foo.version}" } + + await expectError( + () => resolveTemplatedModule(garden, config, _templates), + (err) => + expect(stripAnsi(err.message)).to.equal( + 'ModuleTemplate test returned an invalid module (named module-${modules.foo.version}-test) for templated module test: Error validating module (modules.garden.yml): key .name with value "module-${modules.foo.version}-test" fails to match the required pattern: /^(?!garden)(?=.{1,63}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/. Note that if a template string is used in the name of a module in a template, that the template string must be fully resolvable at the time of module scanning. This means that e.g. references to other modules or runtime outputs cannot be used.' + ) + ) + }) }) }) diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index 080639c014..d04462beaf 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -2303,8 +2303,8 @@ describe("Garden", () => { const garden = await makeTestGarden(root) await garden.scanAndAddConfigs() - const configA = (await garden.getRawModuleConfigs(["foo-bar-test-a"]))[0] - const configB = (await garden.getRawModuleConfigs(["foo-bar-test-b"]))[0] + const configA = (await garden.getRawModuleConfigs(["foo-test-a"]))[0] + const configB = (await garden.getRawModuleConfigs(["foo-test-b"]))[0] expect(omitUndefined(configA)).to.eql({ apiVersion: "garden.io/v0", @@ -2314,13 +2314,14 @@ describe("Garden", () => { }, include: [], configPath: resolve(root, "modules.garden.yml"), - name: "foo-bar-test-a", + name: "foo-test-a", path: root, serviceConfigs: [], spec: { build: { dependencies: [], }, + extraFlags: ["${providers.test-plugin.outputs.testKey}"], }, testConfigs: [], type: "test", @@ -2335,23 +2336,24 @@ describe("Garden", () => { parentName: "foo", templateName: "combo", inputs: { - foo: "bar", + name: "test", + value: "${providers.test-plugin.outputs.testKey}", }, }) expect(omitUndefined(configB)).to.eql({ apiVersion: "garden.io/v0", kind: "Module", build: { - dependencies: [{ name: "foo-bar-test-a", copy: [] }], + dependencies: [{ name: "foo-test-a", copy: [] }], }, include: [], configPath: resolve(root, "modules.garden.yml"), - name: "foo-bar-test-b", + name: "foo-test-b", path: root, serviceConfigs: [], spec: { build: { - dependencies: [{ name: "foo-bar-test-a", copy: [] }], + dependencies: [{ name: "foo-test-a", copy: [] }], }, }, testConfigs: [], @@ -2366,7 +2368,8 @@ describe("Garden", () => { parentName: "foo", templateName: "combo", inputs: { - foo: "bar", + name: "test", + value: "${providers.test-plugin.outputs.testKey}", }, }) }) @@ -2858,7 +2861,7 @@ describe("Garden", () => { expect(fileContents.toString().trim()).to.equal(dedent` Hello I am file! - input: bar + input: testValue module reference: ${projectRoot} `) }) @@ -2876,7 +2879,7 @@ describe("Garden", () => { expect(fileContents.toString().trim()).to.equal(dedent` Hello I am string! - input: bar + input: testValue module reference: ${projectRoot} `) }) @@ -3048,6 +3051,36 @@ describe("Garden", () => { `) ) }) + + it("fully resolves module template inputs before resolving templated modules", async () => { + const root = resolve(dataDir, "test-projects", "module-templates") + const garden = await makeTestGarden(root) + + const graph = await garden.getConfigGraph(garden.log) + const moduleA = graph.getModule("foo-test-a") + + expect(moduleA.spec.extraFlags).to.eql(["testValue"]) + }) + + it("throws if templated module inputs don't match the template inputs schema", async () => { + const root = resolve(dataDir, "test-projects", "module-templates") + const garden = await makeTestGarden(root) + + await garden.scanAndAddConfigs() + + const moduleA = garden["moduleConfigs"]["foo-test-a"] + moduleA.inputs = { name: "test", value: 123 } + + await expectError( + () => garden.getConfigGraph(garden.log), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Failed resolving one or more modules: + + foo-test-a: Error validating inputs for module foo-test-a (modules.garden.yml): value at ..value should be string + `) + ) + }) }) context("module type has a base", () => { diff --git a/docs/reference/module-types/templated.md b/docs/reference/module-types/templated.md index 624c0a6ae3..ab20f08f8b 100644 --- a/docs/reference/module-types/templated.md +++ b/docs/reference/module-types/templated.md @@ -127,6 +127,12 @@ generateFiles: template: # A map of inputs to pass to the ModuleTemplate. These must match the inputs schema of the ModuleTemplate. +# +# Note: You can use template strings for the inputs, but be aware that inputs that are used to generate the resulting +# module names and other top-level identifiers must be resolvable when scanning for modules, and thus cannot reference +# other modules or runtime variables. See the [environment configuration context +# reference](https://docs.garden.io/reference/template-strings#environment-configuration-context) to see template +# strings that are safe to use for inputs used to generate module identifiers. inputs: ``` @@ -379,6 +385,8 @@ The ModuleTemplate to use to generate the sub-modules of this module. A map of inputs to pass to the ModuleTemplate. These must match the inputs schema of the ModuleTemplate. +Note: You can use template strings for the inputs, but be aware that inputs that are used to generate the resulting module names and other top-level identifiers must be resolvable when scanning for modules, and thus cannot reference other modules or runtime variables. See the [environment configuration context reference](https://docs.garden.io/reference/template-strings#environment-configuration-context) to see template strings that are safe to use for inputs used to generate module identifiers. + | Type | Required | | -------- | -------- | | `object` | No |