From e8ef6b22c26a8176b1fc6492cf6998e255296459 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Tue, 1 Dec 2020 18:34:38 +0100 Subject: [PATCH] refactor: reduce work at module resolution time Just a bit of debt being cleaned up here, but it does have a small impact on init time. The primary change was in the plugin interface, which is now a callback that is resolved later in the init process. --- cli/src/cli.ts | 15 +- cli/src/generate-docs.ts | 4 +- cli/test/unit/src/cli.ts | 4 +- core/src/cli/cli.ts | 6 +- core/src/commands/commands.ts | 4 +- core/src/commands/create/create-module.ts | 6 +- core/src/commands/tools.ts | 14 +- core/src/commands/util/fetch-tools.ts | 4 +- core/src/config/base.ts | 2 +- core/src/config/module.ts | 23 +- core/src/config/workflow.ts | 4 +- core/src/docs/commands.ts | 4 +- core/src/docs/generate.ts | 11 +- core/src/garden.ts | 113 ++--- core/src/plugins.ts | 116 ++++-- core/src/plugins/base-volume.ts | 23 +- core/src/plugins/container/container.ts | 149 +++---- core/src/plugins/exec.ts | 111 ++--- core/src/plugins/google/google-app-engine.ts | 131 +++--- .../plugins/google/google-cloud-functions.ts | 99 ++--- core/src/plugins/hadolint/hadolint.ts | 387 +++++++++--------- core/src/plugins/kubernetes/config.ts | 336 +++++++-------- core/src/plugins/kubernetes/kubernetes.ts | 85 ++-- core/src/plugins/kubernetes/local/config.ts | 35 +- core/src/plugins/kubernetes/local/local.ts | 19 +- .../volumes/persistentvolumeclaim.ts | 4 +- core/src/plugins/local/local-docker-swarm.ts | 353 ++++++++-------- .../local/local-google-cloud-functions.ts | 245 +++++------ .../maven-container/maven-container.ts | 37 +- core/src/plugins/npm-package.ts | 24 +- core/src/plugins/octant/octant.ts | 179 ++++---- core/src/plugins/openfaas/openfaas.ts | 59 +-- core/src/plugins/plugins.ts | 49 ++- core/src/plugins/terraform/commands.ts | 6 +- core/src/plugins/terraform/module.ts | 21 +- core/src/plugins/terraform/terraform.ts | 97 +++-- core/src/server/commands.ts | 4 +- core/src/tasks/resolve-provider.ts | 4 +- core/src/types/plugin/base.ts | 4 +- core/src/types/plugin/module/runModule.ts | 2 +- core/src/types/plugin/plugin.ts | 4 +- .../types/plugin/service/getPortForward.ts | 2 +- core/src/types/plugin/service/runService.ts | 2 +- .../types/plugin/service/stopPortForward.ts | 2 +- core/src/types/plugin/task/runTask.ts | 2 +- core/src/types/service.ts | 6 +- core/test/helpers.ts | 275 +++++++------ .../unit/src/commands/create/create-module.ts | 6 +- core/test/unit/src/commands/run/task.ts | 57 +-- core/test/unit/src/commands/tools.ts | 5 +- .../unit/src/commands/util/fetch-tools.ts | 9 +- core/test/unit/src/garden.ts | 68 +-- .../unit/src/plugins/container/container.ts | 2 +- .../unit/src/plugins/container/helpers.ts | 2 +- core/test/unit/src/plugins/invalid-name.ts | 9 +- .../plugins/kubernetes/container/ingress.ts | 4 +- .../unit/src/plugins/kubernetes/kubernetes.ts | 4 +- .../maven-container/maven-container.ts | 4 +- .../unit/src/plugins/terraform/terraform.ts | 36 +- plugins/conftest-container/index.ts | 2 +- plugins/conftest-kubernetes/index.ts | 2 +- plugins/conftest/index.ts | 6 +- 62 files changed, 1692 insertions(+), 1610 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 79c24a39d0..9b8cc040d2 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -12,14 +12,15 @@ import { getDefaultProfiler } from "@garden-io/core/build/src/util/profiling" import { GardenProcess } from "@garden-io/core/build/src/db/entities/garden-process" import { ensureConnected } from "@garden-io/core/build/src/db/connection" import { GardenCli, RunOutput } from "@garden-io/core/build/src/cli/cli" -import { GardenPlugin } from "@garden-io/core/build/src/types/plugin/plugin" +import { GardenPluginCallback } from "@garden-io/core/build/src/types/plugin/plugin" // These plugins are always registered -export const bundledPlugins = [ - require("@garden-io/garden-conftest"), - require("@garden-io/garden-conftest-container"), - require("@garden-io/garden-conftest-kubernetes"), -].map((m) => m.gardenPlugin as GardenPlugin) +export const getBundledPlugins = (): GardenPluginCallback[] => + [ + require("@garden-io/garden-conftest"), + require("@garden-io/garden-conftest-container"), + require("@garden-io/garden-conftest-kubernetes"), + ].map((m) => () => m.gardenPlugin()) export async function runCli({ args, @@ -38,7 +39,7 @@ export async function runCli({ try { if (!cli) { - cli = new GardenCli({ plugins: bundledPlugins }) + cli = new GardenCli({ plugins: getBundledPlugins() }) } // Note: We slice off the binary/script name from argv. result = await cli.run({ args, exitOnError, processRecord }) diff --git a/cli/src/generate-docs.ts b/cli/src/generate-docs.ts index 5370710697..bf3a096f0b 100644 --- a/cli/src/generate-docs.ts +++ b/cli/src/generate-docs.ts @@ -11,7 +11,7 @@ import { resolve } from "path" import { Logger } from "@garden-io/core/build/src/logger/logger" import { LogLevel } from "@garden-io/core/build/src/logger/log-node" import { GARDEN_CLI_ROOT } from "@garden-io/core/build/src/constants" -import { bundledPlugins } from "./cli" +import { getBundledPlugins } from "./cli" require("source-map-support").install() @@ -24,7 +24,7 @@ try { }) } catch (_) {} -generateDocs(resolve(GARDEN_CLI_ROOT, "..", "docs"), bundledPlugins) +generateDocs(resolve(GARDEN_CLI_ROOT, "..", "docs"), getBundledPlugins()) .then(() => { // tslint:disable-next-line: no-console console.log("Done!") diff --git a/cli/test/unit/src/cli.ts b/cli/test/unit/src/cli.ts index ab9709df93..857c09b991 100644 --- a/cli/test/unit/src/cli.ts +++ b/cli/test/unit/src/cli.ts @@ -10,7 +10,7 @@ import { expect } from "chai" import { find } from "lodash" import { resolve } from "path" -import { runCli, bundledPlugins } from "../../../src/cli" +import { runCli, getBundledPlugins } from "../../../src/cli" import { testRoot } from "../../helpers" import { GardenCli } from "@garden-io/core/build/src/cli/cli" @@ -29,7 +29,7 @@ describe("runCli", () => { const projectRoot = resolve(testRoot, "test-projects", "bundled-projects") const { cli, result } = await runCli({ args: ["tools", "--root", projectRoot], exitOnError: false }) - expect(cli!["plugins"]).to.eql(bundledPlugins) + expect(cli!["plugins"].map((p) => p.name)).to.eql(getBundledPlugins().map((p) => p.name)) const conftestTool = result?.result?.tools?.find((t) => t.pluginName === "conftest") expect(conftestTool).to.exist diff --git a/core/src/cli/cli.ts b/core/src/cli/cli.ts index e889331fc6..bb18f1e7cd 100644 --- a/core/src/cli/cli.ts +++ b/core/src/cli/cli.ts @@ -37,7 +37,7 @@ import { defaultDotIgnoreFiles } from "../util/fs" import { BufferedEventStream } from "../enterprise/buffered-event-stream" import { GardenProcess } from "../db/entities/garden-process" import { DashboardEventStream } from "../server/dashboard-event-stream" -import { GardenPlugin } from "../types/plugin/plugin" +import { GardenPluginCallback } from "../types/plugin/plugin" import { renderError } from "../logger/renderers" import { EnterpriseApi } from "../enterprise/api" @@ -90,9 +90,9 @@ export interface RunOutput { export class GardenCli { private commands: { [key: string]: Command } = {} private fileWritersInitialized: boolean = false - private plugins: GardenPlugin[] + private plugins: GardenPluginCallback[] - constructor({ plugins }: { plugins?: GardenPlugin[] } = {}) { + constructor({ plugins }: { plugins?: GardenPluginCallback[] } = {}) { this.plugins = plugins || [] const commands = sortBy(getAllCommands(), (c) => c.name) diff --git a/core/src/commands/commands.ts b/core/src/commands/commands.ts index 00795d2d08..0afd9973a5 100644 --- a/core/src/commands/commands.ts +++ b/core/src/commands/commands.ts @@ -35,7 +35,7 @@ import { LogOutCommand } from "./logout" import { ToolsCommand } from "./tools" import { UtilCommand } from "./util/util" -export const coreCommands: (Command | CommandGroup)[] = [ +export const getCoreCommands = (): (Command | CommandGroup)[] => [ new BuildCommand(), new CallCommand(), new ConfigCommand(), @@ -66,5 +66,5 @@ export const coreCommands: (Command | CommandGroup)[] = [ ] export function getAllCommands() { - return coreCommands.flatMap((cmd) => (cmd instanceof CommandGroup ? [cmd, ...cmd.getSubCommands()] : [cmd])) + return getCoreCommands().flatMap((cmd) => (cmd instanceof CommandGroup ? [cmd, ...cmd.getSubCommands()] : [cmd])) } diff --git a/core/src/commands/create/create-module.ts b/core/src/commands/create/create-module.ts index 201c7efe54..f6baed168c 100644 --- a/core/src/commands/create/create-module.ts +++ b/core/src/commands/create/create-module.ts @@ -18,7 +18,7 @@ import { resolve, basename, relative, join } from "path" import { GardenBaseError, ParameterError } from "../../exceptions" import { getModuleTypes, getPluginBaseNames } from "../../plugins" import { addConfig } from "./helpers" -import { supportedPlugins } from "../../plugins/plugins" +import { getSupportedPlugins } from "../../plugins/plugins" import { baseModuleSpecSchema } from "../../config/module" import { renderConfigReference } from "../../docs/config" import { DOCS_BASE_URL } from "../../constants" @@ -119,7 +119,7 @@ export class CreateModuleCommand extends Command p())) if (opts.interactive && (!opts.name || !opts.type)) { log.root.stop() @@ -219,7 +219,7 @@ export class CreateModuleCommand extends Command getPluginBaseNames(p.name, keyBy(supportedPlugins, "name"))) + allProviders.map((p) => getPluginBaseNames(p.name, keyBy(getSupportedPlugins, "name"))) ) if (!allProvidersWithBases.includes(pluginName)) { diff --git a/core/src/commands/tools.ts b/core/src/commands/tools.ts index 06b1dd6661..aed35a190a 100644 --- a/core/src/commands/tools.ts +++ b/core/src/commands/tools.ts @@ -88,7 +88,7 @@ export class ToolsCommand extends Command { } async action({ garden, log, args, opts }: CommandParams) { - const tools = getTools(garden) + const tools = await getTools(garden) if (!args.tool) { // We're listing tools, not executing one @@ -113,7 +113,7 @@ export class ToolsCommand extends Command { } // We're executing a tool - const availablePlugins = Object.values(garden["registeredPlugins"]) + const availablePlugins = await garden.getAllPlugins() let plugins = availablePlugins if (pluginName) { @@ -136,7 +136,7 @@ export class ToolsCommand extends Command { log.debug(`Unable to resolve project config: ${err.message}`) } } - const configuredPlugins = await garden.getPlugins() + const configuredPlugins = await garden.getAllPlugins() plugins = uniqByName([...configuredPlugins, ...availablePlugins]) } } @@ -180,15 +180,15 @@ export class ToolsCommand extends Command { } } -function getTools(garden: Garden) { - const registeredPlugins = Object.values(garden["registeredPlugins"]) +async function getTools(garden: Garden) { + const registeredPlugins = await garden.getAllPlugins() return sortBy(registeredPlugins, "name").flatMap((plugin) => (plugin.tools || []).map((tool) => ({ ...omit(tool, "_includeInGardenImage"), pluginName: plugin.name })) ) } -function printTools(garden: Garden, log: LogEntry) { +async function printTools(garden: Garden, log: LogEntry) { log.info(dedent` ${chalk.white.bold("USAGE")} @@ -198,7 +198,7 @@ function printTools(garden: Garden, log: LogEntry) { ${chalk.white.bold("PLUGIN TOOLS")} `) - const tools = getTools(garden) + const tools = await getTools(garden) const rows = tools.map((tool) => { return [ diff --git a/core/src/commands/util/fetch-tools.ts b/core/src/commands/util/fetch-tools.ts index a37e2155d4..2567436d0a 100644 --- a/core/src/commands/util/fetch-tools.ts +++ b/core/src/commands/util/fetch-tools.ts @@ -58,7 +58,7 @@ export class FetchToolsCommand extends Command<{}, FetchToolsOpts> { let plugins: GardenPlugin[] if (opts.all) { - plugins = Object.values(garden.registeredPlugins) + plugins = await garden.getAllPlugins() printHeader(log, "Fetching tools for all registered providers", "hammer_and_wrench") } else { const projectRoot = findProjectConfig(garden.projectRoot) @@ -74,7 +74,7 @@ export class FetchToolsCommand extends Command<{}, FetchToolsOpts> { garden = await Garden.factory(garden.projectRoot, { ...omit(garden.opts, "config"), log }) } - plugins = await garden.getPlugins() + plugins = await garden.getConfiguredPlugins() printHeader(log, "Fetching all tools for the current project and environment", "hammer_and_wrench") } diff --git a/core/src/config/base.ts b/core/src/config/base.ts index 6c7d5ffb2b..3edc2f5263 100644 --- a/core/src/config/base.ts +++ b/core/src/config/base.ts @@ -172,7 +172,7 @@ export function prepareModuleResource(spec: any, configPath: string, projectRoot repositoryUrl: spec.repositoryUrl, serviceConfigs: [], spec: { - ...omit(spec, baseModuleSchemaKeys), + ...omit(spec, baseModuleSchemaKeys()), build: { ...spec.build, dependencies }, }, testConfigs: [], diff --git a/core/src/config/module.ts b/core/src/config/module.ts index b8bd511009..d450020b17 100644 --- a/core/src/config/module.ts +++ b/core/src/config/module.ts @@ -267,17 +267,18 @@ export const moduleConfigSchema = () => .description("The configuration for a module.") .unknown(false) -export const baseModuleSchemaKeys = Object.keys(baseModuleSpecSchema().describe().keys).concat([ - "kind", - "name", - "type", - "path", - "configPath", - "serviceConfigs", - "taskConfigs", - "testConfigs", - "_config", -]) +export const baseModuleSchemaKeys = () => + Object.keys(baseModuleSpecSchema().describe().keys).concat([ + "kind", + "name", + "type", + "path", + "configPath", + "serviceConfigs", + "taskConfigs", + "testConfigs", + "_config", + ]) export function serializeConfig(moduleConfig: Partial) { return stableStringify(moduleConfig) diff --git a/core/src/config/workflow.ts b/core/src/config/workflow.ts index 4ab6b698da..807b1b5b16 100644 --- a/core/src/config/workflow.ts +++ b/core/src/config/workflow.ts @@ -16,7 +16,7 @@ import { WorkflowConfigContext } from "./config-context" import { resolveTemplateStrings } from "../template-string" import { validateWithPath } from "./validation" import { ConfigurationError } from "../exceptions" -import { coreCommands } from "../commands/commands" +import { getCoreCommands } from "../commands/commands" import { CommandGroup } from "../commands/base" import { EnvironmentConfig, getNamespace } from "./project" import { globalOptions } from "../cli/params" @@ -335,7 +335,7 @@ export function resolveWorkflowConfig(garden: Garden, config: WorkflowConfig) { * Get all commands whitelisted for workflows */ function getStepCommands() { - return coreCommands + return getCoreCommands() .flatMap((cmd) => { if (cmd instanceof CommandGroup) { return cmd.getSubCommands() diff --git a/core/src/docs/commands.ts b/core/src/docs/commands.ts index 6346bf9f93..127389f615 100644 --- a/core/src/docs/commands.ts +++ b/core/src/docs/commands.ts @@ -10,7 +10,7 @@ import { readFileSync, writeFileSync } from "fs" import handlebars from "handlebars" import { resolve } from "path" import { globalOptions } from "../cli/params" -import { coreCommands } from "../commands/commands" +import { getCoreCommands } from "../commands/commands" import { describeParameters, CommandGroup } from "../commands/base" import { TEMPLATES_DIR, renderConfigReference } from "./config" @@ -18,7 +18,7 @@ export function writeCommandReferenceDocs(docsRoot: string) { const referenceDir = resolve(docsRoot, "reference") const outputPath = resolve(referenceDir, "commands.md") - const commands = coreCommands + const commands = getCoreCommands() .flatMap((cmd) => { if (cmd instanceof CommandGroup && cmd.subCommands?.length) { return cmd diff --git a/core/src/docs/generate.ts b/core/src/docs/generate.ts index 26381ceabb..745d55c8d4 100644 --- a/core/src/docs/generate.ts +++ b/core/src/docs/generate.ts @@ -19,18 +19,21 @@ import { writeFileSync, readFile, writeFile } from "fs-extra" import { renderModuleTypeReference, moduleTypes } from "./module-type" import { renderProviderReference } from "./provider" import { defaultNamespace } from "../config/project" -import { GardenPlugin } from "../types/plugin/plugin" +import { GardenPlugin, GardenPluginCallback } from "../types/plugin/plugin" import { workflowConfigSchema } from "../config/workflow" import { moduleTemplateSchema } from "../config/module-template" -export async function generateDocs(targetDir: string, plugins: GardenPlugin[]) { +export async function generateDocs(targetDir: string, plugins: GardenPluginCallback[]) { // tslint:disable: no-console const docsRoot = resolve(process.cwd(), targetDir) console.log("Updating command references...") writeCommandReferenceDocs(docsRoot) console.log("Updating config references...") - await writeConfigReferenceDocs(docsRoot, plugins) + await writeConfigReferenceDocs( + docsRoot, + plugins.map((p) => p()) + ) console.log("Updating template string reference...") writeTemplateStringReferenceDocs(docsRoot) console.log("Generating table of contents...") @@ -77,7 +80,7 @@ export async function writeConfigReferenceDocs(docsRoot: string, plugins: Garden }) const providerDir = resolve(docsRoot, "reference", "providers") - const allPlugins = await garden.getPlugins() + const allPlugins = await garden.getAllPlugins() const pluginsByName = keyBy(allPlugins, "name") const providersReadme = ["---", "order: 1", "title: Providers", "---", "", "# Providers", ""] diff --git a/core/src/garden.ts b/core/src/garden.ts index bdf21739d3..11762ca40c 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -11,14 +11,14 @@ import chalk from "chalk" import { ensureDir } from "fs-extra" import dedent from "dedent" import { platform, arch } from "os" -import { parse, relative, resolve, join } from "path" -import { flatten, isString, sortBy, fromPairs, keyBy, mapValues, cloneDeep, groupBy } from "lodash" +import { relative, resolve, join } from "path" +import { flatten, sortBy, fromPairs, keyBy, mapValues, cloneDeep, groupBy } from "lodash" const AsyncLock = require("async-lock") import { TreeCache } from "./cache" -import { builtinPlugins } from "./plugins/plugins" +import { getBuiltinPlugins } from "./plugins/plugins" import { GardenModule, getModuleCacheContext, getModuleKey, ModuleConfigMap, moduleFromConfig } from "./types/module" -import { pluginModuleSchema, ModuleTypeMap } from "./types/plugin/plugin" +import { ModuleTypeMap } from "./types/plugin/plugin" import { SourceConfig, ProjectConfig, @@ -40,7 +40,6 @@ import { getLogger } from "./logger/logger" import { PluginActionHandlers, GardenPlugin } from "./types/plugin/plugin" import { loadConfigResources, findProjectConfig, prepareModuleResource, GardenResource } from "./config/base" import { DeepPrimitiveMap, StringMap, PrimitiveMap } from "./config/common" -import { validateSchema } from "./config/validation" import { BaseTask } from "./tasks/base" import { LocalConfigStore, ConfigStore, GlobalConfigStore, LinkedSource } from "./config-store" import { getLinkedSources, ExternalSourceType } from "./util/ext-source-util" @@ -172,7 +171,7 @@ export class Garden { private pluginModuleConfigs: ModuleConfig[] private resolvedProviders: { [key: string]: Provider } protected configsScanned: boolean - public readonly registeredPlugins: { [key: string]: GardenPlugin } + protected registeredPlugins: RegisterPluginParam[] private readonly taskGraph: TaskGraph private watcher: Watcher private asyncLock: any @@ -267,17 +266,12 @@ export class Garden { this.moduleConfigs = {} this.pluginModuleConfigs = [] this.workflowConfigs = {} - this.registeredPlugins = {} + this.registeredPlugins = [...getBuiltinPlugins(), ...params.plugins] this.resolvedProviders = {} this.taskGraph = new TaskGraph(this, this.log) this.events = new EventBus() - // Register plugins - for (const plugin of [...builtinPlugins, ...params.plugins]) { - this.registerPlugin(plugin) - } - // TODO: actually resolve version, based on the VCS version of the plugin and its dependencies this.version = { versionString: getPackageVersion(), @@ -448,52 +442,8 @@ export class Garden { this.watcher = new Watcher(this, this.log, paths, modules, bufferInterval) } - private registerPlugin(nameOrPlugin: RegisterPluginParam) { - let plugin: GardenPlugin - - if (isString(nameOrPlugin)) { - let moduleNameOrLocation = nameOrPlugin - - // allow relative references to project root - if (parse(moduleNameOrLocation).dir !== "") { - moduleNameOrLocation = resolve(this.projectRoot, moduleNameOrLocation) - } - - let pluginModule: any - - try { - pluginModule = require(moduleNameOrLocation) - } catch (error) { - throw new ConfigurationError( - `Unable to load plugin "${moduleNameOrLocation}" (could not load module: ${error.message})`, - { - message: error.message, - moduleNameOrLocation, - } - ) - } - - try { - pluginModule = validateSchema(pluginModule, pluginModuleSchema(), { - context: `plugin module "${moduleNameOrLocation}"`, - }) - } catch (err) { - throw new PluginError(`Unable to load plugin: ${err}`, { - moduleNameOrLocation, - err, - }) - } - - plugin = pluginModule.gardenPlugin - } else { - plugin = nameOrPlugin - } - - this.registeredPlugins[plugin.name] = plugin - } - async getPlugin(pluginName: string): Promise { - const plugins = await this.getPlugins() + const plugins = await this.getAllPlugins() const plugin = findByName(plugins, pluginName) if (!plugin) { @@ -511,7 +461,10 @@ export class Garden { return plugin } - async getPlugins() { + /** + * Returns all registered plugins, loading them if necessary. + */ + async getAllPlugins() { // The duplicated check is a small optimization to avoid the async lock when possible, // since this is called quite frequently. if (this.loadedPlugins) { @@ -527,7 +480,7 @@ export class Garden { this.log.silly(`Loading plugins`) const rawConfigs = this.getRawProviderConfigs() - this.loadedPlugins = loadPlugins(this.log, this.registeredPlugins, rawConfigs) + this.loadedPlugins = await loadPlugins(this.log, this.projectRoot, this.registeredPlugins, rawConfigs) this.log.silly(`Loaded plugins: ${rawConfigs.map((c) => c.name).join(", ")}`) }) @@ -536,13 +489,19 @@ export class Garden { } /** - * Returns a mapping of all configured module types in the project and their definitions. + * Returns plugins that are currently configured in provider configs. */ - async getModuleTypes(): Promise { - const plugins = await this.getPlugins() + async getConfiguredPlugins() { + const plugins = await this.getAllPlugins() const configNames = keyBy(this.getRawProviderConfigs(), "name") - const configuredPlugins = plugins.filter((p) => configNames[p.name]) + return plugins.filter((p) => configNames[p.name]) + } + /** + * Returns a mapping of all configured module types in the project and their definitions. + */ + async getModuleTypes(): Promise { + const configuredPlugins = await this.getConfiguredPlugins() return getModuleTypes(configuredPlugins) } @@ -605,13 +564,21 @@ export class Garden { status: "active", }) - const plugins = keyBy(await this.getPlugins(), "name") + const plugins = keyBy(await this.getAllPlugins(), "name") // Detect circular dependencies here const validationGraph = new DependencyValidationGraph() await Bluebird.map(rawConfigs, async (config) => { const plugin = plugins[config.name] + + if (!plugin) { + throw new ConfigurationError(`Configured provider '${config.name}' has not been registered.`, { + name: config.name, + availablePlugins: Object.keys(plugins), + }) + } + validationGraph.addNode(plugin.name) for (const dep of await getAllProviderDependencyNames(plugin!, config!)) { @@ -693,7 +660,7 @@ export class Garden { async getTools() { if (!this.tools) { - const plugins = await this.getPlugins() + const plugins = await this.getAllPlugins() const tools: PluginTools = {} for (const plugin of Object.values(plugins)) { @@ -729,7 +696,7 @@ export class Garden { async getActionRouter() { if (!this.actionHelper) { - const loadedPlugins = await this.getPlugins() + const loadedPlugins = await this.getAllPlugins() const moduleTypes = await this.getModuleTypes() const plugins = keyBy(loadedPlugins, "name") @@ -803,13 +770,19 @@ export class Garden { } // Walk through all plugins in dependency order, and allow them to augment the graph - const providerConfigs = Object.values(resolvedProviders).map((p) => p.config) + const plugins = keyBy(await this.getAllPlugins(), "name") + + for (const pluginName of getDependencyOrder(plugins)) { + const provider = resolvedProviders[pluginName] + + if (!provider) { + continue + } - for (const provider of getDependencyOrder(providerConfigs, this.registeredPlugins)) { // Skip the routine if the provider doesn't have the handler const handler = await actions.getActionHandler({ actionType: "augmentGraph", - pluginName: provider.name, + pluginName, throwIfMissing: false, }) @@ -824,7 +797,7 @@ export class Garden { } const { addBuildDependencies, addRuntimeDependencies, addModules } = await actions.augmentGraph({ - pluginName: provider.name, + pluginName, log, providers: resolvedProviders, modules: resolvedModules, diff --git a/core/src/plugins.ts b/core/src/plugins.ts index fe1da9eb53..dd9b5cae8f 100644 --- a/core/src/plugins.ts +++ b/core/src/plugins.ts @@ -13,26 +13,36 @@ import { ModuleTypeExtension, pluginSchema, ModuleTypeMap, + RegisterPluginParam, + pluginModuleSchema, } from "./types/plugin/plugin" import { GenericProviderConfig } from "./config/provider" import { ConfigurationError, PluginError, RuntimeError } from "./exceptions" -import { uniq, mapValues, fromPairs, flatten, keyBy, some } from "lodash" +import { uniq, mapValues, fromPairs, flatten, keyBy, some, isString, isFunction } from "lodash" import { findByName, pushToKey, getNames } from "./util/util" import { deline } from "./util/string" import { validateSchema } from "./config/validation" import { LogEntry } from "./logger/log-entry" import { DependencyValidationGraph } from "./util/validate-dependencies" +import { parse, resolve } from "path" +import Bluebird from "bluebird" + +export async function loadPlugins( + log: LogEntry, + projectRoot: string, + registeredPlugins: RegisterPluginParam[], + configs: GenericProviderConfig[] +) { + const initializedPlugins: PluginMap = {} + const loadedPlugins = keyBy(await Bluebird.map(registeredPlugins, (p) => loadPlugin(log, projectRoot, p)), "name") -export function loadPlugins(log: LogEntry, registeredPlugins: PluginMap, configs: GenericProviderConfig[]) { - const loadedPlugins: PluginMap = {} - - const loadPlugin = (name: string) => { - if (loadedPlugins[name]) { - return loadedPlugins[name] + const validatePlugin = (name: string) => { + if (initializedPlugins[name]) { + return initializedPlugins[name] } - log.silly(`Loading plugin ${name}`) - let plugin = registeredPlugins[name] + log.silly(`Validating plugin ${name}`) + let plugin = loadedPlugins[name] if (!plugin) { return null @@ -42,7 +52,7 @@ export function loadPlugins(log: LogEntry, registeredPlugins: PluginMap, configs context: `plugin "${name}"`, }) - loadedPlugins[name] = plugin + initializedPlugins[name] = plugin if (plugin.base) { if (plugin.base === plugin.name) { @@ -51,13 +61,13 @@ export function loadPlugins(log: LogEntry, registeredPlugins: PluginMap, configs }) } - const base = loadPlugin(plugin.base) + const base = validatePlugin(plugin.base) if (!base) { throw new PluginError( `Plugin '${plugin.name}' specifies plugin '${plugin.base}' as a base, ` + `but that plugin has not been registered.`, - { registeredPlugins: Object.keys(registeredPlugins), base: plugin.base } + { loadedPlugins: Object.keys(loadedPlugins), base: plugin.base } ) } @@ -68,12 +78,12 @@ export function loadPlugins(log: LogEntry, registeredPlugins: PluginMap, configs } for (const dep of plugin.dependencies || []) { - const depPlugin = loadPlugin(dep) + const depPlugin = validatePlugin(dep) if (!depPlugin) { throw new PluginError( `Plugin '${plugin.name}' lists plugin '${dep}' as a dependency, but that plugin has not been registered.`, - { registeredPlugins: Object.keys(registeredPlugins), dependency: dep } + { loadedPlugins: Object.keys(loadedPlugins), dependency: dep } ) } } @@ -84,33 +94,82 @@ export function loadPlugins(log: LogEntry, registeredPlugins: PluginMap, configs } // Load plugins in dependency order - const orderedConfigs = getDependencyOrder(configs, registeredPlugins) + const configsByName = keyBy(configs, "name") + const orderedPlugins = getDependencyOrder(loadedPlugins) - for (const config of orderedConfigs) { - const plugin = loadPlugin(config.name) + for (const name of orderedPlugins) { + const plugin = validatePlugin(name) - if (!plugin) { - throw new ConfigurationError(`Configured provider '${config.name}' has not been registered.`, { - name: config.name, - availablePlugins: Object.keys(registeredPlugins), + if (!plugin && configsByName[name]) { + throw new ConfigurationError(`Configured provider '${name}' has not been registered.`, { + name, + availablePlugins: Object.keys(loadedPlugins), }) } } // Resolve plugins against their base plugins - const resolvedPlugins = mapValues(loadedPlugins, (p) => resolvePlugin(p, loadedPlugins, configs)) + const resolvedPlugins = mapValues(initializedPlugins, (p) => resolvePlugin(p, initializedPlugins, configs)) // Resolve module type definitions return Object.values(resolveModuleDefinitions(resolvedPlugins, configs)) } +async function loadPlugin(log: LogEntry, projectRoot: string, nameOrPlugin: RegisterPluginParam) { + let plugin: GardenPlugin + + if (isString(nameOrPlugin)) { + let moduleNameOrLocation = nameOrPlugin + + // allow relative references to project root + if (parse(moduleNameOrLocation).dir !== "") { + moduleNameOrLocation = resolve(projectRoot, moduleNameOrLocation) + } + + let pluginModule: any + + try { + pluginModule = require(moduleNameOrLocation) + } catch (error) { + throw new ConfigurationError( + `Unable to load plugin "${moduleNameOrLocation}" (could not load module: ${error.message})`, + { + message: error.message, + moduleNameOrLocation, + } + ) + } + + try { + pluginModule = validateSchema(pluginModule, pluginModuleSchema(), { + context: `plugin module "${moduleNameOrLocation}"`, + }) + } catch (err) { + throw new PluginError(`Unable to load plugin: ${err}`, { + moduleNameOrLocation, + err, + }) + } + + plugin = pluginModule.gardenPlugin + } else if (isFunction(nameOrPlugin)) { + plugin = nameOrPlugin() + } else { + plugin = nameOrPlugin + } + + log.silly(`Loaded plugin ${plugin.name}`) + + return plugin +} + /** - * Returns the given provider configs in dependency order. + * Returns the given provider plugins in dependency order. */ -export function getDependencyOrder(configs: T[], registeredPlugins: PluginMap): T[] { +export function getDependencyOrder(loadedPlugins: PluginMap): string[] { const graph = new DependencyValidationGraph() - for (const plugin of Object.values(registeredPlugins)) { + for (const plugin of Object.values(loadedPlugins)) { graph.addNode(plugin.name) if (plugin.base) { @@ -132,12 +191,7 @@ export function getDependencyOrder(configs: T[] throw new PluginError(`Found a circular dependency between registered plugins:\n\n${description}`, detail) } - const ordered = graph.overallOrder() - - // Note: concat() makes sure we're not mutating the original array, because JS... - return configs.concat().sort((a, b) => { - return ordered.indexOf(a.name) - ordered.indexOf(b.name) - }) + return graph.overallOrder() } // Takes a plugin and resolves it against its base plugin, if applicable diff --git a/core/src/plugins/base-volume.ts b/core/src/plugins/base-volume.ts index fa52991721..0539f0d659 100644 --- a/core/src/plugins/base-volume.ts +++ b/core/src/plugins/base-volume.ts @@ -35,16 +35,17 @@ export const baseVolumeSpecSchema = () => `), }) -export const gardenPlugin = createGardenPlugin({ - name: "base-volume", - createModuleTypes: [ - { - name: "base-volume", - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "base-volume", + createModuleTypes: [ + { + name: "base-volume", + docs: dedent` Internal abstraction used for specifying and referencing (usually persistent) volumes by other module types. `, - schema: baseVolumeSpecSchema(), - handlers: {}, - }, - ], -}) + schema: baseVolumeSpecSchema(), + handlers: {}, + }, + ], + }) diff --git a/core/src/plugins/container/container.ts b/core/src/plugins/container/container.ts index 4c8c6eab36..d9ed708f99 100644 --- a/core/src/plugins/container/container.ts +++ b/core/src/plugins/container/container.ts @@ -216,16 +216,17 @@ async function suggestModules({ name, path }: SuggestModulesParams): Promise + createGardenPlugin({ + name: "container", + docs: dedent` Provides the [container](${getModuleTypeUrl("container")}) module type. _Note that this provider is currently automatically included, and you do not need to configure it in your project configuration._ `, - createModuleTypes: [ - { - name: "container", - docs: dedent` + createModuleTypes: [ + { + name: "container", + docs: dedent` Specify a container image to build or pull from a remote registry. You may also optionally specify services to deploy, tasks or tests to run inside the container. @@ -234,76 +235,76 @@ export const gardenPlugin = createGardenPlugin({ other module types like [helm](${getModuleTypeUrl("helm")}) or [kubernetes](${getModuleTypeUrl("kubernetes")}). `, - moduleOutputsSchema: containerModuleOutputsSchema(), - schema: containerModuleSpecSchema(), - taskOutputsSchema, - handlers: { - configure: configureContainerModule, - suggestModules, - getBuildStatus: getContainerBuildStatus, - build: buildContainerModule, - publish: publishContainerModule, - - async getModuleOutputs({ moduleConfig, version }) { - const deploymentImageName = containerHelpers.getDeploymentImageName(moduleConfig, undefined) - const deploymentImageId = containerHelpers.getDeploymentImageId(moduleConfig, version, undefined) - - return { - outputs: { - "local-image-name": containerHelpers.getLocalImageName(moduleConfig), - "local-image-id": containerHelpers.getLocalImageId(moduleConfig, version), - "deployment-image-name": deploymentImageName, - "deployment-image-id": deploymentImageId, - }, - } - }, + moduleOutputsSchema: containerModuleOutputsSchema(), + schema: containerModuleSpecSchema(), + taskOutputsSchema, + handlers: { + configure: configureContainerModule, + suggestModules, + getBuildStatus: getContainerBuildStatus, + build: buildContainerModule, + publish: publishContainerModule, + + async getModuleOutputs({ moduleConfig, version }) { + const deploymentImageName = containerHelpers.getDeploymentImageName(moduleConfig, undefined) + const deploymentImageId = containerHelpers.getDeploymentImageId(moduleConfig, version, undefined) + + return { + outputs: { + "local-image-name": containerHelpers.getLocalImageName(moduleConfig), + "local-image-id": containerHelpers.getLocalImageId(moduleConfig, version), + "deployment-image-name": deploymentImageName, + "deployment-image-id": deploymentImageId, + }, + } + }, - async hotReloadService(_: HotReloadServiceParams) { - return {} + async hotReloadService(_: HotReloadServiceParams) { + return {} + }, }, }, - }, - ], - configSchema: providerConfigBaseSchema(), - tools: [ - { - name: "docker", - description: "The official Docker CLI.", - type: "binary", - _includeInGardenImage: true, - builds: [ - { - platform: "darwin", - architecture: "amd64", - url: "https://download.docker.com/mac/static/stable/x86_64/docker-19.03.6.tgz", - sha256: "82d279c6a2df05c2bb628607f4c3eacb5a7447be6d5f2a2f65643fbb6ed2f9af", - extract: { - format: "tar", - targetPath: "docker/docker", + ], + configSchema: providerConfigBaseSchema(), + tools: [ + { + name: "docker", + description: "The official Docker CLI.", + type: "binary", + _includeInGardenImage: true, + builds: [ + { + platform: "darwin", + architecture: "amd64", + url: "https://download.docker.com/mac/static/stable/x86_64/docker-19.03.6.tgz", + sha256: "82d279c6a2df05c2bb628607f4c3eacb5a7447be6d5f2a2f65643fbb6ed2f9af", + extract: { + format: "tar", + targetPath: "docker/docker", + }, }, - }, - { - platform: "linux", - architecture: "amd64", - url: "https://download.docker.com/linux/static/stable/x86_64/docker-19.03.6.tgz", - sha256: "34ff89ce917796594cd81149b1777d07786d297ffd0fef37a796b5897052f7cc", - extract: { - format: "tar", - targetPath: "docker/docker", + { + platform: "linux", + architecture: "amd64", + url: "https://download.docker.com/linux/static/stable/x86_64/docker-19.03.6.tgz", + sha256: "34ff89ce917796594cd81149b1777d07786d297ffd0fef37a796b5897052f7cc", + extract: { + format: "tar", + targetPath: "docker/docker", + }, }, - }, - { - platform: "windows", - architecture: "amd64", - url: - "https://github.com/rgl/docker-ce-windows-binaries-vagrant/releases/download/v19.03.6/docker-19.03.6.zip", - sha256: "b4591baa2b7016af9ff3328a26146e4db3e6ce3fbe0503a7fd87363f29d63f5c", - extract: { - format: "zip", - targetPath: "docker/docker.exe", + { + platform: "windows", + architecture: "amd64", + url: + "https://github.com/rgl/docker-ce-windows-binaries-vagrant/releases/download/v19.03.6/docker-19.03.6.zip", + sha256: "b4591baa2b7016af9ff3328a26146e4db3e6ce3fbe0503a7fd87363f29d63f5c", + extract: { + format: "zip", + targetPath: "docker/docker.exe", + }, }, - }, - ], - }, - ], -}) + ], + }, + ], + }) diff --git a/core/src/plugins/exec.ts b/core/src/plugins/exec.ts index 4b669846cb..81e870337a 100644 --- a/core/src/plugins/exec.ts +++ b/core/src/plugins/exec.ts @@ -326,26 +326,27 @@ export async function runExecTask(params: RunTaskParams): Promise + createGardenPlugin({ + name: "exec", + docs: dedent` A simple provider that allows running arbitary scripts when initializing providers, and provides the exec module type. _Note: This provider is always loaded when running Garden. You only need to explicitly declare it in your provider configuration if you want to configure a script for it to run._ `, - configSchema: providerConfigBaseSchema().keys({ - initScript: joi.string().description(dedent` + configSchema: providerConfigBaseSchema().keys({ + initScript: joi.string().description(dedent` An optional script to run in the project root when initializing providers. This is handy for running an arbitrary script when initializing. For example, another provider might declare a dependency on this provider, to ensure this script runs before resolving that provider. `), - }), - createModuleTypes: [ - { - name: "exec", - docs: dedent` + }), + createModuleTypes: [ + { + name: "exec", + docs: dedent` A simple module for executing commands in your shell. This can be a useful escape hatch if no other module type fits your needs, and you just need to execute something (as opposed to deploy it, track its status etc.). @@ -357,55 +358,55 @@ export const execPlugin = createGardenPlugin({ This means that include/exclude filters and ignore files are not applied to local exec modules, as the filtering is done during the sync. `, - moduleOutputsSchema: joi.object().keys({}), - schema: execModuleSpecSchema(), - taskOutputsSchema: joi.object().keys({ - log: joi - .string() - .allow("") - .default("") - .description( - "The full log from the executed task. " + - "(Pro-tip: Make it machine readable so it can be parsed by dependant tasks and services!)" - ), - }), - handlers: { - configure: configureExecModule, - build: buildExecModule, - runTask: runExecTask, - testModule: testExecModule, + moduleOutputsSchema: joi.object().keys({}), + schema: execModuleSpecSchema(), + taskOutputsSchema: joi.object().keys({ + log: joi + .string() + .allow("") + .default("") + .description( + "The full log from the executed task. " + + "(Pro-tip: Make it machine readable so it can be parsed by dependant tasks and services!)" + ), + }), + handlers: { + configure: configureExecModule, + build: buildExecModule, + runTask: runExecTask, + testModule: testExecModule, + }, }, - }, - ], - handlers: { - async getEnvironmentStatus({ ctx }) { - // Return ready if there is no initScript to run - return { ready: !ctx.provider.config.initScript, outputs: {} } - }, - async prepareEnvironment({ ctx, log }) { - if (ctx.provider.config.initScript) { - try { - log.info({ section: "exec", msg: "Running init script" }) - await runScript(log, ctx.projectRoot, ctx.provider.config.initScript) - } catch (_err) { - const error = _err as ExecaError - - // Unexpected error (failed to execute script, as opposed to script returning an error code) - if (!error.exitCode) { - throw error + ], + handlers: { + async getEnvironmentStatus({ ctx }) { + // Return ready if there is no initScript to run + return { ready: !ctx.provider.config.initScript, outputs: {} } + }, + async prepareEnvironment({ ctx, log }) { + if (ctx.provider.config.initScript) { + try { + log.info({ section: "exec", msg: "Running init script" }) + await runScript(log, ctx.projectRoot, ctx.provider.config.initScript) + } catch (_err) { + const error = _err as ExecaError + + // Unexpected error (failed to execute script, as opposed to script returning an error code) + if (!error.exitCode) { + throw error + } + + throw new RuntimeError(`exec provider init script exited with code ${error.exitCode}`, { + exitCode: error.exitCode, + stdout: error.stdout, + stderr: error.stderr, + }) } - - throw new RuntimeError(`exec provider init script exited with code ${error.exitCode}`, { - exitCode: error.exitCode, - stdout: error.stdout, - stderr: error.stderr, - }) } - } - return { status: { ready: true, outputs: {} } } + return { status: { ready: true, outputs: {} } } + }, }, - }, -}) + }) export const gardenPlugin = execPlugin diff --git a/core/src/plugins/google/google-app-engine.ts b/core/src/plugins/google/google-app-engine.ts index bdd1843aec..323b379062 100644 --- a/core/src/plugins/google/google-app-engine.ts +++ b/core/src/plugins/google/google-app-engine.ts @@ -21,83 +21,84 @@ const configSchema = providerConfigBaseSchema().keys({ project: joi.string().required().description("The GCP project to deploy containers to."), }) -export const gardenPlugin = createGardenPlugin({ - name: "google-app-engine", - docs: "EXPERIMENTAL", - configSchema, - handlers: { - getEnvironmentStatus, - prepareEnvironment, - }, - extendModuleTypes: [ - { - name: "container", - handlers: { - async getModuleOutputs({ ctx, moduleConfig }) { - // TODO: we may want to pull this from the service status instead, along with other outputs - const project = ctx.provider.config.project - const endpoint = `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${moduleConfig.name}` - - return { - outputs: { - endpoint, - }, - } - }, +export const gardenPlugin = () => + createGardenPlugin({ + name: "google-app-engine", + docs: "EXPERIMENTAL", + configSchema, + handlers: { + getEnvironmentStatus, + prepareEnvironment, + }, + extendModuleTypes: [ + { + name: "container", + handlers: { + async getModuleOutputs({ ctx, moduleConfig }) { + // TODO: we may want to pull this from the service status instead, along with other outputs + const project = ctx.provider.config.project + const endpoint = `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${moduleConfig.name}` - async getServiceStatus(): Promise { - // TODO - // const project = this.getProject(service, env) - // - // const appStatus = await this.gcloud(project).json(["app", "describe"]) - // const services = await this.gcloud(project).json(["app", "services", "list"]) - // const instances: any[] = await this.gcloud(project).json(["app", "instances", "list"]) + return { + outputs: { + endpoint, + }, + } + }, - return { state: "unknown", detail: {} } - }, + async getServiceStatus(): Promise { + // TODO + // const project = this.getProject(service, env) + // + // const appStatus = await this.gcloud(project).json(["app", "describe"]) + // const services = await this.gcloud(project).json(["app", "services", "list"]) + // const instances: any[] = await this.gcloud(project).json(["app", "instances", "list"]) - async deployService({ ctx, service, runtimeContext, log }: DeployServiceParams) { - log.info({ - section: service.name, - msg: `Deploying app...`, - }) + return { state: "unknown", detail: {} } + }, - const config = service.spec + async deployService({ ctx, service, runtimeContext, log }: DeployServiceParams) { + log.info({ + section: service.name, + msg: `Deploying app...`, + }) - // prepare app.yaml - const appYaml: any = { - runtime: "custom", - env: "flex", - env_variables: { ...runtimeContext.envVars, ...service.spec.env }, - } + const config = service.spec - if (config.healthCheck) { - if (config.healthCheck.tcpPort || config.healthCheck.command) { - log.warn({ - section: service.name, - msg: "GAE only supports httpGet health checks", - }) + // prepare app.yaml + const appYaml: any = { + runtime: "custom", + env: "flex", + env_variables: { ...runtimeContext.envVars, ...service.spec.env }, } - if (config.healthCheck.httpGet) { - appYaml.liveness_check = { path: config.healthCheck.httpGet.path } - appYaml.readiness_check = { path: config.healthCheck.httpGet.path } + + if (config.healthCheck) { + if (config.healthCheck.tcpPort || config.healthCheck.command) { + log.warn({ + section: service.name, + msg: "GAE only supports httpGet health checks", + }) + } + if (config.healthCheck.httpGet) { + appYaml.liveness_check = { path: config.healthCheck.httpGet.path } + appYaml.readiness_check = { path: config.healthCheck.httpGet.path } + } } - } - // write app.yaml to build context - const appYamlPath = join(service.module.path, "app.yaml") - await dumpYaml(appYamlPath, appYaml) + // write app.yaml to build context + const appYamlPath = join(service.module.path, "app.yaml") + await dumpYaml(appYamlPath, appYaml) - // deploy to GAE - const project = ctx.provider.config.project + // deploy to GAE + const project = ctx.provider.config.project - await gcloud(project).call(["app", "deploy", "--quiet"], { cwd: service.module.path }) + await gcloud(project).call(["app", "deploy", "--quiet"], { cwd: service.module.path }) - log.info({ section: service.name, msg: `App deployed` }) + log.info({ section: service.name, msg: `App deployed` }) - return { state: "ready", detail: {} } + return { state: "ready", detail: {} } + }, }, }, - }, - ], -}) + ], + }) diff --git a/core/src/plugins/google/google-cloud-functions.ts b/core/src/plugins/google/google-cloud-functions.ts index 18a0c6abee..baca5a12ae 100644 --- a/core/src/plugins/google/google-cloud-functions.ts +++ b/core/src/plugins/google/google-cloud-functions.ts @@ -83,57 +83,58 @@ const configSchema = providerConfigBaseSchema().keys({ .description("The default GCP project to deploy functions to (can be overridden on individual functions)."), }) -export const gardenPlugin = createGardenPlugin({ - name: "google-cloud-function", - docs: "EXPERIMENTAL", - configSchema, - handlers: { - getEnvironmentStatus, - prepareEnvironment, - }, - createModuleTypes: [ - { - name: "google-cloud-function", - docs: "(TODO)", - schema: gcfModuleSpecSchema(), - handlers: { - configure: configureGcfModule, - - async getModuleOutputs({ ctx, moduleConfig }) { - const project = moduleConfig.spec.project || ctx.provider.config.defaultProject - - return { - outputs: { - endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${name}`, - }, - } - }, - - async deployService(params: DeployServiceParams) { - const { ctx, service } = params - - // TODO: provide env vars somehow to function - const project = getGcfProject(service, ctx.provider) - const functionPath = resolve(service.module.path, service.spec.path) - const entrypoint = service.spec.entrypoint || service.name - - await gcloud(project).call([ - "beta", - "functions", - "deploy", - service.name, - `--source=${functionPath}`, - `--entry-point=${entrypoint}`, - // TODO: support other trigger types - "--trigger-http", - ]) - - return getServiceStatus(params) +export const gardenPlugin = () => + createGardenPlugin({ + name: "google-cloud-function", + docs: "EXPERIMENTAL", + configSchema, + handlers: { + getEnvironmentStatus, + prepareEnvironment, + }, + createModuleTypes: [ + { + name: "google-cloud-function", + docs: "(TODO)", + schema: gcfModuleSpecSchema(), + handlers: { + configure: configureGcfModule, + + async getModuleOutputs({ ctx, moduleConfig }) { + const project = moduleConfig.spec.project || ctx.provider.config.defaultProject + + return { + outputs: { + endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${name}`, + }, + } + }, + + async deployService(params: DeployServiceParams) { + const { ctx, service } = params + + // TODO: provide env vars somehow to function + const project = getGcfProject(service, ctx.provider) + const functionPath = resolve(service.module.path, service.spec.path) + const entrypoint = service.spec.entrypoint || service.name + + await gcloud(project).call([ + "beta", + "functions", + "deploy", + service.name, + `--source=${functionPath}`, + `--entry-point=${entrypoint}`, + // TODO: support other trigger types + "--trigger-http", + ]) + + return getServiceStatus(params) + }, }, }, - }, - ], -}) + ], + }) export async function getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { const project = getGcfProject(service, ctx.provider) diff --git a/core/src/plugins/hadolint/hadolint.ts b/core/src/plugins/hadolint/hadolint.ts index f93b83d02a..6373230540 100644 --- a/core/src/plugins/hadolint/hadolint.ts +++ b/core/src/plugins/hadolint/hadolint.ts @@ -67,71 +67,72 @@ const moduleTypeUrl = getModuleTypeUrl("hadolint") const providerUrl = getProviderUrl("hadolint") const gitHubUrl = getGitHubUrl("examples/hadolint") -export const gardenPlugin = createGardenPlugin({ - name: "hadolint", - dependencies: ["container"], - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "hadolint", + dependencies: ["container"], + docs: dedent` This provider creates a [\`hadolint\`](${moduleTypeUrl}) module type, and (by default) generates one such module for each \`container\` module that contains a Dockerfile in your project. Each module creates a single test that runs [hadolint](https://github.com/hadolint/hadolint) against the Dockerfile in question, in order to ensure that the Dockerfile is valid and follows best practices. To configure \`hadolint\`, you can use \`.hadolint.yaml\` config files. For each test, we first look for one in the relevant module root. If none is found there, we check the project root, and if none is there we fall back to default configuration. Note that for reasons of portability, we do not fall back to global/user configuration files. See the [hadolint docs](https://github.com/hadolint/hadolint#configure) for details on how to configure it, and the [hadolint example project](${gitHubUrl}) for a usage example. `, - configSchema, - handlers: { - augmentGraph: async ({ ctx, modules }) => { - const provider = ctx.provider as HadolintProvider - - if (!provider.config.autoInject) { - return {} - } - - const allModuleNames = new Set(modules.map((m) => m.name)) - - const existingHadolintModuleDockerfiles = modules - .filter((m) => m.compatibleTypes.includes("hadolint")) - .map((m) => resolve(m.path, m.spec.dockerfilePath)) - - return { - addModules: await Bluebird.filter(modules, async (module) => { - const dockerfilePath = containerHelpers.getDockerfileSourcePath(module) - - return ( - // Pick all container or container-based modules - module.compatibleTypes.includes("container") && - // Make sure we don't step on an existing custom hadolint module - !existingHadolintModuleDockerfiles.includes(dockerfilePath) && - // Only create for modules with Dockerfiles - containerHelpers.hasDockerfile(module, module.version) - ) - }).map((module) => { - const baseName = "hadolint-" + module.name - - let name = baseName - let i = 2 - - while (allModuleNames.has(name)) { - name = `${baseName}-${i++}` - } - - allModuleNames.add(name) - - return { - kind: "Module", - type: "hadolint", - name, - description: `hadolint test for module '${module.name}' (auto-generated)`, - path: module.path, - dockerfilePath: relative(module.path, containerHelpers.getDockerfileSourcePath(module)), - } - }), - } + configSchema, + handlers: { + augmentGraph: async ({ ctx, modules }) => { + const provider = ctx.provider as HadolintProvider + + if (!provider.config.autoInject) { + return {} + } + + const allModuleNames = new Set(modules.map((m) => m.name)) + + const existingHadolintModuleDockerfiles = modules + .filter((m) => m.compatibleTypes.includes("hadolint")) + .map((m) => resolve(m.path, m.spec.dockerfilePath)) + + return { + addModules: await Bluebird.filter(modules, async (module) => { + const dockerfilePath = containerHelpers.getDockerfileSourcePath(module) + + return ( + // Pick all container or container-based modules + module.compatibleTypes.includes("container") && + // Make sure we don't step on an existing custom hadolint module + !existingHadolintModuleDockerfiles.includes(dockerfilePath) && + // Only create for modules with Dockerfiles + containerHelpers.hasDockerfile(module, module.version) + ) + }).map((module) => { + const baseName = "hadolint-" + module.name + + let name = baseName + let i = 2 + + while (allModuleNames.has(name)) { + name = `${baseName}-${i++}` + } + + allModuleNames.add(name) + + return { + kind: "Module", + type: "hadolint", + name, + description: `hadolint test for module '${module.name}' (auto-generated)`, + path: module.path, + dockerfilePath: relative(module.path, containerHelpers.getDockerfileSourcePath(module)), + } + }), + } + }, }, - }, - createModuleTypes: [ - { - name: "hadolint", - docs: dedent` + createModuleTypes: [ + { + name: "hadolint", + docs: dedent` Runs \`hadolint\` on the specified Dockerfile. > Note: In most cases, you'll let the [provider](${providerUrl}) create this module type automatically, but you may in some cases want or need to manually specify a Dockerfile to lint. @@ -142,144 +143,144 @@ export const gardenPlugin = createGardenPlugin({ See the [hadolint docs](https://github.com/hadolint/hadolint#configure) for details on how to configure it. `, - schema: joi.object().keys({ - build: baseBuildSpecSchema(), - dockerfilePath: joi - .posixPath() - .relativeOnly() - .subPathOnly() - .required() - .description("POSIX-style path to a Dockerfile that you want to lint with `hadolint`."), - }), - handlers: { - configure: async ({ moduleConfig }) => { - moduleConfig.include = [moduleConfig.spec.dockerfilePath] - moduleConfig.testConfigs = [{ name: "lint", dependencies: [], spec: {}, timeout: 10, disabled: false }] - return { moduleConfig } - }, - testModule: async ({ ctx, log, module, testConfig }: TestModuleParams) => { - const dockerfilePath = join(module.path, module.spec.dockerfilePath) - const startedAt = new Date() - let dockerfile: string - - try { - dockerfile = (await readFile(dockerfilePath)).toString() - } catch { - throw new ConfigurationError(`hadolint: Could not find Dockerfile at ${module.spec.dockerfilePath}`, { - modulePath: module.path, - ...module.spec, - }) - } - - let configPath: string - const moduleConfigPath = join(module.path, configFilename) - const projectConfigPath = join(ctx.projectRoot, configFilename) - - if (await pathExists(moduleConfigPath)) { - // Prefer configuration from the module root - configPath = moduleConfigPath - } else if (await pathExists(projectConfigPath)) { - // 2nd preference is configuration in project root - configPath = projectConfigPath - } else { - // Fall back to empty default config - configPath = defaultConfigPath - } - - const args = ["--config", configPath, "--format", "json", dockerfilePath] - const result = await ctx.tools["hadolint.hadolint"].exec({ log, args, ignoreError: true }) - - let success = true - - const parsed = JSON.parse(result.stdout) - const errors = parsed.filter((p: any) => p.level === "error") - const warnings = parsed.filter((p: any) => p.level === "warning") - const provider = ctx.provider as HadolintProvider - - const resultCategories: string[] = [] - let formattedResult = "OK" - - if (errors.length > 0) { - resultCategories.push(`${errors.length} error(s)`) - } - - if (warnings.length > 0) { - resultCategories.push(`${warnings.length} warning(s)`) - } - - let formattedHeader = `hadolint reported ${naturalList(resultCategories)}` - - if (parsed.length > 0) { - const dockerfileLines = splitLines(dockerfile) - - formattedResult = - `${formattedHeader}:\n\n` + - parsed - .map((msg: any) => { - const color = msg.level === "error" ? chalk.bold.red : chalk.bold.yellow - const rawLine = dockerfileLines[msg.line - 1] - const linePrefix = padEnd(`${msg.line}:`, 5, " ") - const columnCursorPosition = (msg.column || 1) + linePrefix.length - - return dedent` + schema: joi.object().keys({ + build: baseBuildSpecSchema(), + dockerfilePath: joi + .posixPath() + .relativeOnly() + .subPathOnly() + .required() + .description("POSIX-style path to a Dockerfile that you want to lint with `hadolint`."), + }), + handlers: { + configure: async ({ moduleConfig }) => { + moduleConfig.include = [moduleConfig.spec.dockerfilePath] + moduleConfig.testConfigs = [{ name: "lint", dependencies: [], spec: {}, timeout: 10, disabled: false }] + return { moduleConfig } + }, + testModule: async ({ ctx, log, module, testConfig }: TestModuleParams) => { + const dockerfilePath = join(module.path, module.spec.dockerfilePath) + const startedAt = new Date() + let dockerfile: string + + try { + dockerfile = (await readFile(dockerfilePath)).toString() + } catch { + throw new ConfigurationError(`hadolint: Could not find Dockerfile at ${module.spec.dockerfilePath}`, { + modulePath: module.path, + ...module.spec, + }) + } + + let configPath: string + const moduleConfigPath = join(module.path, configFilename) + const projectConfigPath = join(ctx.projectRoot, configFilename) + + if (await pathExists(moduleConfigPath)) { + // Prefer configuration from the module root + configPath = moduleConfigPath + } else if (await pathExists(projectConfigPath)) { + // 2nd preference is configuration in project root + configPath = projectConfigPath + } else { + // Fall back to empty default config + configPath = defaultConfigPath + } + + const args = ["--config", configPath, "--format", "json", dockerfilePath] + const result = await ctx.tools["hadolint.hadolint"].exec({ log, args, ignoreError: true }) + + let success = true + + const parsed = JSON.parse(result.stdout) + const errors = parsed.filter((p: any) => p.level === "error") + const warnings = parsed.filter((p: any) => p.level === "warning") + const provider = ctx.provider as HadolintProvider + + const resultCategories: string[] = [] + let formattedResult = "OK" + + if (errors.length > 0) { + resultCategories.push(`${errors.length} error(s)`) + } + + if (warnings.length > 0) { + resultCategories.push(`${warnings.length} warning(s)`) + } + + let formattedHeader = `hadolint reported ${naturalList(resultCategories)}` + + if (parsed.length > 0) { + const dockerfileLines = splitLines(dockerfile) + + formattedResult = + `${formattedHeader}:\n\n` + + parsed + .map((msg: any) => { + const color = msg.level === "error" ? chalk.bold.red : chalk.bold.yellow + const rawLine = dockerfileLines[msg.line - 1] + const linePrefix = padEnd(`${msg.line}:`, 5, " ") + const columnCursorPosition = (msg.column || 1) + linePrefix.length + + return dedent` ${color(msg.code + ":")} ${chalk.bold(msg.message || "")} ${linePrefix}${chalk.gray(rawLine)} ${chalk.gray(padStart("^", columnCursorPosition, "-"))} ` - }) - .join("\n") - } - - const threshold = provider.config.testFailureThreshold - - if (warnings.length > 0 && threshold === "warning") { - success = false - } else if (errors.length > 0 && threshold !== "none") { - success = false - } else if (warnings.length > 0) { - log.warn(chalk.yellow(formattedHeader)) - } - - return { - testName: testConfig.name, - moduleName: module.name, - command: ["hadolint", ...args], - version: module.version.versionString, - success, - startedAt, - completedAt: new Date(), - log: formattedResult, - } + }) + .join("\n") + } + + const threshold = provider.config.testFailureThreshold + + if (warnings.length > 0 && threshold === "warning") { + success = false + } else if (errors.length > 0 && threshold !== "none") { + success = false + } else if (warnings.length > 0) { + log.warn(chalk.yellow(formattedHeader)) + } + + return { + testName: testConfig.name, + moduleName: module.name, + command: ["hadolint", ...args], + version: module.version.versionString, + success, + startedAt, + completedAt: new Date(), + log: formattedResult, + } + }, }, }, - }, - ], - tools: [ - { - name: "hadolint", - description: "A Dockerfile linter.", - type: "binary", - _includeInGardenImage: false, - builds: [ - { - platform: "darwin", - architecture: "amd64", - url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Darwin-x86_64", - sha256: "da3bd1fae47f1ba4c4bca6a86d2c70bdbd6705308bd300d1f897c162bc32189a", - }, - { - platform: "linux", - architecture: "amd64", - url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Linux-x86_64", - sha256: "b23e4d0e8964774cc0f4dd7ff81f1d05b5d7538b0b80dae5235b1239ab60749d", - }, - { - platform: "windows", - architecture: "amd64", - url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Windows-x86_64.exe", - sha256: "8ba81d1fe79b91afb7ee16ac4e9fc6635646c2f770071d1ba924a8d26debe298", - }, - ], - }, - ], -}) + ], + tools: [ + { + name: "hadolint", + description: "A Dockerfile linter.", + type: "binary", + _includeInGardenImage: false, + builds: [ + { + platform: "darwin", + architecture: "amd64", + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Darwin-x86_64", + sha256: "da3bd1fae47f1ba4c4bca6a86d2c70bdbd6705308bd300d1f897c162bc32189a", + }, + { + platform: "linux", + architecture: "amd64", + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Linux-x86_64", + sha256: "b23e4d0e8964774cc0f4dd7ff81f1d05b5d7538b0b80dae5235b1239ab60749d", + }, + { + platform: "windows", + architecture: "amd64", + url: "https://github.com/hadolint/hadolint/releases/download/v1.17.2/hadolint-Windows-x86_64.exe", + sha256: "8ba81d1fe79b91afb7ee16ac4e9fc6635646c2f770071d1ba924a8d26debe298", + }, + ], + }, + ], + }) diff --git a/core/src/plugins/kubernetes/config.ts b/core/src/plugins/kubernetes/config.ts index 15396dee8e..ce09f943c6 100644 --- a/core/src/plugins/kubernetes/config.ts +++ b/core/src/plugins/kubernetes/config.ts @@ -286,13 +286,14 @@ const tlsCertificateSchema = () => .example("cert-manager"), }) -export const kubernetesConfigBase = providerConfigBaseSchema().keys({ - buildMode: joi - .string() - .allow("local-docker", "cluster-docker", "kaniko") - .default("local-docker") - .description( - dedent` +export const kubernetesConfigBase = () => + providerConfigBaseSchema().keys({ + buildMode: joi + .string() + .allow("local-docker", "cluster-docker", "kaniko") + .default("local-docker") + .description( + dedent` Choose the mechanism for building container images before deploying. By default it uses the local Docker daemon, but you can set it to \`cluster-docker\` or \`kaniko\` to sync files to a remote Docker daemon, installed in the cluster, and build container images there. This removes the need to run Docker or @@ -309,80 +310,80 @@ export const kubernetesConfigBase = providerConfigBaseSchema().keys({ this is less secure than Kaniko, but in turn it is generally faster. See the [Kaniko docs](https://github.com/GoogleContainerTools/kaniko) for more information on Kaniko. ` - ), - clusterDocker: joi - .object() - .keys({ - enableBuildKit: joi - .boolean() - .default(false) - .description( - deline` + ), + clusterDocker: joi + .object() + .keys({ + enableBuildKit: joi + .boolean() + .default(false) + .description( + deline` Enable [BuildKit](https://github.com/moby/buildkit) support. This should in most cases work well and be more performant, but we're opting to keep it optional until it's enabled by default in Docker. ` - ), - }) - .default(() => {}) - .description("Configuration options for the `cluster-docker` build mode."), - kaniko: joi - .object() - .keys({ - image: joi - .string() - .default(DEFAULT_KANIKO_IMAGE) - .description( - deline` + ), + }) + .default(() => {}) + .description("Configuration options for the `cluster-docker` build mode."), + kaniko: joi + .object() + .keys({ + image: joi + .string() + .default(DEFAULT_KANIKO_IMAGE) + .description( + deline` Change the kaniko image (repository/image:tag) to use when building in kaniko mode. ` - ), - extraFlags: joi.array().items(joi.string()).description(deline` + ), + extraFlags: joi.array().items(joi.string()).description(deline` Specify extra flags to use when building the container image with kaniko. Flags set on container module take precedence over these.`), - }) - .default(() => {}) - .description("Configuration options for the `kaniko` build mode."), - defaultHostname: joi - .string() - .description("A default hostname to use when no hostname is explicitly configured for a service.") - .example("api.mydomain.com"), - deploymentStrategy: joi - .string() - .default("rolling") - .allow("rolling", "blue-green") - .description( - dedent` + }) + .default(() => {}) + .description("Configuration options for the `kaniko` build mode."), + defaultHostname: joi + .string() + .description("A default hostname to use when no hostname is explicitly configured for a service.") + .example("api.mydomain.com"), + deploymentStrategy: joi + .string() + .default("rolling") + .allow("rolling", "blue-green") + .description( + dedent` Defines the strategy for deploying the project services. Default is "rolling update" and there is experimental support for "blue/green" deployment. The feature only supports modules of type \`container\`: other types will just deploy using the default strategy. ` - ) - .meta({ - experimental: true, - }), - forceSsl: joi - .boolean() - .default(false) - .description( - "Require SSL on all `container` module services. If set to true, an error is raised when no certificate " + - "is available for a configured hostname on a `container` module." - ), - gardenSystemNamespace: joi - .string() - .default(defaultSystemNamespace) - .description( - dedent` + ) + .meta({ + experimental: true, + }), + forceSsl: joi + .boolean() + .default(false) + .description( + "Require SSL on all `container` module services. If set to true, an error is raised when no certificate " + + "is available for a configured hostname on a `container` module." + ), + gardenSystemNamespace: joi + .string() + .default(defaultSystemNamespace) + .description( + dedent` Override the garden-system namespace name. This option is mainly used for testing. In most cases you should leave the default value. ` - ) - .meta({ internal: true }), - imagePullSecrets: imagePullSecretsSchema(), - // TODO: invert the resources and storage config schemas - resources: joi - .object() - .keys({ - builder: resourceSchema(defaultResources.builder).description(dedent` + ) + .meta({ internal: true }), + imagePullSecrets: imagePullSecretsSchema(), + // TODO: invert the resources and storage config schemas + resources: joi + .object() + .keys({ + builder: resourceSchema(defaultResources.builder).description(dedent` Resource requests and limits for the in-cluster builder. When \`buildMode\` is \`cluster-docker\`, this refers to the Docker Daemon that is installed and run @@ -392,53 +393,53 @@ export const kubernetesConfigBase = providerConfigBaseSchema().keys({ When \`buildMode\` is \`kaniko\`, this refers to _each instance_ of Kaniko, so you'd generally use lower limits/requests, but you should evaluate based on your needs. `), - registry: resourceSchema(defaultResources.registry).description(dedent` + registry: resourceSchema(defaultResources.registry).description(dedent` Resource requests and limits for the in-cluster image registry. Built images are pushed to this registry, so that they are available to all the nodes in your cluster. This is shared across all users and builds, so it should be resourced accordingly, factoring in how many concurrent builds you expect and how large your images tend to be. `), - sync: resourceSchema(defaultResources.sync).description(dedent` + sync: resourceSchema(defaultResources.sync).description(dedent` Resource requests and limits for the code sync service, which we use to sync build contexts to the cluster ahead of building images. This generally is not resource intensive, but you might want to adjust the defaults if you have many concurrent users. `), - }) - .default(defaultResources).description(deline` + }) + .default(defaultResources).description(deline` Resource requests and limits for the in-cluster builder, container registry and code sync service. (which are automatically installed and used when \`buildMode\` is \`cluster-docker\` or \`kaniko\`). `), - storage: joi - .object() - .keys({ - builder: storageSchema(defaultStorage.builder).description(dedent` + storage: joi + .object() + .keys({ + builder: storageSchema(defaultStorage.builder).description(dedent` Storage parameters for the data volume for the in-cluster Docker Daemon. Only applies when \`buildMode\` is set to \`cluster-docker\`, ignored otherwise. `), - nfs: joi - .object() - .keys({ - storageClass: joi - .string() - .allow(null) - .default(null) - .description("Storage class to use as backing storage for NFS ."), - }) - .default({ storageClass: null }).description(dedent` + nfs: joi + .object() + .keys({ + storageClass: joi + .string() + .allow(null) + .default(null) + .description("Storage class to use as backing storage for NFS ."), + }) + .default({ storageClass: null }).description(dedent` Storage parameters for the NFS provisioner, which we automatically create for the sync volume, _unless_ you specify a \`storageClass\` for the sync volume. See the below \`sync\` parameter for more. Only applies when \`buildMode\` is set to \`cluster-docker\` or \`kaniko\`, ignored otherwise. `), - registry: storageSchema(defaultStorage.registry).description(dedent` + registry: storageSchema(defaultStorage.registry).description(dedent` Storage parameters for the in-cluster Docker registry volume. Built images are stored here, so that they are available to all the nodes in your cluster. Only applies when \`buildMode\` is set to \`cluster-docker\` or \`kaniko\`, ignored otherwise. `), - sync: storageSchema(defaultStorage.sync).description(dedent` + sync: storageSchema(defaultStorage.sync).description(dedent` Storage parameters for the code sync volume, which build contexts are synced to ahead of running in-cluster builds. @@ -448,133 +449,134 @@ export const kubernetesConfigBase = providerConfigBaseSchema().keys({ Only applies when \`buildMode\` is set to \`cluster-docker\` or \`kaniko\`, ignored otherwise. `), - }) - .default(defaultStorage).description(dedent` + }) + .default(defaultStorage).description(dedent` Storage parameters to set for the in-cluster builder, container registry and code sync persistent volumes (which are automatically installed and used when \`buildMode\` is \`cluster-docker\` or \`kaniko\`). These are all shared cluster-wide across all users and builds, so they should be resourced accordingly, factoring in how many concurrent builds you expect and how large your images and build contexts tend to be. `), - tlsCertificates: joiArray(tlsCertificateSchema()) - .unique("name") - .description("One or more certificates to use for ingress."), - certManager: joi - .object() - .optional() - .keys({ - install: joi.bool().default(false).description(dedent` + tlsCertificates: joiArray(tlsCertificateSchema()) + .unique("name") + .description("One or more certificates to use for ingress."), + certManager: joi + .object() + .optional() + .keys({ + install: joi.bool().default(false).description(dedent` Automatically install \`cert-manager\` on initialization. See the [cert-manager integration guide](https://docs.garden.io/advanced/cert-manager-integration) for details. `), - email: joi - .string() - .required() - .description("The email to use when requesting Let's Encrypt certificates.") - .example("yourname@example.com"), - issuer: joi - .string() - .allow("acme") - .default("acme") - .description("The type of issuer for the certificate (only ACME is supported for now).") - .example("acme"), - acmeServer: joi - .string() - .allow("letsencrypt-staging", "letsencrypt-prod") - .default("letsencrypt-staging") - .description( - deline`Specify which ACME server to request certificates from. Currently Let's Encrypt staging and prod + email: joi + .string() + .required() + .description("The email to use when requesting Let's Encrypt certificates.") + .example("yourname@example.com"), + issuer: joi + .string() + .allow("acme") + .default("acme") + .description("The type of issuer for the certificate (only ACME is supported for now).") + .example("acme"), + acmeServer: joi + .string() + .allow("letsencrypt-staging", "letsencrypt-prod") + .default("letsencrypt-staging") + .description( + deline`Specify which ACME server to request certificates from. Currently Let's Encrypt staging and prod servers are supported.` - ) - .example("letsencrypt-staging"), - acmeChallengeType: joi - .string() - .allow("HTTP-01") - .default("HTTP-01") - .description( - deline`The type of ACME challenge used to validate hostnames and generate the certificates + ) + .example("letsencrypt-staging"), + acmeChallengeType: joi + .string() + .allow("HTTP-01") + .default("HTTP-01") + .description( + deline`The type of ACME challenge used to validate hostnames and generate the certificates (only HTTP-01 is supported for now).` - ) - .example("HTTP-01"), - }).description(dedent`cert-manager configuration, for creating and managing TLS certificates. See the + ) + .example("HTTP-01"), + }).description(dedent`cert-manager configuration, for creating and managing TLS certificates. See the [cert-manager guide](https://docs.garden.io/advanced/cert-manager-integration) for details.`), - _systemServices: joiArray(joiIdentifier()).meta({ internal: true }), - systemNodeSelector: joiStringMap(joi.string()) - .description( - dedent` + _systemServices: joiArray(joiIdentifier()).meta({ internal: true }), + systemNodeSelector: joiStringMap(joi.string()) + .description( + dedent` Exposes the \`nodeSelector\` field on the PodSpec of system services. This allows you to constrain the system services to only run on particular nodes. [See here](https://kubernetes.io/docs/concepts/configuration/assign-pod-node/) for the official Kubernetes guide to assigning Pods to nodes. ` - ) - .example({ disktype: "ssd" }) - .default(() => ({})), - registryProxyTolerations: joiArray( - joi.object().keys({ - effect: joi.string().allow("NoSchedule", "PreferNoSchedule", "NoExecute").description(dedent` + ) + .example({ disktype: "ssd" }) + .default(() => ({})), + registryProxyTolerations: joiArray( + joi.object().keys({ + effect: joi.string().allow("NoSchedule", "PreferNoSchedule", "NoExecute").description(dedent` "Effect" indicates the taint effect to match. Empty means match all taint effects. When specified, allowed values are "NoSchedule", "PreferNoSchedule" and "NoExecute". `), - key: joi.string().description(dedent` + key: joi.string().description(dedent` "Key" is the taint key that the toleration applies to. Empty means match all taint keys. If the key is empty, operator must be "Exists"; this combination means to match all values and all keys. `), - operator: joi.string().allow("Exists", "Equal").default("Equal").description(dedent` + operator: joi.string().allow("Exists", "Equal").default("Equal").description(dedent` "Operator" represents a key's relationship to the value. Valid operators are "Exists" and "Equal". Defaults to "Equal". "Exists" is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. `), - tolerationSeconds: joi.string().description(dedent` + tolerationSeconds: joi.string().description(dedent` "TolerationSeconds" represents the period of time the toleration (which must be of effect "NoExecute", otherwise this field is ignored) tolerates the taint. By default, it is not set, which means tolerate the taint forever (do not evict). Zero and negative values will be treated as 0 (evict immediately) by the system. `), - value: joi.string().description(dedent` + value: joi.string().description(dedent` "Value" is the taint value the toleration matches to. If the operator is "Exists", the value should be empty, otherwise just a regular string. `), - }) - ).description(dedent` + }) + ).description(dedent` For setting tolerations on the registry-proxy when using in-cluster building. The registry-proxy is a DaemonSet that proxies connections to the docker registry service on each node. Use this only if you're doing in-cluster building and the nodes in your cluster have [taints](https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/). `), -}) + }) -export const configSchema = kubernetesConfigBase - .keys({ - name: joiProviderName("kubernetes"), - context: k8sContextSchema().required(), - deploymentRegistry: containerRegistryConfigSchema(), - ingressClass: joi.string().description(dedent` +export const configSchema = () => + kubernetesConfigBase() + .keys({ + name: joiProviderName("kubernetes"), + context: k8sContextSchema().required(), + deploymentRegistry: containerRegistryConfigSchema(), + ingressClass: joi.string().description(dedent` The ingress class to use on configured Ingresses (via the \`kubernetes.io/ingress.class\` annotation) when deploying \`container\` services. Use this if you have multiple ingress controllers in your cluster. `), - ingressHttpPort: joi - .number() - .default(80) - .description("The external HTTP port of the cluster's ingress controller."), - ingressHttpsPort: joi - .number() - .default(443) - .description("The external HTTPS port of the cluster's ingress controller."), - kubeconfig: joi - .posixPath() - .description("Path to kubeconfig file to use instead of the system default. Must be a POSIX-style path."), - namespace: joi.string().description(dedent` + ingressHttpPort: joi + .number() + .default(80) + .description("The external HTTP port of the cluster's ingress controller."), + ingressHttpsPort: joi + .number() + .default(443) + .description("The external HTTPS port of the cluster's ingress controller."), + kubeconfig: joi + .posixPath() + .description("Path to kubeconfig file to use instead of the system default. Must be a POSIX-style path."), + namespace: joi.string().description(dedent` Specify which namespace to deploy services to. Defaults to \`-\`. Note that the framework may generate other namespaces as well with this name as a prefix. `), - setupIngressController: joi - .string() - .allow("nginx", false, null) - .default(false) - .description("Set this to `nginx` to install/enable the NGINX ingress controller."), - }) - .unknown(false) + setupIngressController: joi + .string() + .allow("nginx", false, null) + .default(false) + .description("Set this to `nginx` to install/enable the NGINX ingress controller."), + }) + .unknown(false) export interface ServiceResourceSpec { kind: HotReloadableKind diff --git a/core/src/plugins/kubernetes/kubernetes.ts b/core/src/plugins/kubernetes/kubernetes.ts index 026e90f058..86e81f9162 100644 --- a/core/src/plugins/kubernetes/kubernetes.ts +++ b/core/src/plugins/kubernetes/kubernetes.ts @@ -183,10 +183,11 @@ const outputsSchema = joi.object().keys({ const localKubernetesUrl = getProviderUrl("local-kubernetes") -export const gardenPlugin = createGardenPlugin({ - name: "kubernetes", - dependencies: ["container"], - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "kubernetes", + dependencies: ["container"], + docs: dedent` The \`kubernetes\` provider allows you to deploy [\`container\` modules](${getModuleTypeUrl("container")}) to Kubernetes clusters, and adds the [\`helm\`](${getModuleTypeUrl("helm")}) and [\`kubernetes\`](${getModuleTypeUrl("kubernetes")}) module types. @@ -197,33 +198,33 @@ export const gardenPlugin = createGardenPlugin({ Note that if you're using a local Kubernetes cluster (e.g. minikube or Docker Desktop), the [local-kubernetes provider](${localKubernetesUrl}) simplifies (and automates) the configuration and setup quite a bit. `, - configSchema, - outputsSchema, - commands: [cleanupClusterRegistry, clusterInit, uninstallGardenServices, pullImage], - handlers: { - configureProvider, - getEnvironmentStatus, - prepareEnvironment, - cleanupEnvironment, - getSecret, - setSecret, - deleteSecret, - getDebugInfo: debugInfo, - }, - createModuleTypes: [ - { - name: "helm", - docs: dedent` + configSchema: configSchema(), + outputsSchema, + commands: [cleanupClusterRegistry, clusterInit, uninstallGardenServices, pullImage], + handlers: { + configureProvider, + getEnvironmentStatus, + prepareEnvironment, + cleanupEnvironment, + getSecret, + setSecret, + deleteSecret, + getDebugInfo: debugInfo, + }, + createModuleTypes: [ + { + name: "helm", + docs: dedent` Specify a Helm chart (either in your repository or remote from a registry) to deploy. Refer to the [Helm guide](${DOCS_BASE_URL}/guides/using-helm-charts) for usage instructions. `, - moduleOutputsSchema: helmModuleOutputsSchema(), - schema: helmModuleSpecSchema(), - handlers: helmHandlers, - }, - { - name: "kubernetes", - docs: dedent` + moduleOutputsSchema: helmModuleOutputsSchema(), + schema: helmModuleSpecSchema(), + handlers: helmHandlers, + }, + { + name: "kubernetes", + docs: dedent` Specify one or more Kubernetes manifests to deploy. You can either (or both) specify the manifests as part of the \`garden.yml\` configuration, or you can refer to @@ -234,17 +235,17 @@ export const gardenPlugin = createGardenPlugin({ If you need more advanced templating features you can use the [helm](${getModuleTypeUrl("helm")}) module type. `, - moduleOutputsSchema: joi.object().keys({}), - schema: kubernetesModuleSpecSchema(), - handlers: kubernetesHandlers, - }, - pvcModuleDefinition, - ], - extendModuleTypes: [ - { - name: "container", - handlers: containerHandlers, - }, - ], - tools: [kubectlSpec, helm3Spec, sternSpec], -}) + moduleOutputsSchema: joi.object().keys({}), + schema: kubernetesModuleSpecSchema(), + handlers: kubernetesHandlers, + }, + pvcModuleDefinition(), + ], + extendModuleTypes: [ + { + name: "container", + handlers: containerHandlers, + }, + ], + tools: [kubectlSpec, helm3Spec, sternSpec], + }) diff --git a/core/src/plugins/kubernetes/local/config.ts b/core/src/plugins/kubernetes/local/config.ts index 4b08cfbafc..68746088ea 100644 --- a/core/src/plugins/kubernetes/local/config.ts +++ b/core/src/plugins/kubernetes/local/config.ts @@ -29,23 +29,24 @@ export interface LocalKubernetesConfig extends KubernetesConfig { setupIngressController: string | null } -export const configSchema = kubernetesConfigBase - .keys({ - name: joiProviderName("local-kubernetes"), - context: k8sContextSchema().optional(), - namespace: joi - .string() - .description( - "Specify which namespace to deploy services to (defaults to the project name). " + - "Note that the framework generates other namespaces as well with this name as a prefix." - ), - setupIngressController: joi - .string() - .allow("nginx", false, null) - .default("nginx") - .description("Set this to null or false to skip installing/enabling the `nginx` ingress controller."), - }) - .description("The provider configuration for the local-kubernetes plugin.") +export const configSchema = () => + kubernetesConfigBase() + .keys({ + name: joiProviderName("local-kubernetes"), + context: k8sContextSchema().optional(), + namespace: joi + .string() + .description( + "Specify which namespace to deploy services to (defaults to the project name). " + + "Note that the framework generates other namespaces as well with this name as a prefix." + ), + setupIngressController: joi + .string() + .allow("nginx", false, null) + .default("nginx") + .description("Set this to null or false to skip installing/enabling the `nginx` ingress controller."), + }) + .description("The provider configuration for the local-kubernetes plugin.") export async function configureProvider(params: ConfigureProviderParams) { const { base, log, projectName, ctx } = params diff --git a/core/src/plugins/kubernetes/local/local.ts b/core/src/plugins/kubernetes/local/local.ts index 6b63b50e25..7ba90aa2b1 100644 --- a/core/src/plugins/kubernetes/local/local.ts +++ b/core/src/plugins/kubernetes/local/local.ts @@ -14,18 +14,19 @@ import { getProviderUrl } from "../../../docs/common" const providerUrl = getProviderUrl("kubernetes") -export const gardenPlugin = createGardenPlugin({ - name: "local-kubernetes", - base: "kubernetes", - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "local-kubernetes", + base: "kubernetes", + docs: dedent` The \`local-kubernetes\` provider is a specialized version of the [\`kubernetes\` provider](${providerUrl}) that automates and simplifies working with local Kubernetes clusters. For general Kubernetes usage information, please refer to the [guides section](${DOCS_BASE_URL}/guides). For local clusters a good place to start is the [Local Kubernetes guide](${DOCS_BASE_URL}/guides/local-kubernetes) guide. The [demo-project](${DOCS_BASE_URL}/example-projects/getting-started/2-initialize-a-project) example project and guide are also helpful as an introduction. If you're working with a remote Kubernetes cluster, please refer to the [\`kubernetes\` provider](${providerUrl}) docs, and the [Remote Kubernetes guide](${DOCS_BASE_URL}/guides/remote-kubernetes) guide. `, - configSchema, - handlers: { - configureProvider, - }, -}) + configSchema: configSchema(), + handlers: { + configureProvider, + }, + }) diff --git a/core/src/plugins/kubernetes/volumes/persistentvolumeclaim.ts b/core/src/plugins/kubernetes/volumes/persistentvolumeclaim.ts index 0abc4e2a6c..8f50144014 100644 --- a/core/src/plugins/kubernetes/volumes/persistentvolumeclaim.ts +++ b/core/src/plugins/kubernetes/volumes/persistentvolumeclaim.ts @@ -39,7 +39,7 @@ type PersistentVolumeClaimModule = GardenModule ({ name: "persistentvolumeclaim", docs: dedent` Creates a [PersistentVolumeClaim](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#persistentvolumeclaims) in your namespace, that can be referenced and mounted by other resources and [container modules](${containerTypeUrl}). @@ -97,7 +97,7 @@ export const pvcModuleDefinition: ModuleTypeDefinition = { return deployKubernetesService(params) }, }, -} +}) /** * Maps a `persistentvolumeclaim` module to a `kubernetes` module (so we can re-use those handlers). diff --git a/core/src/plugins/local/local-docker-swarm.ts b/core/src/plugins/local/local-docker-swarm.ts index b70e3508d2..9b7ac07f9a 100644 --- a/core/src/plugins/local/local-docker-swarm.ts +++ b/core/src/plugins/local/local-docker-swarm.ts @@ -27,201 +27,202 @@ const DEPLOY_TIMEOUT = 30 const pluginName = "local-docker-swarm" -export const gardenPlugin = createGardenPlugin({ - name: pluginName, - docs: "EXPERIMENTAL", - handlers: { - getEnvironmentStatus, - prepareEnvironment, - }, - extendModuleTypes: [ - { - name: "container", - handlers: { - getServiceStatus, - - async deployService({ ctx, module, service, runtimeContext, log }: DeployServiceParams) { - // TODO: split this method up and test - const { versionString } = service.module.version - - log.info({ section: service.name, msg: `Deploying version ${versionString}` }) - - const identifier = containerHelpers.getLocalImageId(module, module.version) - const ports = service.spec.ports.map((p) => { - const port: any = { - Protocol: p.protocol ? p.protocol.toLowerCase() : "tcp", - TargetPort: p.containerPort, - } - - if (p.hostPort) { - port.PublishedPort = p.servicePort - } - }) - - const envVars = map({ ...runtimeContext.envVars, ...service.spec.env }, (v, k) => `${k}=${v}`) +export const gardenPlugin = () => + createGardenPlugin({ + name: pluginName, + docs: "EXPERIMENTAL", + handlers: { + getEnvironmentStatus, + prepareEnvironment, + }, + extendModuleTypes: [ + { + name: "container", + handlers: { + getServiceStatus, + + async deployService({ ctx, module, service, runtimeContext, log }: DeployServiceParams) { + // TODO: split this method up and test + const { versionString } = service.module.version + + log.info({ section: service.name, msg: `Deploying version ${versionString}` }) + + const identifier = containerHelpers.getLocalImageId(module, module.version) + const ports = service.spec.ports.map((p) => { + const port: any = { + Protocol: p.protocol ? p.protocol.toLowerCase() : "tcp", + TargetPort: p.containerPort, + } - const volumeMounts = service.spec.volumes.map((v) => { - // TODO-LOW: Support named volumes - if (v.hostPath) { - return { - Type: "bind", - Source: resolve(module.path, v.hostPath), - Target: v.containerPath, + if (p.hostPort) { + port.PublishedPort = p.servicePort } - } else { - return { - Type: "tmpfs", - Target: v.containerPath, + }) + + const envVars = map({ ...runtimeContext.envVars, ...service.spec.env }, (v, k) => `${k}=${v}`) + + const volumeMounts = service.spec.volumes.map((v) => { + // TODO-LOW: Support named volumes + if (v.hostPath) { + return { + Type: "bind", + Source: resolve(module.path, v.hostPath), + Target: v.containerPath, + } + } else { + return { + Type: "tmpfs", + Target: v.containerPath, + } } - } - }) - - const opts: any = { - Name: getSwarmServiceName(ctx, service.name), - Labels: { - environment: ctx.environmentName, - provider: pluginName, - }, - TaskTemplate: { - ContainerSpec: { - Image: identifier, - Command: service.spec.args, - Env: envVars, - Mounts: volumeMounts, + }) + + const opts: any = { + Name: getSwarmServiceName(ctx, service.name), + Labels: { + environment: ctx.environmentName, + provider: pluginName, }, - Resources: { - Limits: {}, - Reservations: {}, + TaskTemplate: { + ContainerSpec: { + Image: identifier, + Command: service.spec.args, + Env: envVars, + Mounts: volumeMounts, + }, + Resources: { + Limits: {}, + Reservations: {}, + }, + RestartPolicy: {}, + Placement: {}, }, - RestartPolicy: {}, - Placement: {}, - }, - Mode: { - Replicated: { - Replicas: 1, + Mode: { + Replicated: { + Replicas: 1, + }, }, - }, - UpdateConfig: { - Parallelism: 1, - }, - IngressSpec: { - Ports: ports, - }, - } - - const docker = getDocker() - const serviceStatus = await getServiceStatus({ - ctx, - service, - module, - runtimeContext, - log, - hotReload: false, - }) - let swarmServiceStatus - let serviceId - - if (serviceStatus.externalId) { - const swarmService = await docker.getService(serviceStatus.externalId) - swarmServiceStatus = await swarmService.inspect() - opts.version = parseInt(swarmServiceStatus.Version.Index, 10) - log.verbose({ - section: service.name, - msg: `Updating existing Swarm service (version ${opts.version})`, - }) - await swarmService.update(opts) - serviceId = serviceStatus.externalId - } else { - log.verbose({ - section: service.name, - msg: `Creating new Swarm service`, + UpdateConfig: { + Parallelism: 1, + }, + IngressSpec: { + Ports: ports, + }, + } + + const docker = getDocker() + const serviceStatus = await getServiceStatus({ + ctx, + service, + module, + runtimeContext, + log, + hotReload: false, }) - const swarmService = await docker.createService(opts) - serviceId = swarmService.ID - } + let swarmServiceStatus + let serviceId + + if (serviceStatus.externalId) { + const swarmService = await docker.getService(serviceStatus.externalId) + swarmServiceStatus = await swarmService.inspect() + opts.version = parseInt(swarmServiceStatus.Version.Index, 10) + log.verbose({ + section: service.name, + msg: `Updating existing Swarm service (version ${opts.version})`, + }) + await swarmService.update(opts) + serviceId = serviceStatus.externalId + } else { + log.verbose({ + section: service.name, + msg: `Creating new Swarm service`, + }) + const swarmService = await docker.createService(opts) + serviceId = swarmService.ID + } - // Wait for service to be ready - const start = new Date().getTime() + // Wait for service to be ready + const start = new Date().getTime() - while (true) { - await sleep(1000) + while (true) { + await sleep(1000) - const { lastState, lastError } = await getServiceState(serviceId) + const { lastState, lastError } = await getServiceState(serviceId) - if (lastError) { - throw new DeploymentError(`Service ${service.name} ${lastState}: ${lastError}`, { - service, - state: lastState, - error: lastError, - }) - } + if (lastError) { + throw new DeploymentError(`Service ${service.name} ${lastState}: ${lastError}`, { + service, + state: lastState, + error: lastError, + }) + } - if (mapContainerState(lastState) === "ready") { - break - } + if (mapContainerState(lastState) === "ready") { + break + } - if (new Date().getTime() - start > DEPLOY_TIMEOUT * 1000) { - throw new DeploymentError(`Timed out deploying ${service.name} (status: ${lastState}`, { - service, - state: lastState, - }) + if (new Date().getTime() - start > DEPLOY_TIMEOUT * 1000) { + throw new DeploymentError(`Timed out deploying ${service.name} (status: ${lastState}`, { + service, + state: lastState, + }) + } } - } - - log.info({ - section: service.name, - msg: `Ready`, - }) - return getServiceStatus({ ctx, module, service, runtimeContext, log, hotReload: false }) - }, + log.info({ + section: service.name, + msg: `Ready`, + }) - async execInService({ ctx, service, command, log }: ExecInServiceParams) { - const status = await getServiceStatus({ - ctx, - service, - module: service.module, - // The runtime context doesn't matter here, we're just checking if the service is running. - runtimeContext: { - envVars: {}, - dependencies: [], - }, - log, - hotReload: false, - }) - - if (!status.state || (status.state !== "ready" && status.state !== "outdated")) { - throw new DeploymentError(`Service ${service.name} is not running`, { - name: service.name, - state: status.state, + return getServiceStatus({ ctx, module, service, runtimeContext, log, hotReload: false }) + }, + + async execInService({ ctx, service, command, log }: ExecInServiceParams) { + const status = await getServiceStatus({ + ctx, + service, + module: service.module, + // The runtime context doesn't matter here, we're just checking if the service is running. + runtimeContext: { + envVars: {}, + dependencies: [], + }, + log, + hotReload: false, }) - } - - // This is ugly, but dockerode doesn't have this, or at least it's too cumbersome to implement. - const swarmServiceName = getSwarmServiceName(ctx, service.name) - const servicePsCommand = [ - "docker", - "service", - "ps", - "-f", - `'name=${swarmServiceName}.1'`, - "-f", - `'desired-state=running'`, - swarmServiceName, - "-q", - ] - let res = await exec(servicePsCommand.join(" ")) - const serviceContainerId = `${swarmServiceName}.1.${res.stdout.trim()}` - - const execCommand = ["docker", "exec", serviceContainerId, ...command] - res = await exec(execCommand.join(" ")) - - return { code: 0, output: "", stdout: res.stdout, stderr: res.stderr } + + if (!status.state || (status.state !== "ready" && status.state !== "outdated")) { + throw new DeploymentError(`Service ${service.name} is not running`, { + name: service.name, + state: status.state, + }) + } + + // This is ugly, but dockerode doesn't have this, or at least it's too cumbersome to implement. + const swarmServiceName = getSwarmServiceName(ctx, service.name) + const servicePsCommand = [ + "docker", + "service", + "ps", + "-f", + `'name=${swarmServiceName}.1'`, + "-f", + `'desired-state=running'`, + swarmServiceName, + "-q", + ] + let res = await exec(servicePsCommand.join(" ")) + const serviceContainerId = `${swarmServiceName}.1.${res.stdout.trim()}` + + const execCommand = ["docker", "exec", serviceContainerId, ...command] + res = await exec(execCommand.join(" ")) + + return { code: 0, output: "", stdout: res.stdout, stderr: res.stderr } + }, }, }, - }, - ], -}) + ], + }) async function getEnvironmentStatus(): Promise { const docker = getDocker() diff --git a/core/src/plugins/local/local-google-cloud-functions.ts b/core/src/plugins/local/local-google-cloud-functions.ts index ede1976a4f..e2926a430d 100644 --- a/core/src/plugins/local/local-google-cloud-functions.ts +++ b/core/src/plugins/local/local-google-cloud-functions.ts @@ -23,145 +23,146 @@ const baseContainerName = `${pluginName}--${emulatorModuleName}` const emulatorBaseModulePath = join(STATIC_DIR, emulatorModuleName) const emulatorPort = 8010 -export const gardenPlugin = createGardenPlugin({ - name: pluginName, - docs: "EXPERIMENTAL", - handlers: { - async configureProvider({ config }: ConfigureProviderParams) { - const emulatorConfig: ContainerModuleConfig = { - allowPublish: false, - apiVersion: DEFAULT_API_VERSION, - build: { - dependencies: [], - }, - description: "Base container for running Google Cloud Functions emulator", - disabled: false, - name: "local-gcf-container", - path: emulatorBaseModulePath, - serviceConfigs: [], - spec: { +export const gardenPlugin = () => + createGardenPlugin({ + name: pluginName, + docs: "EXPERIMENTAL", + handlers: { + async configureProvider({ config }: ConfigureProviderParams) { + const emulatorConfig: ContainerModuleConfig = { + allowPublish: false, + apiVersion: DEFAULT_API_VERSION, build: { dependencies: [], - timeout: DEFAULT_BUILD_TIMEOUT, }, - buildArgs: {}, - extraFlags: [], - services: [], - tasks: [], - tests: [], - }, - taskConfigs: [], - testConfigs: [], - type: "container", - } + description: "Base container for running Google Cloud Functions emulator", + disabled: false, + name: "local-gcf-container", + path: emulatorBaseModulePath, + serviceConfigs: [], + spec: { + build: { + dependencies: [], + timeout: DEFAULT_BUILD_TIMEOUT, + }, + buildArgs: {}, + extraFlags: [], + services: [], + tasks: [], + tests: [], + }, + taskConfigs: [], + testConfigs: [], + type: "container", + } - return { - config, - moduleConfigs: [emulatorConfig], - } + return { + config, + moduleConfigs: [emulatorConfig], + } + }, }, - }, - extendModuleTypes: [ - { - name: "google-cloud-function", - handlers: { - async configure(params: ConfigureModuleParams) { - const { moduleConfig: parsed } = await configureGcfModule(params) + extendModuleTypes: [ + { + name: "google-cloud-function", + handlers: { + async configure(params: ConfigureModuleParams) { + const { moduleConfig: parsed } = await configureGcfModule(params) - // convert the module and services to containers to run locally - const serviceConfigs: ServiceConfig[] = parsed.serviceConfigs.map((s) => { - const functionEntrypoint = s.spec.entrypoint || s.name + // convert the module and services to containers to run locally + const serviceConfigs: ServiceConfig[] = parsed.serviceConfigs.map((s) => { + const functionEntrypoint = s.spec.entrypoint || s.name - const spec = { - name: s.name, - dependencies: s.dependencies, - disabled: parsed.disabled, - outputs: { - ingress: `http://${s.name}:${emulatorPort}/local/local/${functionEntrypoint}`, - }, - annotations: {}, - args: ["/app/start.sh", functionEntrypoint], - daemon: false, - ingresses: [ - { - name: "default", - annotations: {}, - hostname: s.spec.hostname, - port: "http", - path: "/", + const spec = { + name: s.name, + dependencies: s.dependencies, + disabled: parsed.disabled, + outputs: { + ingress: `http://${s.name}:${emulatorPort}/local/local/${functionEntrypoint}`, }, - ], - env: {}, - healthCheck: { tcpPort: "http" }, - limits: s.spec.limits, - ports: [ - { - name: "http", - protocol: "TCP", - containerPort: emulatorPort, - servicePort: emulatorPort, - }, - ], - replicas: 1, - volumes: [], - } - - return { - name: spec.name, - dependencies: spec.dependencies, - disabled: parsed.disabled, - hotReloadable: false, - outputs: spec.outputs, - spec, - } - }) - - const build = { - dependencies: parsed.build.dependencies.concat([ - { - name: emulatorModuleName, - plugin: pluginName, - copy: [ + annotations: {}, + args: ["/app/start.sh", functionEntrypoint], + daemon: false, + ingresses: [ { - source: "child/Dockerfile", - target: "Dockerfile", + name: "default", + annotations: {}, + hostname: s.spec.hostname, + port: "http", + path: "/", }, ], - }, - ]), - timeout: DEFAULT_BUILD_TIMEOUT, - } + env: {}, + healthCheck: { tcpPort: "http" }, + limits: s.spec.limits, + ports: [ + { + name: "http", + protocol: "TCP", + containerPort: emulatorPort, + servicePort: emulatorPort, + }, + ], + replicas: 1, + volumes: [], + } - const moduleConfig: ContainerModuleConfig = { - apiVersion: DEFAULT_API_VERSION, - allowPublish: true, - build, - disabled: parsed.disabled, - name: parsed.name, - path: parsed.path, - type: "container", + return { + name: spec.name, + dependencies: spec.dependencies, + disabled: parsed.disabled, + hotReloadable: false, + outputs: spec.outputs, + spec, + } + }) + + const build = { + dependencies: parsed.build.dependencies.concat([ + { + name: emulatorModuleName, + plugin: pluginName, + copy: [ + { + source: "child/Dockerfile", + target: "Dockerfile", + }, + ], + }, + ]), + timeout: DEFAULT_BUILD_TIMEOUT, + } - spec: { + const moduleConfig: ContainerModuleConfig = { + apiVersion: DEFAULT_API_VERSION, + allowPublish: true, build, - buildArgs: { - baseImageName: `${baseContainerName}:\${modules.${baseContainerName}.version}`, + disabled: parsed.disabled, + name: parsed.name, + path: parsed.path, + type: "container", + + spec: { + build, + buildArgs: { + baseImageName: `${baseContainerName}:\${modules.${baseContainerName}.version}`, + }, + extraFlags: [], + image: `${parsed.name}:\${modules.${parsed.name}.version}`, + services: serviceConfigs.map((s) => s.spec), + tasks: [], + tests: [], }, - extraFlags: [], - image: `${parsed.name}:\${modules.${parsed.name}.version}`, - services: serviceConfigs.map((s) => s.spec), - tasks: [], - tests: [], - }, - serviceConfigs, - taskConfigs: [], - testConfigs: parsed.testConfigs, - } + serviceConfigs, + taskConfigs: [], + testConfigs: parsed.testConfigs, + } - return { moduleConfig } + return { moduleConfig } + }, }, }, - }, - ], -}) + ], + }) diff --git a/core/src/plugins/maven-container/maven-container.ts b/core/src/plugins/maven-container/maven-container.ts index 3943c17de5..fd5856dad0 100644 --- a/core/src/plugins/maven-container/maven-container.ts +++ b/core/src/plugins/maven-container/maven-container.ts @@ -94,21 +94,22 @@ export const mavenContainerConfigSchema = () => const moduleTypeUrl = getModuleTypeUrl("maven-container") -export const gardenPlugin = createGardenPlugin({ - name: "maven-container", - dependencies: ["container"], +export const gardenPlugin = () => + createGardenPlugin({ + name: "maven-container", + dependencies: ["container"], - docs: dedent` + docs: dedent` Adds the [maven-container module type](${moduleTypeUrl}), which is a specialized version of the \`container\` module type that has special semantics for building JAR files using Maven. To use it, simply add the provider to your provider configuration, and refer to the [maven-container module docs](${moduleTypeUrl}) for details on how to configure the modules. `, - createModuleTypes: [ - { - name: "maven-container", - base: "container", - docs: dedent` + createModuleTypes: [ + { + name: "maven-container", + base: "container", + docs: dedent` A specialized version of the [container](https://docs.garden.io/reference/module-types/container) module type that has special semantics for JAR files built with Maven. @@ -122,16 +123,16 @@ export const gardenPlugin = createGardenPlugin({ To use it, make sure to add the \`maven-container\` provider to your project configuration. The provider will automatically fetch and cache Maven and the appropriate OpenJDK version ahead of building. `, - schema: mavenContainerModuleSpecSchema(), - moduleOutputsSchema: containerModuleOutputsSchema(), - handlers: { - configure: configureMavenContainerModule, - getBuildStatus, - build, + schema: mavenContainerModuleSpecSchema(), + moduleOutputsSchema: containerModuleOutputsSchema(), + handlers: { + configure: configureMavenContainerModule, + getBuildStatus, + build, + }, }, - }, - ], -}) + ], + }) export async function configureMavenContainerModule(params: ConfigureModuleParams) { const { base, moduleConfig } = params diff --git a/core/src/plugins/npm-package.ts b/core/src/plugins/npm-package.ts index ed062cd215..01599cff9c 100644 --- a/core/src/plugins/npm-package.ts +++ b/core/src/plugins/npm-package.ts @@ -8,14 +8,16 @@ import { createGardenPlugin } from "../types/plugin/plugin" -export const gardenPlugin = createGardenPlugin({ - name: "npm-package", - createModuleTypes: [ - { - name: "npm-package", - base: "exec", - docs: "[DEPRECATED]", - handlers: {}, - }, - ], -}) +export const gardenPlugin = () => + createGardenPlugin({ + name: "npm-package", + dependencies: ["exec"], + createModuleTypes: [ + { + name: "npm-package", + base: "exec", + docs: "[DEPRECATED]", + handlers: {}, + }, + ], + }) diff --git a/core/src/plugins/octant/octant.ts b/core/src/plugins/octant/octant.ts index 5b1a627fa1..c210d3bb11 100644 --- a/core/src/plugins/octant/octant.ts +++ b/core/src/plugins/octant/octant.ts @@ -16,107 +16,108 @@ import { createGardenPlugin } from "../../types/plugin/plugin" let octantProc: ExecaChildProcess let octantPort: number -export const gardenPlugin = createGardenPlugin({ - name: "octant", - dependencies: ["kubernetes"], - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "octant", + dependencies: ["kubernetes"], + docs: dedent` Adds [Octant](https://github.com/vmware-tanzu/octant) to the Garden dashboard, as well as a \`garden tools octant\` command. `, - dashboardPages: [ - { - name: "octant", - title: "Octant", - description: "The Octant admin UI for Kubernetes", - newWindow: false, - }, - ], - handlers: { - async getDashboardPage({ ctx, log }: GetDashboardPageParams) { - if (!octantProc) { - const tool = ctx.tools["octant.octant"] - const k8sProvider = getK8sProvider(ctx.provider.dependencies) - const path = await tool.getPath(log) + dashboardPages: [ + { + name: "octant", + title: "Octant", + description: "The Octant admin UI for Kubernetes", + newWindow: false, + }, + ], + handlers: { + async getDashboardPage({ ctx, log }: GetDashboardPageParams) { + if (!octantProc) { + const tool = ctx.tools["octant.octant"] + const k8sProvider = getK8sProvider(ctx.provider.dependencies) + const path = await tool.getPath(log) - octantPort = await getPort() - const host = "127.0.0.1:" + octantPort + octantPort = await getPort() + const host = "127.0.0.1:" + octantPort - const args = ["--disable-open-browser", "--listener-addr", host] + const args = ["--disable-open-browser", "--listener-addr", host] - if (k8sProvider.config.kubeconfig) { - args.push("--kubeconfig", k8sProvider.config.kubeconfig) - } - if (k8sProvider.config.context) { - args.push("--context", k8sProvider.config.context) - } - if (k8sProvider.config.namespace) { - args.push("--namespace", k8sProvider.config.namespace) - } + if (k8sProvider.config.kubeconfig) { + args.push("--kubeconfig", k8sProvider.config.kubeconfig) + } + if (k8sProvider.config.context) { + args.push("--context", k8sProvider.config.context) + } + if (k8sProvider.config.namespace) { + args.push("--namespace", k8sProvider.config.namespace) + } - octantProc = execa(path, args, { buffer: false, cleanup: true }) + octantProc = execa(path, args, { buffer: false, cleanup: true }) - return new Promise((resolve, reject) => { - let resolved = false + return new Promise((resolve, reject) => { + let resolved = false - // Wait for dashboard to be available - octantProc.stderr?.on("data", (data) => { - if (data.toString().includes("Dashboard is available")) { - resolved = true - resolve({ url: "http://" + host }) - } - }) + // Wait for dashboard to be available + octantProc.stderr?.on("data", (data) => { + if (data.toString().includes("Dashboard is available")) { + resolved = true + resolve({ url: "http://" + host }) + } + }) - octantProc.on("error", (err) => { - !resolved && reject(err) - }) + octantProc.on("error", (err) => { + !resolved && reject(err) + }) - octantProc.on("close", (err) => { - // TODO: restart process - !resolved && reject(err) + octantProc.on("close", (err) => { + // TODO: restart process + !resolved && reject(err) + }) }) - }) - } else { - return { url: "http://127.0.0.1:" + octantPort } - } + } else { + return { url: "http://127.0.0.1:" + octantPort } + } + }, }, - }, - tools: [ - { - name: "octant", - description: "A web admin UI for Kubernetes.", - type: "binary", - _includeInGardenImage: false, - builds: [ - { - platform: "darwin", - architecture: "amd64", - url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_macOS-64bit.tar.gz", - sha256: "63d03320e058eab4ef7ace6eb17c00e56f8fab85a202843295922535d28693a8", - extract: { - format: "tar", - targetPath: "octant_0.15.0_macOS-64bit/octant", + tools: [ + { + name: "octant", + description: "A web admin UI for Kubernetes.", + type: "binary", + _includeInGardenImage: false, + builds: [ + { + platform: "darwin", + architecture: "amd64", + url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_macOS-64bit.tar.gz", + sha256: "63d03320e058eab4ef7ace6eb17c00e56f8fab85a202843295922535d28693a8", + extract: { + format: "tar", + targetPath: "octant_0.15.0_macOS-64bit/octant", + }, }, - }, - { - platform: "linux", - architecture: "amd64", - url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_Linux-64bit.tar.gz", - sha256: "475c420c42f4d5f44650b1fb383f7e830e3939cbcc28e84ef49a6269dc3f658e", - extract: { - format: "tar", - targetPath: "octant_0.15.0_Linux-64bit/octant", + { + platform: "linux", + architecture: "amd64", + url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_Linux-64bit.tar.gz", + sha256: "475c420c42f4d5f44650b1fb383f7e830e3939cbcc28e84ef49a6269dc3f658e", + extract: { + format: "tar", + targetPath: "octant_0.15.0_Linux-64bit/octant", + }, }, - }, - { - platform: "windows", - architecture: "amd64", - url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_Windows-64bit.zip", - sha256: "963f50c196a56390127b01eabb49abaf0604f49a8c879ce4f28562d8d825b84d", - extract: { - format: "tar", - targetPath: "octant_0.15.0_Windows-64bit/octant.exe", + { + platform: "windows", + architecture: "amd64", + url: "https://github.com/vmware-tanzu/octant/releases/download/v0.15.0/octant_0.15.0_Windows-64bit.zip", + sha256: "963f50c196a56390127b01eabb49abaf0604f49a8c879ce4f28562d8d825b84d", + extract: { + format: "tar", + targetPath: "octant_0.15.0_Windows-64bit/octant.exe", + }, }, - }, - ], - }, - ], -}) + ], + }, + ], + }) diff --git a/core/src/plugins/openfaas/openfaas.ts b/core/src/plugins/openfaas/openfaas.ts index 7213306da5..3cf0ec5789 100644 --- a/core/src/plugins/openfaas/openfaas.ts +++ b/core/src/plugins/openfaas/openfaas.ts @@ -55,45 +55,46 @@ const systemDir = join(STATIC_DIR, "openfaas", "system") const moduleTypeUrl = getModuleTypeUrl("openfaas") const gitHubUrl = getGitHubUrl("examples/openfaas") -export const gardenPlugin = createGardenPlugin({ - name: "openfaas", - configSchema: configSchema(), - dependencies: ["kubernetes"], - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "openfaas", + configSchema: configSchema(), + dependencies: ["kubernetes"], + docs: dedent` This provider adds support for [OpenFaaS](https://www.openfaas.com/). It adds the [\`openfaas\` module type](${moduleTypeUrl}) and (by default) installs the \`faas-netes\` runtime to the project namespace. Each \`openfaas\` module maps to a single OpenFaaS function. See the reference below for configuration options for \`faas-netes\`, and the [module type docs](${moduleTypeUrl}) for how to configure the individual functions. Also see the [openfaas example project](${gitHubUrl}) for a simple usage example. `, - handlers: { - configureProvider, - }, - createModuleTypes: [ - { - name: "openfaas", - docs: dedent` + handlers: { + configureProvider, + }, + createModuleTypes: [ + { + name: "openfaas", + docs: dedent` Deploy a [OpenFaaS](https://www.openfaas.com/) function using Garden. Requires the \`openfaas\` provider to be configured. `, - moduleOutputsSchema: openfaasModuleOutputsSchema(), - schema: openfaasModuleSpecSchema(), - handlers: { - configure: configureModule, - getModuleOutputs: getOpenfaasModuleOutputs, - getBuildStatus: getOpenfaasModuleBuildStatus, - build: buildOpenfaasModule, - // TODO: design and implement a proper test flow for openfaas functions - testModule: testExecModule, - getServiceStatus, - getServiceLogs, - deployService, - deleteService, + moduleOutputsSchema: openfaasModuleOutputsSchema(), + schema: openfaasModuleSpecSchema(), + handlers: { + configure: configureModule, + getModuleOutputs: getOpenfaasModuleOutputs, + getBuildStatus: getOpenfaasModuleBuildStatus, + build: buildOpenfaasModule, + // TODO: design and implement a proper test flow for openfaas functions + testModule: testExecModule, + getServiceStatus, + getServiceLogs, + deployService, + deleteService, + }, }, - }, - ], - tools: [faasCliSpec], -}) + ], + tools: [faasCliSpec], + }) const templateModuleConfig: ExecModuleConfig = { allowPublish: false, diff --git a/core/src/plugins/plugins.ts b/core/src/plugins/plugins.ts index 792fe5cba8..928501c090 100644 --- a/core/src/plugins/plugins.ts +++ b/core/src/plugins/plugins.ts @@ -7,32 +7,35 @@ */ import { InternalError } from "../exceptions" +import { GardenPluginCallback } from "../types/plugin/plugin" // These plugins are always registered -export const supportedPlugins = [ - require("./container/container"), - require("./exec"), - require("./hadolint/hadolint"), - require("./kubernetes/kubernetes"), - require("./kubernetes/local/local"), - require("./maven-container/maven-container"), - require("./octant/octant"), - require("./openfaas/openfaas"), - require("./terraform/terraform"), - require("./templated"), -].map(resolvePluginFromModule) - -// These plugins are always registered -export const builtinPlugins = supportedPlugins.concat( +export const getSupportedPlugins = () => [ - require("./google/google-app-engine"), - require("./google/google-cloud-functions"), - require("./local/local-google-cloud-functions"), - require("./npm-package"), + require("./container/container"), + require("./exec"), + require("./hadolint/hadolint"), + require("./kubernetes/kubernetes"), + require("./kubernetes/local/local"), + require("./maven-container/maven-container"), + require("./octant/octant"), + require("./openfaas/openfaas"), + require("./terraform/terraform"), + require("./templated"), ].map(resolvePluginFromModule) -) -function resolvePluginFromModule(module: NodeModule) { +// These plugins are always registered +export const getBuiltinPlugins = () => + getSupportedPlugins().concat( + [ + require("./google/google-app-engine"), + require("./google/google-cloud-functions"), + require("./local/local-google-cloud-functions"), + require("./npm-package"), + ].map(resolvePluginFromModule) + ) + +function resolvePluginFromModule(module: NodeModule): GardenPluginCallback { const filename = module.filename const gardenPlugin = module["gardenPlugin"] @@ -40,9 +43,5 @@ function resolvePluginFromModule(module: NodeModule) { throw new InternalError(`Module ${filename} does not define a gardenPlugin`, { filename }) } - if (typeof gardenPlugin === "function") { - return gardenPlugin() - } - return gardenPlugin } diff --git a/core/src/plugins/terraform/commands.ts b/core/src/plugins/terraform/commands.ts index 66a75b7884..1c92e0a65c 100644 --- a/core/src/plugins/terraform/commands.ts +++ b/core/src/plugins/terraform/commands.ts @@ -22,10 +22,8 @@ import { getProviderStatusCachePath } from "../../tasks/resolve-provider" const commandsToWrap = ["apply", "plan", "destroy"] const initCommand = chalk.bold("terraform init") -export const terraformCommands: PluginCommand[] = commandsToWrap.flatMap((commandName) => [ - makeRootCommand(commandName), - makeModuleCommand(commandName), -]) +export const getTerraformCommands = (): PluginCommand[] => + commandsToWrap.flatMap((commandName) => [makeRootCommand(commandName), makeModuleCommand(commandName)]) function makeRootCommand(commandName: string) { const terraformCommand = chalk.bold("terraform " + commandName) diff --git a/core/src/plugins/terraform/module.ts b/core/src/plugins/terraform/module.ts index a9751b0f65..d10a114a40 100644 --- a/core/src/plugins/terraform/module.ts +++ b/core/src/plugins/terraform/module.ts @@ -38,12 +38,13 @@ export interface TerraformModuleSpec extends TerraformBaseSpec { export interface TerraformModule extends GardenModule {} -export const schema = joi.object().keys({ - build: baseBuildSpecSchema(), - allowDestroy: joi.boolean().default(false).description(dedent` +export const terraformModuleSchema = () => + joi.object().keys({ + build: baseBuildSpecSchema(), + allowDestroy: joi.boolean().default(false).description(dedent` If set to true, Garden will run \`terraform destroy\` on the stack when calling \`garden delete env\` or \`garden delete service \`. `), - autoApply: joi.boolean().allow(null).default(null).description(dedent` + autoApply: joi.boolean().allow(null).default(null).description(dedent` If set to true, Garden will automatically run \`terraform apply -auto-approve\` when the stack is not up-to-date. Otherwise, a warning is logged if the stack is out-of-date, and an error thrown if it is missing entirely. @@ -52,23 +53,23 @@ export const schema = joi.object().keys({ Defaults to the value set in the provider config. `), - dependencies: dependenciesSchema(), - root: joi.posixPath().subPathOnly().default(".").description(dedent` + dependencies: dependenciesSchema(), + root: joi.posixPath().subPathOnly().default(".").description(dedent` Specify the path to the working directory root—i.e. where your Terraform files are—relative to the module root. `), - variables: variablesSchema().description(dedent` + variables: variablesSchema().description(dedent` A map of variables to use when applying the stack. You can define these here or you can place a \`terraform.tfvars\` file in the working directory root. If you specified \`variables\` in the \`terraform\` provider config, those will be included but the variables specified here take precedence. `), - version: joi.string().allow(...supportedVersions, null).description(dedent` + version: joi.string().allow(...supportedVersions, null).description(dedent` The version of Terraform to use. Defaults to the version set in the provider config. Set to \`null\` to use whichever version of \`terraform\` that is on your PATH. `), - workspace: joi.string().allow(null).description("Use the specified Terraform workspace."), -}) + workspace: joi.string().allow(null).description("Use the specified Terraform workspace."), + }) export async function configureTerraformModule({ ctx, moduleConfig }: ConfigureModuleParams) { // Make sure the configured root path exists diff --git a/core/src/plugins/terraform/terraform.ts b/core/src/plugins/terraform/terraform.ts index aaac850298..534b97f751 100644 --- a/core/src/plugins/terraform/terraform.ts +++ b/core/src/plugins/terraform/terraform.ts @@ -17,11 +17,17 @@ import { supportedVersions, defaultTerraformVersion, terraformCliSpecs } from ". import { ConfigureProviderParams, ConfigureProviderResult } from "../../types/plugin/provider/configureProvider" import { ConfigurationError } from "../../exceptions" import { variablesSchema, TerraformBaseSpec } from "./common" -import { schema, configureTerraformModule, getTerraformStatus, deployTerraform, deleteTerraformModule } from "./module" +import { + terraformModuleSchema, + configureTerraformModule, + getTerraformStatus, + deployTerraform, + deleteTerraformModule, +} from "./module" import { DOCS_BASE_URL } from "../../constants" import { SuggestModulesParams, SuggestModulesResult } from "../../types/plugin/module/suggestModules" import { listDirectory } from "../../util/fs" -import { terraformCommands } from "./commands" +import { getTerraformCommands } from "./commands" type TerraformProviderConfig = GenericProviderConfig & TerraformBaseSpec & { @@ -66,23 +72,24 @@ const configSchema = providerConfigBaseSchema() const serviceOutputsTemplateString = "${runtime.services..outputs.}" const providerOutputsTemplateString = "${providers.terraform.outputs.}" -export const gardenPlugin = createGardenPlugin({ - name: "terraform", - docs: dedent` +export const gardenPlugin = () => + createGardenPlugin({ + name: "terraform", + docs: dedent` This provider allows you to integrate Terraform stacks into your Garden project. See the [Terraform guide](${DOCS_BASE_URL}/advanced/terraform) for details and usage information. `, - configSchema, - handlers: { - configureProvider, - getEnvironmentStatus, - prepareEnvironment, - cleanupEnvironment, - }, - commands: terraformCommands, - createModuleTypes: [ - { - name: "terraform", - docs: dedent` + configSchema, + handlers: { + configureProvider, + getEnvironmentStatus, + prepareEnvironment, + cleanupEnvironment, + }, + commands: getTerraformCommands(), + createModuleTypes: [ + { + name: "terraform", + docs: dedent` Resolves a Terraform stack and either applies it automatically (if \`autoApply: true\`) or warns when the stack resources are not up-to-date. **Note: It is not recommended to set \`autoApply\` to \`true\` for any production or shared environments, since this may result in accidental or conflicting changes to the stack.** Instead, it is recommended to manually plan and apply using the provided plugin commands. Run \`garden plugins terraform\` for details. @@ -93,38 +100,38 @@ export const gardenPlugin = createGardenPlugin({ See the [Terraform guide](${DOCS_BASE_URL}/advanced/terraform) for a high-level introduction to the \`terraform\` provider. `, - serviceOutputsSchema: joiVariables().description("A map of all the outputs defined in the Terraform stack."), - schema, - handlers: { - suggestModules: async ({ name, path }: SuggestModulesParams): Promise => { - const files = await listDirectory(path, { recursive: false }) - - if (files.filter((f) => f.endsWith(".tf")).length > 0) { - return { - suggestions: [ - { - description: `based on found .tf files`, - module: { - type: "terraform", - name, - autoApply: false, + serviceOutputsSchema: joiVariables().description("A map of all the outputs defined in the Terraform stack."), + schema: terraformModuleSchema(), + handlers: { + suggestModules: async ({ name, path }: SuggestModulesParams): Promise => { + const files = await listDirectory(path, { recursive: false }) + + if (files.filter((f) => f.endsWith(".tf")).length > 0) { + return { + suggestions: [ + { + description: `based on found .tf files`, + module: { + type: "terraform", + name, + autoApply: false, + }, }, - }, - ], + ], + } + } else { + return { suggestions: [] } } - } else { - return { suggestions: [] } - } + }, + configure: configureTerraformModule, + getServiceStatus: getTerraformStatus, + deployService: deployTerraform, + deleteService: deleteTerraformModule, }, - configure: configureTerraformModule, - getServiceStatus: getTerraformStatus, - deployService: deployTerraform, - deleteService: deleteTerraformModule, }, - }, - ], - tools: Object.values(terraformCliSpecs), -}) + ], + tools: Object.values(terraformCliSpecs), + }) async function configureProvider({ config, diff --git a/core/src/server/commands.ts b/core/src/server/commands.ts index b8151b59ab..08e9a073da 100644 --- a/core/src/server/commands.ts +++ b/core/src/server/commands.ts @@ -112,8 +112,8 @@ export async function prepareCommands(): Promise { } // Need to import this here to avoid circular import issues - const { coreCommands } = require("../commands/commands") - coreCommands.forEach(addCommand) + const { getCoreCommands } = require("../commands/commands") + getCoreCommands().forEach(addCommand) return commands } diff --git a/core/src/tasks/resolve-provider.ts b/core/src/tasks/resolve-provider.ts index 77ceff2b8f..d80b0484d4 100644 --- a/core/src/tasks/resolve-provider.ts +++ b/core/src/tasks/resolve-provider.ts @@ -91,7 +91,7 @@ export class ResolveProviderTask extends BaseTask { const allDeps = uniq([...pluginDeps, ...explicitDeps, ...implicitDeps]) const rawProviderConfigs = this.garden.getRawProviderConfigs() - const plugins = keyBy(await this.garden.getPlugins(), "name") + const plugins = keyBy(await this.garden.getAllPlugins(), "name") const matchDependencies = (depName: string) => { // Match against a provider if its name matches directly, or it inherits from a base named `depName` @@ -175,7 +175,7 @@ export class ResolveProviderTask extends BaseTask { // Validating the output config against the base plugins. This is important to make sure base handlers are // compatible with the config. - const plugins = await this.garden.getPlugins() + const plugins = await this.garden.getAllPlugins() const pluginsByName = keyBy(plugins, "name") const plugin = pluginsByName[providerName] diff --git a/core/src/types/plugin/base.ts b/core/src/types/plugin/base.ts index a46feda1c9..161002100a 100644 --- a/core/src/types/plugin/base.ts +++ b/core/src/types/plugin/base.ts @@ -64,12 +64,12 @@ export const taskActionParamsSchema = () => task: taskSchema(), }) -export const runBaseParams = { +export const runBaseParams = () => ({ interactive: joi.boolean().description("Whether to run the module interactively (i.e. attach to the terminal)."), runtimeContext: runtimeContextSchema(), silent: joi.boolean().description("Set to false if the output should not be logged to the console."), timeout: joi.number().optional().description("If set, how long to run the command before timing out."), -} +}) // TODO: update this schema in 0.13 export interface RunResult { diff --git a/core/src/types/plugin/module/runModule.ts b/core/src/types/plugin/module/runModule.ts index 91e10fb800..8bd202627e 100644 --- a/core/src/types/plugin/module/runModule.ts +++ b/core/src/types/plugin/module/runModule.ts @@ -20,7 +20,7 @@ export interface RunModuleParams extends timeout?: number } -export const runModuleBaseSchema = () => moduleActionParamsSchema().keys(runBaseParams) +export const runModuleBaseSchema = () => moduleActionParamsSchema().keys(runBaseParams()) export const runModuleParamsSchema = () => runModuleBaseSchema().keys({ diff --git a/core/src/types/plugin/plugin.ts b/core/src/types/plugin/plugin.ts index 2bb8415b1c..228ea32c66 100644 --- a/core/src/types/plugin/plugin.ts +++ b/core/src/types/plugin/plugin.ts @@ -412,11 +412,13 @@ export interface GardenPlugin extends GardenPluginSpec { dashboardPages: DashboardPage[] } +export type GardenPluginCallback = () => GardenPlugin + export interface PluginMap { [name: string]: GardenPlugin } -export type RegisterPluginParam = string | GardenPlugin +export type RegisterPluginParam = string | GardenPlugin | GardenPluginCallback const moduleHandlersSchema = () => joi diff --git a/core/src/types/plugin/service/getPortForward.ts b/core/src/types/plugin/service/getPortForward.ts index c2c91edd92..792a290d49 100644 --- a/core/src/types/plugin/service/getPortForward.ts +++ b/core/src/types/plugin/service/getPortForward.ts @@ -33,7 +33,7 @@ export const getPortForward = () => ({ If there is a corresponding \`stopPortForward\` handler, it is called when cleaning up. `, - paramsSchema: serviceActionParamsSchema().keys(forwardablePortKeys), + paramsSchema: serviceActionParamsSchema().keys(forwardablePortKeys()), resultSchema: joi.object().keys({ hostname: joi.string().hostname().description("The hostname of the port tunnel.").example("localhost"), port: joi.number().integer().description("The port of the tunnel.").example(12345), diff --git a/core/src/types/plugin/service/runService.ts b/core/src/types/plugin/service/runService.ts index 83669505ca..8eca7cf7d8 100644 --- a/core/src/types/plugin/service/runService.ts +++ b/core/src/types/plugin/service/runService.ts @@ -26,6 +26,6 @@ export const runService = () => ({ Called by the \`garden run service\` command. `, - paramsSchema: serviceActionParamsSchema().keys(runBaseParams), + paramsSchema: serviceActionParamsSchema().keys(runBaseParams()), resultSchema: runResultSchema(), }) diff --git a/core/src/types/plugin/service/stopPortForward.ts b/core/src/types/plugin/service/stopPortForward.ts index 542e8ecc6d..d468f7f360 100644 --- a/core/src/types/plugin/service/stopPortForward.ts +++ b/core/src/types/plugin/service/stopPortForward.ts @@ -21,6 +21,6 @@ export const stopPortForward = () => ({ description: dedent` Close a port forward created by \`getPortForward\`. `, - paramsSchema: serviceActionParamsSchema().keys(forwardablePortKeys), + paramsSchema: serviceActionParamsSchema().keys(forwardablePortKeys()), resultSchema: joi.object().keys({}), }) diff --git a/core/src/types/plugin/task/runTask.ts b/core/src/types/plugin/task/runTask.ts index 98ec6a40b4..8d5ad5464d 100644 --- a/core/src/types/plugin/task/runTask.ts +++ b/core/src/types/plugin/task/runTask.ts @@ -38,7 +38,7 @@ export const runTask = () => ({ Runs a task within the context of its module. This should wait until execution completes, and return its output. `, - paramsSchema: taskActionParamsSchema().keys(runBaseParams).keys({ + paramsSchema: taskActionParamsSchema().keys(runBaseParams()).keys({ artifactsPath: artifactsPathSchema(), taskVersion: taskVersionSchema(), }), diff --git a/core/src/types/service.ts b/core/src/types/service.ts index 8a67d9ea2a..921f36b587 100644 --- a/core/src/types/service.ts +++ b/core/src/types/service.ts @@ -149,7 +149,7 @@ export interface ForwardablePort { urlProtocol?: string } -export const forwardablePortKeys = { +export const forwardablePortKeys = () => ({ name: joiIdentifier().description( "A descriptive name for the port. Should correspond to user-configured ports where applicable." ), @@ -159,9 +159,9 @@ export const forwardablePortKeys = { urlProtocol: joi .string() .description("The protocol to use for URLs pointing at the port. This can be any valid URI protocol."), -} +}) -const forwardablePortSchema = () => joi.object().keys(forwardablePortKeys) +const forwardablePortSchema = () => joi.object().keys(forwardablePortKeys()) export interface ServiceStatus { createdAt?: string diff --git a/core/test/helpers.ts b/core/test/helpers.ts index f314444784..9c435cd12e 100644 --- a/core/test/helpers.ts +++ b/core/test/helpers.ts @@ -137,152 +137,161 @@ export async function configureTestModule({ moduleConfig }: ConfigureModuleParam const testPluginSecrets: { [key: string]: string } = {} -export const testPlugin = createGardenPlugin({ - name: "test-plugin", - dashboardPages: [ - { - name: "test", - description: "Test dashboard page", - title: "Test", - newWindow: false, - }, - ], - handlers: { - async configureProvider({ config }) { - for (let member in testPluginSecrets) { - delete testPluginSecrets[member] - } - return { config } - }, +export const testPlugin = () => + createGardenPlugin({ + name: "test-plugin", + dashboardPages: [ + { + name: "test", + description: "Test dashboard page", + title: "Test", + newWindow: false, + }, + ], + handlers: { + async configureProvider({ config }) { + for (let member in testPluginSecrets) { + delete testPluginSecrets[member] + } + return { config } + }, - async getDashboardPage({ page }) { - return { url: `http://localhost:12345/${page.name}` } - }, + async getDashboardPage({ page }) { + return { url: `http://localhost:12345/${page.name}` } + }, - async getEnvironmentStatus() { - return { ready: true, outputs: { testKey: "testValue" } } - }, + async getEnvironmentStatus() { + return { ready: true, outputs: { testKey: "testValue" } } + }, - async prepareEnvironment() { - return { status: { ready: true, outputs: { testKey: "testValue" } } } - }, + async prepareEnvironment() { + return { status: { ready: true, outputs: { testKey: "testValue" } } } + }, - async setSecret({ key, value }) { - testPluginSecrets[key] = "" + value - return {} - }, + async setSecret({ key, value }) { + testPluginSecrets[key] = "" + value + return {} + }, - async getSecret({ key }) { - return { value: testPluginSecrets[key] || null } - }, + async getSecret({ key }) { + return { value: testPluginSecrets[key] || null } + }, - async deleteSecret({ key }) { - if (testPluginSecrets[key]) { - delete testPluginSecrets[key] - return { found: true } - } else { - return { found: false } - } - }, - async getDebugInfo() { - return { - info: { - exampleData: "data", - exampleData2: "data2", - }, - } + async deleteSecret({ key }) { + if (testPluginSecrets[key]) { + delete testPluginSecrets[key] + return { found: true } + } else { + return { found: false } + } + }, + async getDebugInfo() { + return { + info: { + exampleData: "data", + exampleData2: "data2", + }, + } + }, }, - }, - createModuleTypes: [ - { - name: "test", - docs: "Test module type", - schema: testModuleSpecSchema(), - handlers: { - testModule: testExecModule, - configure: configureTestModule, - build: buildExecModule, - runModule, - - async getModuleOutputs() { - return { outputs: { foo: "bar" } } - }, - async getServiceStatus() { - return { state: "ready", detail: {} } - }, - async deployService() { - return { state: "ready", detail: {} } - }, - - async runService({ - ctx, - service, - interactive, - runtimeContext, - timeout, - log, - }: RunServiceParams): Promise { - return runModule({ + createModuleTypes: [ + { + name: "test", + docs: "Test module type", + schema: testModuleSpecSchema(), + handlers: { + testModule: testExecModule, + configure: configureTestModule, + build: buildExecModule, + runModule, + + async getModuleOutputs() { + return { outputs: { foo: "bar" } } + }, + async getServiceStatus() { + return { state: "ready", detail: {} } + }, + async deployService() { + return { state: "ready", detail: {} } + }, + + async runService({ ctx, - log, - module: service.module, - args: [service.name], + service, interactive, runtimeContext, timeout, - }) - }, - - async runTask({ ctx, task, interactive, runtimeContext, log }: RunTaskParams): Promise { - const result = await runModule({ - ctx, - interactive, log, - runtimeContext, - module: task.module, - args: task.spec.command, - timeout: task.spec.timeout || 9999, - }) - - return { - ...result, - taskName: task.name, - outputs: { - log: result.log, - }, - } + }: RunServiceParams): Promise { + return runModule({ + ctx, + log, + module: service.module, + args: [service.name], + interactive, + runtimeContext, + timeout, + }) + }, + + async runTask({ ctx, task, interactive, runtimeContext, log }: RunTaskParams): Promise { + const result = await runModule({ + ctx, + interactive, + log, + runtimeContext, + module: task.module, + args: task.spec.command, + timeout: task.spec.timeout || 9999, + }) + + return { + ...result, + taskName: task.name, + outputs: { + log: result.log, + }, + } + }, }, }, - }, - ], -}) - -export const testPluginB = createGardenPlugin({ - ...testPlugin, - name: "test-plugin-b", - dependencies: ["test-plugin"], - createModuleTypes: [], - // This doesn't actually change any behavior, except to use this provider instead of test-plugin - extendModuleTypes: [ - { - name: "test", - handlers: testPlugin.createModuleTypes![0].handlers, - }, - ], -}) + ], + }) -export const testPluginC = createGardenPlugin({ - ...testPlugin, - name: "test-plugin-c", - createModuleTypes: [ - { - name: "test-c", - docs: "Test module type C", - schema: testModuleSpecSchema(), - handlers: testPlugin.createModuleTypes![0].handlers, - }, - ], -}) +export const testPluginB = () => { + const base = testPlugin() + + return createGardenPlugin({ + ...base, + name: "test-plugin-b", + dependencies: ["test-plugin"], + createModuleTypes: [], + // This doesn't actually change any behavior, except to use this provider instead of test-plugin + extendModuleTypes: [ + { + name: "test", + handlers: base.createModuleTypes![0].handlers, + }, + ], + }) +} + +export const testPluginC = () => { + const base = testPlugin() + + return createGardenPlugin({ + ...base, + name: "test-plugin-c", + createModuleTypes: [ + { + name: "test-c", + docs: "Test module type C", + schema: testModuleSpecSchema(), + handlers: base.createModuleTypes![0].handlers, + }, + ], + }) +} const defaultModuleConfig: ModuleConfig = { apiVersion: DEFAULT_API_VERSION, @@ -317,11 +326,11 @@ export const makeTestModule = (params: Partial = {}) => { return { ...defaultModuleConfig, ...params } } -export const testPlugins = [testPlugin, testPluginB, testPluginC] +export const testPlugins = () => [testPlugin(), testPluginB(), testPluginC()] export const makeTestGarden = async (projectRoot: string, opts: GardenOpts = {}) => { opts = { sessionId: uuidv4(), ...opts } - const plugins = [...testPlugins, ...(opts.plugins || [])] + const plugins = [...testPlugins(), ...(opts.plugins || [])] return TestGarden.factory(projectRoot, { ...opts, plugins }) } diff --git a/core/test/unit/src/commands/create/create-module.ts b/core/test/unit/src/commands/create/create-module.ts index bd56216bd0..7742196976 100644 --- a/core/test/unit/src/commands/create/create-module.ts +++ b/core/test/unit/src/commands/create/create-module.ts @@ -17,7 +17,7 @@ import { safeLoadAll } from "js-yaml" import { exec, safeDumpYaml } from "../../../../../src/util/util" import stripAnsi = require("strip-ansi") import { getModuleTypes } from "../../../../../src/plugins" -import { supportedPlugins } from "../../../../../src/plugins/plugins" +import { getSupportedPlugins } from "../../../../../src/plugins/plugins" import inquirer = require("inquirer") import { defaultConfigFilename } from "../../../../../src/util/fs" @@ -228,8 +228,9 @@ describe("CreateModuleCommand", () => { }) describe("getModuleTypeSuggestions", () => { + const moduleTypes = getModuleTypes(getSupportedPlugins().map((f) => f())) + it("should return a list of all supported module types", async () => { - const moduleTypes = getModuleTypes(supportedPlugins) const result = await getModuleTypeSuggestions(garden.log, moduleTypes, tmp.path, "test") expect(result).to.eql([ @@ -242,7 +243,6 @@ describe("CreateModuleCommand", () => { await writeFile(join(tmp.path, "Chart.yaml"), "") await writeFile(join(tmp.path, "foo.tf"), "") - const moduleTypes = getModuleTypes(supportedPlugins) const result = await getModuleTypeSuggestions(garden.log, moduleTypes, tmp.path, "test") const stripped = result.map((r) => (r instanceof inquirer.Separator ? r : { ...r, name: stripAnsi(r.name) })) diff --git a/core/test/unit/src/commands/run/task.ts b/core/test/unit/src/commands/run/task.ts index c2d1dbcef6..8cfec278af 100644 --- a/core/test/unit/src/commands/run/task.ts +++ b/core/test/unit/src/commands/run/task.ts @@ -26,37 +26,40 @@ import { dedent } from "../../../../../src/util/string" import { runExecTask } from "../../../../../src/plugins/exec" import { createGardenPlugin } from "../../../../../src/types/plugin/plugin" -// Use the runExecTask handler -const testExecPlugin = createGardenPlugin({ - ...testPlugin, - createModuleTypes: [ - { - ...testPlugin.createModuleTypes![0], - handlers: { - ...testPlugin.createModuleTypes![0].handlers, - runTask: runExecTask, +describe("RunTaskCommand", () => { + const cmd = new RunTaskCommand() + + const basePlugin = testPlugin() + + // Use the runExecTask handler + const testExecPlugin = createGardenPlugin({ + ...basePlugin, + createModuleTypes: [ + { + ...basePlugin.createModuleTypes![0], + handlers: { + ...basePlugin.createModuleTypes![0].handlers, + runTask: runExecTask, + }, }, - }, - ], -}) -const testExecPluginB = createGardenPlugin({ - ...testPluginB, - extendModuleTypes: [ - { - name: "test", - handlers: testExecPlugin.createModuleTypes![0].handlers, - }, - ], -}) + ], + }) -async function makeExecTestGarden(projectRoot: string = projectRootA) { - return TestGarden.factory(projectRoot, { - plugins: [testExecPlugin, testExecPluginB], + const testExecPluginB = createGardenPlugin({ + ...testPluginB(), + extendModuleTypes: [ + { + name: "test", + handlers: testExecPlugin.createModuleTypes![0].handlers, + }, + ], }) -} -describe("RunTaskCommand", () => { - const cmd = new RunTaskCommand() + async function makeExecTestGarden(projectRoot: string = projectRootA) { + return TestGarden.factory(projectRoot, { + plugins: [testExecPlugin, testExecPluginB], + }) + } it("should run a task", async () => { const garden = await makeExecTestGarden() diff --git a/core/test/unit/src/commands/tools.ts b/core/test/unit/src/commands/tools.ts index e0584b6caf..e545437489 100644 --- a/core/test/unit/src/commands/tools.ts +++ b/core/test/unit/src/commands/tools.ts @@ -19,7 +19,6 @@ import { import { expect } from "chai" import { DEFAULT_API_VERSION } from "../../../../src/constants" import { createGardenPlugin } from "../../../../src/types/plugin/plugin" -import { pick } from "lodash" import { join } from "path" import { ToolsCommand } from "../../../../src/commands/tools" import { LogLevel } from "../../../../src/logger/log-node" @@ -114,7 +113,7 @@ describe("ToolsCommand", () => { const _garden = garden as any _garden.providerConfigs = [{ name: "test-a" }] - _garden.registeredPlugins = pick(garden["registeredPlugins"], ["test-a", "test-b"]) + _garden.registeredPlugins = [pluginA, pluginB] }) it("should list tools with no name specified", async () => { @@ -236,7 +235,7 @@ describe("ToolsCommand", () => { it("should run a tool by name when run outside of a project", async () => { const _garden: any = await makeDummyGarden(tmpDir.path, { noEnterprise: true }) - _garden.registeredPlugins = pick(garden["registeredPlugins"], ["test-a", "test-b"]) + _garden.registeredPlugins = [pluginA, pluginB] const { result } = await command.action({ garden: _garden, diff --git a/core/test/unit/src/commands/util/fetch-tools.ts b/core/test/unit/src/commands/util/fetch-tools.ts index 67a1c03faa..5be9f7e1d1 100644 --- a/core/test/unit/src/commands/util/fetch-tools.ts +++ b/core/test/unit/src/commands/util/fetch-tools.ts @@ -12,7 +12,6 @@ import { FetchToolsCommand } from "../../../../../src/commands/util/fetch-tools" import { expect } from "chai" import { DEFAULT_API_VERSION, GARDEN_GLOBAL_PATH } from "../../../../../src/constants" import { createGardenPlugin } from "../../../../../src/types/plugin/plugin" -import { pick } from "lodash" import { join } from "path" import { defaultNamespace } from "../../../../../src/config/project" @@ -79,7 +78,7 @@ describe("FetchToolsCommand", () => { }) garden.providerConfigs = [{ name: "test" }] - garden.registeredPlugins = pick(garden["registeredPlugins"], "test") + garden.registeredPlugins = [plugin] await garden.resolveProviders(garden.log) @@ -126,7 +125,7 @@ describe("FetchToolsCommand", () => { }) garden.providerConfigs = [] - garden.registeredPlugins = pick(garden["registeredPlugins"], "test") + garden.registeredPlugins = [plugin] await garden.resolveProviders(garden.log) @@ -164,7 +163,7 @@ describe("FetchToolsCommand", () => { }) garden.providerConfigs = [] - garden.registeredPlugins = pick(garden["registeredPlugins"], "test") + garden.registeredPlugins = [plugin] await garden.resolveProviders(garden.log) @@ -211,7 +210,7 @@ describe("FetchToolsCommand", () => { }) garden.providerConfigs = [] - garden.registeredPlugins = pick(garden["registeredPlugins"], "test") + garden.registeredPlugins = [plugin] await garden.resolveProviders(garden.log) diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index d04462beaf..54257db648 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -212,20 +212,6 @@ describe("Garden", () => { await expectError(async () => TestGarden.factory(projectRootA, { environmentName: "garden-bla" }), "parameter") }) - it("should throw if plugin module exports invalid name", async () => { - const pluginPath = join(__dirname, "plugins", "invalid-name.js") - const plugins = [pluginPath] - const projectRoot = join(dataDir, "test-project-empty") - await expectError(async () => TestGarden.factory(projectRoot, { plugins }), "plugin") - }) - - it("should throw if plugin module doesn't contain plugin", async () => { - const pluginPath = join(__dirname, "plugins", "missing-plugin.js") - const plugins = [pluginPath] - const projectRoot = join(dataDir, "test-project-empty") - await expectError(async () => TestGarden.factory(projectRoot, { plugins }), "plugin") - }) - it("should set .garden as the default cache dir", async () => { const projectRoot = join(dataDir, "test-project-empty") const garden = await TestGarden.factory(projectRoot, { plugins: [testPlugin] }) @@ -360,7 +346,7 @@ describe("Garden", () => { }) }) - describe("getPlugins", () => { + describe("getAllPlugins", () => { it("should attach base from createModuleTypes when overriding a handler via extendModuleTypes", async () => { const base = createGardenPlugin({ name: "base", @@ -409,9 +395,37 @@ describe("Garden", () => { expect(extended.handlers.build!.base!.base).to.not.exist }) + it("should throw if plugin module exports invalid name", async () => { + const pluginPath = join(__dirname, "plugins", "invalid-name.js") + const plugins = [pluginPath] + const projectRoot = join(dataDir, "test-project-empty") + const garden = await TestGarden.factory(projectRoot, { plugins }) + await expectError( + () => garden.getAllPlugins(), + (err) => + expect(stripAnsi(err.message)).to.equal( + `Unable to load plugin: Error: Error validating plugin module "${pluginPath}": key .gardenPlugin must be of type object` + ) + ) + }) + + it("should throw if plugin module doesn't contain plugin", async () => { + const pluginPath = join(__dirname, "plugins", "missing-plugin.js") + const plugins = [pluginPath] + const projectRoot = join(dataDir, "test-project-empty") + const garden = await TestGarden.factory(projectRoot, { plugins }) + await expectError( + () => garden.getAllPlugins(), + (err) => + expect(stripAnsi(err.message)).to.equal( + `Unable to load plugin: Error: Error validating plugin module "${pluginPath}": key .gardenPlugin is required` + ) + ) + }) + it("should throw if multiple plugins declare the same module type", async () => { const testPluginDupe = { - ...testPlugin, + ...testPlugin(), name: "test-plugin-dupe", } const garden = await makeTestGardenA([testPluginDupe]) @@ -419,7 +433,7 @@ describe("Garden", () => { garden["providerConfigs"].push({ name: "test-plugin-dupe" }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal( "Module type 'test' is declared in multiple plugins: test-plugin, test-plugin-dupe." @@ -444,7 +458,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal(deline` Plugin 'foo' extends module type 'bar' but the module type has not been declared. @@ -569,7 +583,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal(deline` Module type 'foo', defined in plugin 'foo', specifies base module type 'bar' which cannot be found. @@ -610,7 +624,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal(deline` Module type 'foo', defined in plugin 'foo', specifies base module type 'bar' which is defined by 'base' @@ -645,7 +659,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal(deline` Found circular dependency between module type @@ -902,7 +916,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal("Plugin 'foo' redeclares the 'foo' module type, already declared by its base.") ) @@ -1004,7 +1018,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal( "Plugin 'foo' specifies plugin 'base' as a base, but that plugin has not been registered." @@ -1028,7 +1042,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal("Found a circular dependency between registered plugins:\n\nfoo <- bar <- foo") ) @@ -1260,7 +1274,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal( "Plugin 'foo' redeclares the 'foo' module type, already declared by its base." @@ -1372,7 +1386,7 @@ describe("Garden", () => { expect(fooExtension.handlers.build!.base).to.exist expect(fooExtension.handlers.build!.base!.actionType).to.equal("build") expect(fooExtension.handlers.build!.base!.moduleType).to.equal("foo") - expect(fooExtension.handlers.build!.base!.pluginName).to.equal("foo") + expect(fooExtension.handlers.build!.base!.pluginName).to.equal("base-a") }) it("should throw if plugins have circular bases", async () => { @@ -1395,7 +1409,7 @@ describe("Garden", () => { }) await expectError( - () => garden.getPlugins(), + () => garden.getAllPlugins(), (err) => expect(err.message).to.equal( "Found a circular dependency between registered plugins:\n\nbase-a <- foo <- base-b <- base-a" diff --git a/core/test/unit/src/plugins/container/container.ts b/core/test/unit/src/plugins/container/container.ts index d1f69ac6ae..537688a935 100644 --- a/core/test/unit/src/plugins/container/container.ts +++ b/core/test/unit/src/plugins/container/container.ts @@ -35,7 +35,7 @@ describe("plugins.container", () => { const modulePath = resolve(dataDir, "test-project-container", "module-a") const relDockerfilePath = "docker-dir/Dockerfile" - const plugin = gardenPlugin + const plugin = gardenPlugin() const handlers = plugin.createModuleTypes![0].handlers const configure = handlers.configure! const build = handlers.build! diff --git a/core/test/unit/src/plugins/container/helpers.ts b/core/test/unit/src/plugins/container/helpers.ts index dc103c8450..697b0c72e8 100644 --- a/core/test/unit/src/plugins/container/helpers.ts +++ b/core/test/unit/src/plugins/container/helpers.ts @@ -31,7 +31,7 @@ describe("containerHelpers", () => { const modulePath = resolve(dataDir, "test-project-container", "module-a") const relDockerfilePath = "docker-dir/Dockerfile" - const plugin = gardenPlugin + const plugin = gardenPlugin() const configure = plugin.createModuleTypes![0].handlers.configure! const baseConfig: ModuleConfig = { diff --git a/core/test/unit/src/plugins/invalid-name.ts b/core/test/unit/src/plugins/invalid-name.ts index 895757357c..0f359354cb 100644 --- a/core/test/unit/src/plugins/invalid-name.ts +++ b/core/test/unit/src/plugins/invalid-name.ts @@ -8,7 +8,8 @@ import { createGardenPlugin } from "../../../../src/types/plugin/plugin" -export const gardenPlugin = createGardenPlugin({ - name: "BAt1%!2f", - handlers: {}, -}) +export const gardenPlugin = () => + createGardenPlugin({ + name: "BAt1%!2f", + handlers: {}, + }) diff --git a/core/test/unit/src/plugins/kubernetes/container/ingress.ts b/core/test/unit/src/plugins/kubernetes/container/ingress.ts index dab17b463b..eb0489c7f3 100644 --- a/core/test/unit/src/plugins/kubernetes/container/ingress.ts +++ b/core/test/unit/src/plugins/kubernetes/container/ingress.ts @@ -304,7 +304,7 @@ const wildcardDomainCertSecret = { describe("createIngressResources", () => { const projectRoot = resolve(dataDir, "test-project-container") - const plugin = gardenPlugin + const plugin = gardenPlugin() const configure = plugin.createModuleTypes![0].handlers.configure! let garden: Garden @@ -327,7 +327,7 @@ describe("createIngressResources", () => { beforeEach(async () => { garden = await makeTestGarden(projectRoot, { plugins: [gardenPlugin] }) - const k8sPlugin = garden.registeredPlugins.kubernetes + const k8sPlugin = await garden.getPlugin("kubernetes") tools = keyBy( (k8sPlugin.tools || []).map((t) => new PluginTool(t)), "name" diff --git a/core/test/unit/src/plugins/kubernetes/kubernetes.ts b/core/test/unit/src/plugins/kubernetes/kubernetes.ts index 6858319d20..5c38a8b5e6 100644 --- a/core/test/unit/src/plugins/kubernetes/kubernetes.ts +++ b/core/test/unit/src/plugins/kubernetes/kubernetes.ts @@ -55,7 +55,7 @@ describe("kubernetes configureProvider", () => { const result = await configureProvider({ ctx: await garden.getPluginContext( providerFromConfig({ - plugin: gardenPlugin, + plugin: gardenPlugin(), config: basicConfig, dependencies: {}, moduleConfigs: [], @@ -91,7 +91,7 @@ describe("kubernetes configureProvider", () => { const result = await configureProvider({ ctx: await garden.getPluginContext( providerFromConfig({ - plugin: gardenPlugin, + plugin: gardenPlugin(), config: basicConfig, dependencies: {}, moduleConfigs: [], diff --git a/core/test/unit/src/plugins/maven-container/maven-container.ts b/core/test/unit/src/plugins/maven-container/maven-container.ts index 972676d7d7..58d1f37cea 100644 --- a/core/test/unit/src/plugins/maven-container/maven-container.ts +++ b/core/test/unit/src/plugins/maven-container/maven-container.ts @@ -37,8 +37,8 @@ describe("maven-container", () => { const projectRoot = resolve(dataDir, "test-projects", "maven-container") const modulePath = projectRoot - const plugin = mavenPlugin - const basePlugin = containerPlugin + const plugin = mavenPlugin() + const basePlugin = containerPlugin() const handlers = plugin.createModuleTypes![0].handlers const baseHandlers = basePlugin.createModuleTypes![0].handlers const build = handlers.build! diff --git a/core/test/unit/src/plugins/terraform/terraform.ts b/core/test/unit/src/plugins/terraform/terraform.ts index 0e5f89c51f..5adee94b61 100644 --- a/core/test/unit/src/plugins/terraform/terraform.ts +++ b/core/test/unit/src/plugins/terraform/terraform.ts @@ -15,7 +15,7 @@ import { getDataDir, makeTestGarden, getLogMessages } from "../../../../helpers" import { findByName } from "../../../../../src/util/util" import { Garden } from "../../../../../src/garden" import { TaskTask } from "../../../../../src/tasks/task" -import { terraformCommands } from "../../../../../src/plugins/terraform/commands" +import { getTerraformCommands } from "../../../../../src/plugins/terraform/commands" import { LogLevel } from "../../../../../src/logger/log-node" import { ConfigGraph } from "../../../../../src/config-graph" import { TerraformProvider } from "../../../../../src/plugins/terraform/terraform" @@ -66,7 +66,7 @@ describe("Terraform provider", () => { it("should expose outputs to template contexts after applying", async () => { const provider = await garden.resolveProvider(garden.log, "terraform") const ctx = await garden.getPluginContext(provider) - const applyRootCommand = findByName(terraformCommands, "apply-root")! + const applyRootCommand = findByName(getTerraformCommands(), "apply-root")! await applyRootCommand.handler({ ctx, args: ["-auto-approve", "-input=false"], @@ -88,7 +88,7 @@ describe("Terraform provider", () => { const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "apply-root")! + const command = findByName(getTerraformCommands(), "apply-root")! await command.handler({ ctx, args: ["-auto-approve", "-input=false"], @@ -103,7 +103,7 @@ describe("Terraform provider", () => { const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "apply-root")! + const command = findByName(getTerraformCommands(), "apply-root")! await command.handler({ ctx, args: ["-auto-approve", "-input=false"], @@ -121,7 +121,7 @@ describe("Terraform provider", () => { const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "plan-root")! + const command = findByName(getTerraformCommands(), "plan-root")! await command.handler({ ctx, args: ["-input=false"], @@ -136,7 +136,7 @@ describe("Terraform provider", () => { const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "plan-root")! + const command = findByName(getTerraformCommands(), "plan-root")! await command.handler({ ctx, args: ["-input=false"], @@ -154,7 +154,7 @@ describe("Terraform provider", () => { const provider = (await garden.resolveProvider(garden.log, "terraform")) as TerraformProvider const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "destroy-root")! + const command = findByName(getTerraformCommands(), "destroy-root")! await command.handler({ ctx, args: ["-input=false", "-auto-approve"], @@ -169,7 +169,7 @@ describe("Terraform provider", () => { const ctx = await garden.getPluginContext(provider) - const command = findByName(terraformCommands, "destroy-root")! + const command = findByName(getTerraformCommands(), "destroy-root")! await command.handler({ ctx, args: ["-input=false", "-auto-approve"], @@ -188,7 +188,7 @@ describe("Terraform provider", () => { const ctx = await garden.getPluginContext(provider) // This creates the test file - const command = findByName(terraformCommands, "apply-root")! + const command = findByName(getTerraformCommands(), "apply-root")! await command.handler({ ctx, args: ["-auto-approve", "-input=false"], @@ -332,7 +332,7 @@ describe("Terraform module type", () => { const ctx = await garden.getPluginContext(provider) graph = await garden.getConfigGraph(garden.log) - const command = findByName(terraformCommands, "apply-module")! + const command = findByName(getTerraformCommands(), "apply-module")! await command.handler({ ctx, args: ["tf", "-auto-approve", "-input=false"], @@ -355,7 +355,7 @@ describe("Terraform module type", () => { graph = await _garden.getConfigGraph(_garden.log) - const command = findByName(terraformCommands, "apply-module")! + const command = findByName(getTerraformCommands(), "apply-module")! await command.handler({ ctx, args: ["tf", "-auto-approve", "-input=false"], @@ -374,7 +374,7 @@ describe("Terraform module type", () => { const ctx = await garden.getPluginContext(provider) graph = await garden.getConfigGraph(garden.log) - const command = findByName(terraformCommands, "plan-module")! + const command = findByName(getTerraformCommands(), "plan-module")! await command.handler({ ctx, args: ["tf", "-input=false"], @@ -397,7 +397,7 @@ describe("Terraform module type", () => { graph = await _garden.getConfigGraph(_garden.log) - const command = findByName(terraformCommands, "plan-module")! + const command = findByName(getTerraformCommands(), "plan-module")! await command.handler({ ctx, args: ["tf", "-input=false"], @@ -416,7 +416,7 @@ describe("Terraform module type", () => { const ctx = await garden.getPluginContext(provider) graph = await garden.getConfigGraph(garden.log) - const command = findByName(terraformCommands, "destroy-module")! + const command = findByName(getTerraformCommands(), "destroy-module")! await command.handler({ ctx, args: ["tf", "-input=false", "-auto-approve"], @@ -439,7 +439,7 @@ describe("Terraform module type", () => { graph = await _garden.getConfigGraph(_garden.log) - const command = findByName(terraformCommands, "destroy-module")! + const command = findByName(getTerraformCommands(), "destroy-module")! await command.handler({ ctx, args: ["tf", "-input=false", "-auto-approve"], @@ -464,7 +464,7 @@ describe("Terraform module type", () => { it("should expose runtime outputs to template contexts if stack had already been applied", async () => { const provider = await garden.resolveProvider(garden.log, "terraform") const ctx = await garden.getPluginContext(provider) - const applyCommand = findByName(terraformCommands, "apply-module")! + const applyCommand = findByName(getTerraformCommands(), "apply-module")! await applyCommand.handler({ ctx, args: ["tf", "-auto-approve", "-input=false"], @@ -481,7 +481,7 @@ describe("Terraform module type", () => { it("should return outputs with the service status", async () => { const provider = await garden.resolveProvider(garden.log, "terraform") const ctx = await garden.getPluginContext(provider) - const applyCommand = findByName(terraformCommands, "apply-module")! + const applyCommand = findByName(getTerraformCommands(), "apply-module")! await applyCommand.handler({ ctx, args: ["tf", "-auto-approve", "-input=false"], @@ -515,7 +515,7 @@ describe("Terraform module type", () => { const provider = await _garden.resolveProvider(_garden.log, "terraform") const ctx = await _garden.getPluginContext(provider) - const applyCommand = findByName(terraformCommands, "apply-module")! + const applyCommand = findByName(getTerraformCommands(), "apply-module")! await applyCommand.handler({ ctx, args: ["tf", "-auto-approve", "-input=false"], diff --git a/plugins/conftest-container/index.ts b/plugins/conftest-container/index.ts index a79ca569ff..1ea8cdf572 100644 --- a/plugins/conftest-container/index.ts +++ b/plugins/conftest-container/index.ts @@ -21,7 +21,7 @@ const moduleTypeUrl = getModuleTypeUrl("conftest") /** * Auto-generates a conftest module for each container module in your project */ -export const gardenPlugin = createGardenPlugin({ +export const gardenPlugin = () => createGardenPlugin({ name: "conftest-container", base: "conftest", dependencies: ["container"], diff --git a/plugins/conftest-kubernetes/index.ts b/plugins/conftest-kubernetes/index.ts index 0e8a850d85..14a66bdaef 100644 --- a/plugins/conftest-kubernetes/index.ts +++ b/plugins/conftest-kubernetes/index.ts @@ -24,7 +24,7 @@ const gitHubUrl = getGitHubUrl("examples/conftest") /** * Auto-generates a conftest module for each helm and kubernetes module in your project */ -export const gardenPlugin = createGardenPlugin({ +export const gardenPlugin = () => createGardenPlugin({ name: "conftest-kubernetes", base: "conftest", dependencies: ["kubernetes"], diff --git a/plugins/conftest/index.ts b/plugins/conftest/index.ts index 27c3b40a01..8b61d21e62 100644 --- a/plugins/conftest/index.ts +++ b/plugins/conftest/index.ts @@ -33,7 +33,7 @@ export interface ConftestProviderConfig extends GenericProviderConfig { export interface ConftestProvider extends Provider {} -export const configSchema = providerConfigBaseSchema() +export const configSchema = () => providerConfigBaseSchema() .keys({ policyPath: joi .posixPath() @@ -94,7 +94,7 @@ const commonModuleSchema = joi.object().keys({ .description("Set to true to use the conftest --combine flag"), }) -export const gardenPlugin = createGardenPlugin({ +export const gardenPlugin = () => createGardenPlugin({ name: "conftest", docs: dedent` This provider allows you to validate your configuration files against policies that you specify, using the [conftest tool](https://github.com/instrumenta/conftest) and Open Policy Agent rego query files. The provider creates a module type of the same name, which allows you to specify files to validate. Each module then creates a Garden test that becomes part of your Stack Graph. @@ -104,7 +104,7 @@ export const gardenPlugin = createGardenPlugin({ If those don't match your needs, you can use this provider directly and manually configure your \`conftest\` modules. Simply add this provider to your project configuration, and see the [conftest module documentation](${moduleTypeUrl}) for a detailed reference. Also, check out the below reference for how to configure default policies, default namespaces, and test failure thresholds for all \`conftest\` modules. `, dependencies: [], - configSchema, + configSchema: configSchema(), createModuleTypes: [ { name: "conftest",