From de9b3c952499caea99161df08f3dabe8e31daa3b Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Sat, 24 Aug 2019 18:45:55 +0200 Subject: [PATCH] refactor(plugins): make plugin definition interface more intuitive This is to prepare for more changes related to the plugin SDK, and to overall make the interface more intuitive. For example, we now distinguish more explicitly between creating and extending module types. Also added a bit of validation when loading plugins (see the added tests). There should be no visible change in usage or operation. --- garden-service/src/actions.ts | 105 ++-- garden-service/src/config/project.ts | 9 +- garden-service/src/docs/config.ts | 12 +- garden-service/src/garden.ts | 181 ++++--- .../src/plugins/container/container.ts | 59 +-- garden-service/src/plugins/exec.ts | 24 +- .../src/plugins/google/google-app-engine.ts | 14 +- .../plugins/google/google-cloud-functions.ts | 27 +- .../src/plugins/kubernetes/config.ts | 2 - .../src/plugins/kubernetes/helm/config.ts | 7 + .../src/plugins/kubernetes/helm/handlers.ts | 27 +- .../kubernetes/kubernetes-module/config.ts | 23 +- .../kubernetes/kubernetes-module/handlers.ts | 7 +- .../src/plugins/kubernetes/kubernetes.ts | 92 ++-- .../src/plugins/kubernetes/local/local.ts | 22 +- .../src/plugins/local/local-docker-swarm.ts | 14 +- .../local/local-google-cloud-functions.ts | 14 +- .../maven-container/maven-container.ts | 35 +- garden-service/src/plugins/npm-package.ts | 22 +- garden-service/src/plugins/openfaas/config.ts | 11 - garden-service/src/plugins/openfaas/local.ts | 14 +- .../src/plugins/openfaas/openfaas.ts | 55 +- garden-service/src/plugins/plugins.ts | 49 +- .../src/plugins/terraform/module.ts | 32 +- .../src/plugins/terraform/terraform.ts | 39 +- garden-service/src/tasks/resolve-provider.ts | 2 +- .../src/types/plugin/module/describeType.ts | 88 ---- garden-service/src/types/plugin/plugin.ts | 160 ++++-- garden-service/src/util/util.ts | 11 + garden-service/test/helpers.ts | 107 ++-- garden-service/test/unit/src/actions.ts | 51 +- garden-service/test/unit/src/commands/call.ts | 31 +- .../test/unit/src/commands/delete.ts | 45 +- .../test/unit/src/commands/deploy.ts | 27 +- .../unit/src/commands/get/get-task-result.ts | 18 +- .../unit/src/commands/get/get-test-result.ts | 18 +- .../test/unit/src/commands/publish.ts | 37 +- .../test/unit/src/config/project.ts | 3 + .../test/unit/src/config/provider.ts | 4 +- garden-service/test/unit/src/garden.ts | 487 +++++++++--------- .../unit/src/plugins/container/container.ts | 13 +- .../unit/src/plugins/container/helpers.ts | 6 +- garden-service/test/unit/src/plugins/exec.ts | 2 +- .../unit/src/plugins/invalid-exported-name.ts | 5 - .../test/unit/src/plugins/invalid-name.ts | 6 + .../unit/src/plugins/invalidModuleName.ts | 3 - .../plugins/kubernetes/container/ingress.ts | 6 +- .../plugins/kubernetes/container/service.ts | 2 +- .../{missing-factory.ts => missing-plugin.ts} | 0 49 files changed, 1048 insertions(+), 980 deletions(-) delete mode 100644 garden-service/src/types/plugin/module/describeType.ts delete mode 100644 garden-service/test/unit/src/plugins/invalid-exported-name.ts create mode 100644 garden-service/test/unit/src/plugins/invalid-name.ts delete mode 100644 garden-service/test/unit/src/plugins/invalidModuleName.ts rename garden-service/test/unit/src/plugins/{missing-factory.ts => missing-plugin.ts} (100%) diff --git a/garden-service/src/actions.ts b/garden-service/src/actions.ts index 49062292d1..c1ea141a67 100644 --- a/garden-service/src/actions.ts +++ b/garden-service/src/actions.ts @@ -13,7 +13,7 @@ import { fromPairs, mapValues, omit, pickBy } from "lodash" import { PublishModuleParams, PublishResult } from "./types/plugin/module/publishModule" import { SetSecretParams, SetSecretResult } from "./types/plugin/provider/setSecret" -import { validate, joi } from "./config/common" +import { validate } from "./config/common" import { defaultProvider } from "./config/provider" import { ParameterError, PluginError, ConfigurationError } from "./exceptions" import { ActionHandlerMap, Garden, ModuleActionHandlerMap, ModuleActionMap, PluginActionMap } from "./garden" @@ -37,17 +37,17 @@ import { TestModuleParams } from "./types/plugin/module/testModule" import { ModuleActionOutputs, ModuleActionParams, - ModuleActions, - ModuleAndRuntimeActions, + ModuleActionHandlers, + ModuleAndRuntimeActionHandlers, PluginActionOutputs, PluginActionParams, - PluginActions, + PluginActionHandlers, ServiceActionOutputs, ServiceActionParams, - ServiceActions, + ServiceActionHandlers, TaskActionOutputs, TaskActionParams, - TaskActions, + TaskActionHandlers, moduleActionDescriptions, moduleActionNames, pluginActionDescriptions, @@ -120,24 +120,29 @@ export class ActionHelper implements TypeGuard { private readonly actionHandlers: PluginActionMap private readonly moduleActionHandlers: ModuleActionMap - constructor(private garden: Garden, plugins: { [key: string]: GardenPlugin }) { + constructor(private garden: Garden, plugins: GardenPlugin[]) { this.actionHandlers = fromPairs(pluginActionNames.map(n => [n, {}])) this.moduleActionHandlers = fromPairs(moduleActionNames.map(n => [n, {}])) - for (const [name, plugin] of Object.entries(plugins)) { - const actions = plugin.actions || {} + for (const plugin of plugins) { + const handlers = plugin.handlers || {} for (const actionType of pluginActionNames) { - const handler = actions[actionType] - handler && this.addActionHandler(name, plugin, actionType, handler) + const handler = handlers[actionType] + handler && this.addActionHandler(plugin, actionType, handler) } - const moduleActions = plugin.moduleActions || {} + for (const spec of plugin.createModuleTypes || []) { + for (const actionType of moduleActionNames) { + const handler = spec.handlers[actionType] + handler && this.addModuleActionHandler(plugin, actionType, spec.name, handler) + } + } - for (const moduleType of Object.keys(moduleActions)) { + for (const spec of plugin.extendModuleTypes || []) { for (const actionType of moduleActionNames) { - const handler = moduleActions[moduleType][actionType] - handler && this.addModuleActionHandler(name, plugin, actionType, moduleType, handler) + const handler = spec.handlers[actionType] + handler && this.addModuleActionHandler(plugin, actionType, spec.name, handler) } } } @@ -206,20 +211,6 @@ export class ActionHelper implements TypeGuard { //region Module Actions //=========================================================================== - async describeType(moduleType: string) { - const handler = await this.getModuleActionHandler({ - actionType: "describeType", - moduleType, - defaultHandler: async ({ }) => ({ - docs: "", - moduleOutputsSchema: joi.object().options({ allowUnknown: true }), - schema: joi.object().options({ allowUnknown: true }), - }), - }) - - return handler({}) - } - async getBuildStatus( params: ModuleActionHelperParams>, ): Promise { @@ -454,13 +445,13 @@ export class ActionHelper implements TypeGuard { } } - private async callActionHandler>( + private async callActionHandler>( { params, actionType, pluginName, defaultHandler }: { params: ActionHelperParams, actionType: T, pluginName: string, - defaultHandler?: PluginActions[T], + defaultHandler?: PluginActionHandlers[T], }, ): Promise { this.garden.log.silly(`Calling '${actionType}' handler on '${pluginName}'`) @@ -478,9 +469,13 @@ export class ActionHelper implements TypeGuard { return result } - private async callModuleHandler>( + private async callModuleHandler>( { params, actionType, defaultHandler }: - { params: ModuleActionHelperParams, actionType: T, defaultHandler?: ModuleActions[T] }, + { + params: ModuleActionHelperParams, + actionType: T, + defaultHandler?: ModuleActionHandlers[T], + }, ): Promise { const { module, pluginName, log } = params @@ -490,7 +485,7 @@ export class ActionHelper implements TypeGuard { moduleType: module.type, actionType, pluginName, - defaultHandler: defaultHandler as ModuleAndRuntimeActions[T], + defaultHandler: defaultHandler as ModuleAndRuntimeActionHandlers[T], }) const handlerParams = { @@ -505,9 +500,13 @@ export class ActionHelper implements TypeGuard { return (handler)(handlerParams) } - private async callServiceHandler( + private async callServiceHandler( { params, actionType, defaultHandler }: - { params: ServiceActionHelperParams, actionType: T, defaultHandler?: ServiceActions[T] }, + { + params: ServiceActionHelperParams, + actionType: T, + defaultHandler?: ServiceActionHandlers[T], + }, ): Promise { let { log, service, runtimeContext } = params let module = omit(service.module, ["_ConfigType"]) @@ -518,7 +517,7 @@ export class ActionHelper implements TypeGuard { moduleType: module.type, actionType, pluginName: params.pluginName, - defaultHandler: defaultHandler as ModuleAndRuntimeActions[T], + defaultHandler: defaultHandler as ModuleAndRuntimeActionHandlers[T], }) // Resolve ${runtime.*} template strings if needed. @@ -552,11 +551,11 @@ export class ActionHelper implements TypeGuard { return (handler)(handlerParams) } - private async callTaskHandler( + private async callTaskHandler( { params, actionType, defaultHandler }: { params: TaskActionHelperParams, actionType: T, - defaultHandler?: TaskActions[T], + defaultHandler?: TaskActionHandlers[T], }, ): Promise { let { task, log } = params @@ -569,7 +568,7 @@ export class ActionHelper implements TypeGuard { moduleType: module.type, actionType, pluginName: params.pluginName, - defaultHandler: defaultHandler as ModuleAndRuntimeActions[T], + defaultHandler: defaultHandler as ModuleAndRuntimeActionHandlers[T], }) // Resolve ${runtime.*} template strings if needed. @@ -603,9 +602,10 @@ export class ActionHelper implements TypeGuard { return (handler)(handlerParams) } - private addActionHandler( - pluginName: string, plugin: GardenPlugin, actionType: T, handler: PluginActions[T], + private addActionHandler( + plugin: GardenPlugin, actionType: T, handler: PluginActionHandlers[T], ) { + const pluginName = plugin.name const schema = pluginActionDescriptions[actionType].resultSchema const wrapped = async (...args) => { @@ -627,9 +627,10 @@ export class ActionHelper implements TypeGuard { typeHandlers[pluginName] = wrapped } - private addModuleActionHandler( - pluginName: string, plugin: GardenPlugin, actionType: T, moduleType: string, handler: ModuleActions[T], + private addModuleActionHandler( + plugin: GardenPlugin, actionType: T, moduleType: string, handler: ModuleActionHandlers[T], ) { + const pluginName = plugin.name const schema = moduleActionDescriptions[actionType].resultSchema const wrapped = async (...args: any[]) => { @@ -662,7 +663,7 @@ export class ActionHelper implements TypeGuard { /** * Get a handler for the specified action. */ - public async getActionHandlers( + public async getActionHandlers( actionType: T, pluginName?: string, ): Promise> { return this.filterActionHandlers(this.actionHandlers[actionType], pluginName) @@ -671,7 +672,7 @@ export class ActionHelper implements TypeGuard { /** * Get a handler for the specified module action. */ - public async getModuleActionHandlers( + public async getModuleActionHandlers( { actionType, moduleType, pluginName }: { actionType: T, moduleType: string, pluginName?: string }, ): Promise> { @@ -694,10 +695,10 @@ export class ActionHelper implements TypeGuard { /** * Get the last configured handler for the specified action (and optionally module type). */ - public async getActionHandler( + public async getActionHandler( { actionType, pluginName, defaultHandler }: - { actionType: T, pluginName: string, defaultHandler?: PluginActions[T] }, - ): Promise { + { actionType: T, pluginName: string, defaultHandler?: PluginActionHandlers[T] }, + ): Promise { const handlers = Object.values(await this.getActionHandlers(actionType, pluginName)) @@ -730,10 +731,10 @@ export class ActionHelper implements TypeGuard { /** * Get the last configured handler for the specified action. */ - public async getModuleActionHandler( + public async getModuleActionHandler( { actionType, moduleType, pluginName, defaultHandler }: - { actionType: T, moduleType: string, pluginName?: string, defaultHandler?: ModuleAndRuntimeActions[T] }, - ): Promise { + { actionType: T, moduleType: string, pluginName?: string, defaultHandler?: ModuleAndRuntimeActionHandlers[T] }, + ): Promise { const handlers = Object.values(await this.getModuleActionHandlers({ actionType, moduleType, pluginName })) diff --git a/garden-service/src/config/project.ts b/garden-service/src/config/project.ts index ec5b561e83..318992d07d 100644 --- a/garden-service/src/config/project.ts +++ b/garden-service/src/config/project.ts @@ -26,7 +26,6 @@ import { ProjectConfigContext } from "./config-context" import { findByName, getNames } from "../util/util" import { ConfigurationError, ParameterError } from "../exceptions" import { PrimitiveMap } from "./common" -import { fixedPlugins } from "../plugins/plugins" import { cloneDeep, omit } from "lodash" import { providerConfigBaseSchema, Provider, ProviderConfig } from "./provider" import { DEFAULT_API_VERSION } from "../constants" @@ -37,6 +36,14 @@ import { resolve } from "path" export const defaultVarfilePath = "garden.env" export const defaultEnvVarfilePath = (environmentName: string) => `garden.${environmentName}.env` +// These plugins are always loaded +const fixedPlugins = [ + "exec", + "container", + // TODO: remove this after we've implemented module type inheritance + "maven-container", +] + export interface CommonEnvironmentConfig { providers?: ProviderConfig[] // further validated by each plugin variables: { [key: string]: Primitive } diff --git a/garden-service/src/docs/config.ts b/garden-service/src/docs/config.ts index b95c34ba2c..7a51f8900c 100644 --- a/garden-service/src/docs/config.ts +++ b/garden-service/src/docs/config.ts @@ -14,7 +14,7 @@ import titleize from "titleize" import humanize from "humanize-string" import { resolve } from "path" import { projectSchema, environmentSchema } from "../config/project" -import { get, flatten, startCase, uniq, find } from "lodash" +import { get, flatten, startCase, uniq, keyBy, find } from "lodash" import { baseModuleSpecSchema } from "../config/module" import handlebars = require("handlebars") import { joiArray, joi } from "../config/common" @@ -24,8 +24,7 @@ import { indent, renderMarkdownTable } from "./util" import { ModuleContext, ServiceRuntimeContext, TaskRuntimeContext } from "../config/config-context" import { defaultDotIgnoreFiles } from "../util/fs" import { providerConfigBaseSchema } from "../config/provider" -import { GardenPlugin } from "../types/plugin/plugin" -import { ModuleTypeDescription } from "../types/plugin/module/describeType" +import { GardenPlugin, ModuleTypeDefinition } from "../types/plugin/plugin" export const TEMPLATES_DIR = resolve(GARDEN_SERVICE_ROOT, "src", "docs", "templates") const partialTemplatePath = resolve(TEMPLATES_DIR, "config-partial.hbs") @@ -452,7 +451,7 @@ function renderProviderReference(name: string, plugin: GardenPlugin) { * Generates the module types reference from the module-type.hbs template. * The reference includes the rendered output from the config-partial.hbs template. */ -function renderModuleTypeReference(name: string, desc: ModuleTypeDescription) { +function renderModuleTypeReference(name: string, desc: ModuleTypeDefinition) { let { schema, docs } = desc const moduleTemplatePath = resolve(TEMPLATES_DIR, "module-type.hbs") @@ -570,10 +569,11 @@ export async function writeConfigReferenceDocs(docsRoot: string) { // Render module type docs const moduleTypeDir = resolve(referenceDir, "module-types") const readme = ["# Module Types", ""] + const moduleTypeDefinitions = keyBy(await garden.getModuleTypeDefinitions(), "name") + for (const { name } of moduleTypes) { const path = resolve(moduleTypeDir, `${name}.md`) - const actions = await garden.getActionHelper() - const desc = await actions.describeType(name) + const desc = moduleTypeDefinitions[name] console.log("->", path) writeFileSync(path, renderModuleTypeReference(name, desc)) diff --git a/garden-service/src/garden.ts b/garden-service/src/garden.ts index fb88b334d7..d537cbf6dd 100644 --- a/garden-service/src/garden.ts +++ b/garden-service/src/garden.ts @@ -7,7 +7,7 @@ */ import Bluebird = require("bluebird") -import { parse, relative, resolve, sep, dirname } from "path" +import { parse, relative, resolve, dirname } from "path" import { flatten, isString, cloneDeep, sortBy, set, fromPairs, keyBy } from "lodash" const AsyncLock = require("async-lock") @@ -16,7 +16,7 @@ import { builtinPlugins } from "./plugins/plugins" import { Module, getModuleCacheContext, getModuleKey, ModuleConfigMap } from "./types/module" import { pluginModuleSchema, pluginSchema } from "./types/plugin/plugin" import { SourceConfig, ProjectConfig, resolveProjectConfig, pickEnvironment } from "./config/project" -import { findByName, pickKeys, getPackageVersion } from "./util/util" +import { findByName, pickKeys, getPackageVersion, pushToKey } from "./util/util" import { ConfigurationError, PluginError, RuntimeError } from "./exceptions" import { VcsHandler, ModuleVersion } from "./vcs/vcs" import { GitHandler } from "./vcs/git" @@ -24,8 +24,8 @@ import { BuildDir } from "./build-dir" import { ConfigGraph } from "./config-graph" import { TaskGraph, TaskResults, ProcessTasksOpts } from "./task-graph" import { getLogger } from "./logger/logger" -import { PluginActions, PluginFactory, GardenPlugin } from "./types/plugin/plugin" -import { joiIdentifier, validate, PrimitiveMap, validateWithPath } from "./config/common" +import { PluginActionHandlers, GardenPlugin } from "./types/plugin/plugin" +import { validate, PrimitiveMap, validateWithPath } from "./config/common" import { resolveTemplateStrings } from "./template-string" import { loadConfig, findProjectConfig } from "./config/base" import { BaseTask } from "./tasks/base" @@ -34,7 +34,7 @@ import { getLinkedSources, ExternalSourceType } from "./util/ext-source-util" import { BuildDependencyConfig, ModuleConfig, ModuleResource, moduleConfigSchema } from "./config/module" import { ModuleConfigContext, ContextResolveOpts } from "./config/config-context" import { createPluginContext, CommandInfo } from "./plugin-context" -import { ModuleAndRuntimeActions, Plugins, RegisterPluginParam } from "./types/plugin/plugin" +import { ModuleAndRuntimeActionHandlers, RegisterPluginParam } from "./types/plugin/plugin" import { SUPPORTED_PLATFORMS, SupportedPlatform, DEFAULT_GARDEN_DIR_NAME } from "./constants" import { platform, arch } from "os" import { LogEntry } from "./logger/log-entry" @@ -47,25 +47,26 @@ import { ActionHelper } from "./actions" import { DependencyGraph, detectCycles, cyclesToString } from "./util/validate-dependencies" import chalk from "chalk" import { RuntimeContext } from "./runtime-context" +import { deline } from "./util/string" -export interface ActionHandlerMap { - [actionName: string]: PluginActions[T] +export interface ActionHandlerMap { + [actionName: string]: PluginActionHandlers[T] } -export interface ModuleActionHandlerMap { - [actionName: string]: ModuleAndRuntimeActions[T] +export interface ModuleActionHandlerMap { + [actionName: string]: ModuleAndRuntimeActionHandlers[T] } export type PluginActionMap = { - [A in keyof PluginActions]: { - [pluginName: string]: PluginActions[A], + [A in keyof PluginActionHandlers]: { + [pluginName: string]: PluginActionHandlers[A], } } export type ModuleActionMap = { - [A in keyof ModuleAndRuntimeActions]: { + [A in keyof ModuleAndRuntimeActionHandlers]: { [moduleType: string]: { - [pluginName: string]: ModuleAndRuntimeActions[A], + [pluginName: string]: ModuleAndRuntimeActionHandlers[A], }, } } @@ -77,7 +78,7 @@ export interface GardenOpts { environmentName?: string, persistent?: boolean, log?: LogEntry, - plugins?: Plugins, + plugins?: RegisterPluginParam[], } interface ModuleConfigResolveOpts extends ContextResolveOpts { @@ -93,7 +94,7 @@ export interface GardenParams { moduleIncludePatterns?: string[] moduleExcludePatterns?: string[] opts: GardenOpts - plugins: Plugins + plugins: RegisterPluginParam[] projectName: string projectRoot: string projectSources?: SourceConfig[] @@ -105,12 +106,12 @@ export interface GardenParams { export class Garden { public readonly log: LogEntry - private loadedPlugins: { [key: string]: GardenPlugin } + private loadedPlugins: GardenPlugin[] private moduleConfigs: ModuleConfigMap private pluginModuleConfigs: ModuleConfig[] private resolvedProviders: Provider[] private modulesScanned: boolean - private readonly registeredPlugins: { [key: string]: PluginFactory } + private readonly registeredPlugins: { [key: string]: GardenPlugin } private readonly taskGraph: TaskGraph private watcher: Watcher private asyncLock: any @@ -182,16 +183,15 @@ export class Garden { this.events = new EventBus(this.log) // Register plugins - for (const [name, pluginFactory] of Object.entries({ ...builtinPlugins, ...params.plugins })) { - // This cast is required for the linter to accept the instance type hackery. - this.registerPlugin(name, pluginFactory) + for (const plugin of [...builtinPlugins, ...params.plugins]) { + this.registerPlugin(plugin) } } static async factory( this: T, currentDirectory: string, opts: GardenOpts = {}, ): Promise> { - let { environmentName, config, gardenDirPath, plugins = {} } = opts + let { environmentName, config, gardenDirPath, plugins = [] } = opts if (!config) { config = await findProjectConfig(currentDirectory) @@ -288,15 +288,11 @@ export class Garden { this.watcher = new Watcher(this, this.log, paths, modules, bufferInterval) } - private registerPlugin(name: string, moduleOrFactory: RegisterPluginParam) { - let factory: PluginFactory - - if (typeof moduleOrFactory === "function") { - factory = moduleOrFactory + private registerPlugin(nameOrPlugin: RegisterPluginParam) { + let plugin: GardenPlugin - } else if (isString(moduleOrFactory)) { - let moduleNameOrLocation = moduleOrFactory - const parsedLocation = parse(moduleNameOrLocation) + if (isString(nameOrPlugin)) { + let moduleNameOrLocation = nameOrPlugin // allow relative references to project root if (parse(moduleNameOrLocation).dir !== "") { @@ -321,19 +317,6 @@ export class Garden { pluginModuleSchema, { context: `plugin module "${moduleNameOrLocation}"` }, ) - - if (pluginModule.name) { - name = pluginModule.name - } else { - if (parsedLocation.name === "index") { - // use parent directory name - name = parsedLocation.dir.split(sep).slice(-1)[0] - } else { - name = parsedLocation.name - } - } - - validate(name, joiIdentifier(), { context: `name of plugin "${moduleNameOrLocation}"` }) } catch (err) { throw new PluginError(`Unable to load plugin: ${err}`, { moduleNameOrLocation, @@ -341,40 +324,26 @@ export class Garden { }) } - factory = pluginModule.gardenPlugin + plugin = pluginModule.gardenPlugin } else { - throw new TypeError(`Expected plugin factory function, module name or module path`) + plugin = nameOrPlugin } - this.registeredPlugins[name] = factory + this.registeredPlugins[plugin.name] = plugin } private async loadPlugin(pluginName: string) { this.log.silly(`Loading plugin ${pluginName}`) - const factory = this.registeredPlugins[pluginName] + let plugin = this.registeredPlugins[pluginName] - if (!factory) { + if (!plugin) { throw new ConfigurationError(`Configured plugin '${pluginName}' has not been registered`, { name: pluginName, availablePlugins: Object.keys(this.registeredPlugins), }) } - let plugin: GardenPlugin - - try { - plugin = await factory({ - projectName: this.projectName, - log: this.log, - }) - } catch (error) { - throw new PluginError(`Unexpected error when loading plugin "${pluginName}": ${error}`, { - pluginName, - error, - }) - } - plugin = validate(plugin, pluginSchema, { context: `plugin "${pluginName}"` }) this.log.silly(`Done loading plugin ${pluginName}`) @@ -382,9 +351,9 @@ export class Garden { return plugin } - async getPlugin(pluginName: string) { + async getPlugin(pluginName: string): Promise { const plugins = await this.getPlugins() - const plugin = plugins[pluginName] + const plugin = findByName(plugins, pluginName) if (!plugin) { throw new PluginError(`Could not find plugin '${pluginName}'. Are you missing a provider configuration?`, { @@ -404,19 +373,73 @@ export class Garden { this.log.silly(`Loading plugins`) const rawConfigs = this.getRawProviderConfigs() - const plugins = {} + const loadedPlugins: GardenPlugin[] = [] + + const moduleDeclarations: { [moduleType: string]: GardenPlugin[] } = {} + const moduleExtensions: { [moduleType: string]: GardenPlugin[] } = {} await Bluebird.map(rawConfigs, async (config) => { - plugins[config.name] = await this.loadPlugin(config.name) + const plugin = await this.loadPlugin(config.name) + loadedPlugins.push(plugin) + + for (const spec of plugin.createModuleTypes || []) { + pushToKey(moduleDeclarations, spec.name, plugin) + } + + for (const spec of plugin.extendModuleTypes || []) { + pushToKey(moduleExtensions, spec.name, plugin) + } }) - this.loadedPlugins = plugins - this.log.silly(`Loaded plugins: ${Object.keys(plugins).join(", ")}`) + // Make sure only one plugin declares each module type + for (const [moduleType, plugins] of Object.entries(moduleDeclarations)) { + if (plugins.length > 1) { + throw new ConfigurationError( + `Module type '${moduleType}' is declared in multiple providers: ${plugins.map(p => p.name).join(", ")}.`, + { moduleType, plugins: plugins.map(p => p.name) }, + ) + } + } + + // Make sure plugins that extend module types correctly declare their dependencies + for (const [moduleType, plugins] of Object.entries(moduleExtensions)) { + const declaredBy = moduleDeclarations[moduleType] && moduleDeclarations[moduleType][0] + + for (const plugin of plugins) { + if (!declaredBy) { + throw new PluginError(deline` + Plugin '${plugin.name}' extends module type '${moduleType}' but the module type has not been declared. + The '${plugin.name}' plugin is likely missing a dependency declaration. + Please report an issue with the author. + `, + { moduleType, pluginName: plugin.name }, + ) + } + + if (!plugin.dependencies || !plugin.dependencies.includes(declaredBy.name)) { + throw new PluginError(deline` + Plugin '${plugin.name}' extends module type '${moduleType}', declared by the '${declaredBy.name}' plugin, + but does not specify a dependency on that plugin. Plugins must explicitly declare dependencies on plugins + that define module types they reference. Please report an issue with the author. + `, + { moduleType, pluginName: plugin.name, declaredBy: declaredBy.name }, + ) + } + } + } + + this.loadedPlugins = loadedPlugins + this.log.silly(`Loaded plugins: ${Object.keys(loadedPlugins).join(", ")}`) }) return this.loadedPlugins } + async getModuleTypeDefinitions() { + const plugins = await this.getPlugins() + return flatten(plugins.map(p => p.createModuleTypes || [])) + } + getRawProviderConfigs() { return this.providerConfigs } @@ -448,13 +471,13 @@ export class Garden { const rawConfigs = this.getRawProviderConfigs() const configsByName = keyBy(rawConfigs, "name") - const plugins = Object.entries(await this.getPlugins()) + const plugins = await this.getPlugins() // Detect circular deps here const pluginGraph: DependencyGraph = {} - await Bluebird.map(plugins, async ([name, plugin]) => { - const config = configsByName[name] + await Bluebird.map(plugins, async (plugin) => { + const config = configsByName[plugin.name] for (const dep of await getProviderDependencies(plugin!, config!)) { set(pluginGraph, [config!.name, dep], { distance: 1, next: dep }) } @@ -471,7 +494,7 @@ export class Garden { ) } - const tasks = plugins.map(([name, plugin]) => { + const tasks = plugins.map((plugin) => { // TODO: actually resolve version, based on the VCS version of the plugin and its dependencies const version = { versionString: getPackageVersion(), @@ -481,7 +504,7 @@ export class Garden { files: [], } - const config = configsByName[name] + const config = configsByName[plugin.name] return new ResolveProviderTask({ garden: this, @@ -585,9 +608,21 @@ export class Garden { opts.configContext = await this.getModuleConfigContext() } + const moduleTypeDefinitions = keyBy(await this.getModuleTypeDefinitions(), "name") + return Bluebird.map(configs, async (config) => { config = await resolveTemplateStrings(cloneDeep(config), opts.configContext!, opts) - const description = await actions.describeType(config.type) + const description = moduleTypeDefinitions[config.type] + + if (!description) { + throw new ConfigurationError(deline` + Unrecognized module type '${config.type}' + (defined at ${relative(this.projectRoot, config.configPath || config.path)}). + Are you missing a provider configuration? + `, + { config, configuredModuleTypes: Object.keys(moduleTypeDefinitions) }, + ) + } // Validate the module-type specific spec config.spec = validateWithPath({ diff --git a/garden-service/src/plugins/container/container.ts b/garden-service/src/plugins/container/container.ts index d9e198ac75..55b63ae62c 100644 --- a/garden-service/src/plugins/container/container.ts +++ b/garden-service/src/plugins/container/container.ts @@ -10,7 +10,7 @@ import dedent = require("dedent") import { keyBy } from "lodash" import { ConfigurationError } from "../../exceptions" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { containerHelpers } from "./helpers" import { ContainerModule, containerModuleSpecSchema } from "./config" import { buildContainerModule, getContainerBuildStatus } from "./build" @@ -154,36 +154,33 @@ export async function configureContainerModule({ ctx, moduleConfig }: ConfigureM return moduleConfig } -export const gardenPlugin = (): GardenPlugin => ({ - moduleActions: { - container: { - describeType, - configure: configureContainerModule, - getBuildStatus: getContainerBuildStatus, - build: buildContainerModule, - publish: publishContainerModule, - - async hotReloadService(_: HotReloadServiceParams) { - return {} +export const gardenPlugin = createGardenPlugin({ + name: "container", + 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. + + Note that the runtime services have somewhat limited features in this module type. For example, you cannot + specify replicas for redundancy, and various platform-specific options are not included. For those, look at + other module types like [helm](https://docs.garden.io/reference/module-types/helm) or + [kubernetes](https://github.com/garden-io/garden/blob/master/docs/reference/module-types/kubernetes.md). + `, + moduleOutputsSchema: containerModuleOutputsSchema, + schema: containerModuleSpecSchema, + taskOutputsSchema, + handlers: { + configure: configureContainerModule, + getBuildStatus: getContainerBuildStatus, + build: buildContainerModule, + publish: publishContainerModule, + + async hotReloadService(_: HotReloadServiceParams) { + return {} + }, }, - }, - }, + ], }) - -async function describeType() { - return { - 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. - - Note that the runtime services have somewhat limited features in this module type. For example, you cannot - specify replicas for redundancy, and various platform-specific options are not included. For those, look at - other module types like [helm](https://docs.garden.io/reference/module-types/helm) or - [kubernetes](https://github.com/garden-io/garden/blob/master/docs/reference/module-types/kubernetes.md). - `, - moduleOutputsSchema: containerModuleOutputsSchema, - schema: containerModuleSpecSchema, - taskOutputsSchema, - } -} diff --git a/garden-service/src/plugins/exec.ts b/garden-service/src/plugins/exec.ts index 94e8f8f228..e1bd263c5f 100644 --- a/garden-service/src/plugins/exec.ts +++ b/garden-service/src/plugins/exec.ts @@ -9,7 +9,7 @@ import { mapValues } from "lodash" import { join } from "path" import { joiArray, joiEnvVars, validateWithPath, joi } from "../config/common" -import { GardenPlugin } from "../types/plugin/plugin" +import { createGardenPlugin } from "../types/plugin/plugin" import { Module } from "../types/module" import { CommonServiceSpec } from "../config/service" import { BaseTestSpec, baseTestSpecSchema } from "../config/test" @@ -26,8 +26,6 @@ import { TestModuleParams } from "../types/plugin/module/testModule" import { TestResult } from "../types/plugin/module/getTestResult" import { RunTaskParams, RunTaskResult } from "../types/plugin/task/runTask" -export const name = "exec" - export interface ExecTestSpec extends BaseTestSpec { command: string[], env: { [key: string]: string }, @@ -229,8 +227,10 @@ export async function runExecTask(params: RunTaskParams): Promise } } -async function describeType() { - return { +export const execPlugin = createGardenPlugin({ + name: "exec", + 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.). @@ -247,20 +247,14 @@ async function describeType() { "(Pro-tip: Make it machine readable so it can be parsed by dependant tasks and services!)", ), }), - } -} - -export const execPlugin: GardenPlugin = { - moduleActions: { - exec: { - describeType, + handlers: { configure: configureExecModule, getBuildStatus: getExecModuleBuildStatus, build: buildExecModule, runTask: runExecTask, testModule: testExecModule, }, - }, -} + }], +}) -export const gardenPlugin = () => execPlugin +export const gardenPlugin = execPlugin diff --git a/garden-service/src/plugins/google/google-app-engine.ts b/garden-service/src/plugins/google/google-app-engine.ts index 9c3dab9041..f188063748 100644 --- a/garden-service/src/plugins/google/google-app-engine.ts +++ b/garden-service/src/plugins/google/google-app-engine.ts @@ -15,7 +15,7 @@ import { prepareEnvironment, } from "./common" import { dumpYaml } from "../../util/util" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { configureContainerModule } from "../container/container" import { ContainerModule } from "../container/config" import { providerConfigBaseSchema } from "../../config/provider" @@ -29,14 +29,16 @@ const configSchema = providerConfigBaseSchema.keys({ .description("The GCP project to deploy containers to."), }) -export const gardenPlugin = (): GardenPlugin => ({ +export const gardenPlugin = createGardenPlugin({ + name: "google-app-engine", configSchema, - actions: { + handlers: { getEnvironmentStatus, prepareEnvironment, }, - moduleActions: { - container: { + extendModuleTypes: [{ + name: "container", + handlers: { async configure(params: ConfigureModuleParams) { const config = await configureContainerModule(params) @@ -107,5 +109,5 @@ export const gardenPlugin = (): GardenPlugin => ({ return { state: "ready", detail: {} } }, }, - }, + }], }) diff --git a/garden-service/src/plugins/google/google-cloud-functions.ts b/garden-service/src/plugins/google/google-cloud-functions.ts index 43b82768ea..41bd60df84 100644 --- a/garden-service/src/plugins/google/google-cloud-functions.ts +++ b/garden-service/src/plugins/google/google-cloud-functions.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { joiArray, validateWithPath, joi } from "../../config/common" +import { joiArray, joi } from "../../config/common" import { Module } from "../../types/module" import { ServiceState, ServiceStatus, ingressHostnameSchema, Service } from "../../types/service" import { resolve } from "path" @@ -17,7 +17,7 @@ import { getEnvironmentStatus, GOOGLE_CLOUD_DEFAULT_REGION, } from "./common" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { baseServiceSpecSchema, CommonServiceSpec } from "../../config/service" import { Provider, providerConfigBaseSchema } from "../../config/provider" import { ConfigureModuleParams, ConfigureModuleResult } from "../../types/plugin/module/configure" @@ -69,15 +69,6 @@ export async function configureGcfModule( endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${name}`, } - // TODO: check that each function exists at the specified path - moduleConfig.spec = validateWithPath({ - config: moduleConfig.spec, - schema: gcfModuleSpecSchema, - name: moduleConfig.name, - path: moduleConfig.path, - projectRoot: ctx.projectRoot, - }) - moduleConfig.serviceConfigs = [{ name, dependencies: spec.dependencies, @@ -100,14 +91,18 @@ const configSchema = providerConfigBaseSchema.keys({ .description("The default GCP project to deploy functions to (can be overridden on individual functions)."), }) -export const gardenPlugin = (): GardenPlugin => ({ +export const gardenPlugin = createGardenPlugin({ + name: "google-cloud-function", configSchema, - actions: { + handlers: { getEnvironmentStatus, prepareEnvironment, }, - moduleActions: { - "google-cloud-function": { + createModuleTypes: [{ + name: "google-cloud-function", + docs: "(TODO)", + schema: gcfModuleSpecSchema, + handlers: { configure: configureGcfModule, async deployService(params: DeployServiceParams) { @@ -130,7 +125,7 @@ export const gardenPlugin = (): GardenPlugin => ({ return getServiceStatus(params) }, }, - }, + }], }) export async function getServiceStatus( diff --git a/garden-service/src/plugins/kubernetes/config.ts b/garden-service/src/plugins/kubernetes/config.ts index 05d77977d8..4dd5a6b88e 100644 --- a/garden-service/src/plugins/kubernetes/config.ts +++ b/garden-service/src/plugins/kubernetes/config.ts @@ -14,8 +14,6 @@ import { containerRegistryConfigSchema, ContainerRegistryConfig } from "../conta import { PluginContext } from "../../plugin-context" import { deline } from "../../util/string" -export const name = "kubernetes" - export interface ProviderSecretRef { name: string namespace: string diff --git a/garden-service/src/plugins/kubernetes/helm/config.ts b/garden-service/src/plugins/kubernetes/helm/config.ts index a460763900..521fce60c4 100644 --- a/garden-service/src/plugins/kubernetes/helm/config.ts +++ b/garden-service/src/plugins/kubernetes/helm/config.ts @@ -160,6 +160,13 @@ const parameterValueSchema = joi.alternatives( joi.object().pattern(/.+/, joi.lazy(() => parameterValueSchema)), ) +export const helmModuleOutputsSchema = joi.object() + .keys({ + "release-name": joi.string() + .required() + .description("The Helm release name of the service."), + }) + export const helmModuleSpecSchema = joi.object().keys({ base: joiUserIdentifier() .description( diff --git a/garden-service/src/plugins/kubernetes/helm/handlers.ts b/garden-service/src/plugins/kubernetes/helm/handlers.ts index e05acfc8ad..8e0ba99541 100644 --- a/garden-service/src/plugins/kubernetes/helm/handlers.ts +++ b/garden-service/src/plugins/kubernetes/helm/handlers.ts @@ -6,8 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { ModuleAndRuntimeActions } from "../../../types/plugin/plugin" -import { HelmModule, validateHelmModule as configureHelmModule, helmModuleSpecSchema } from "./config" +import { ModuleAndRuntimeActionHandlers } from "../../../types/plugin/plugin" +import { HelmModule, validateHelmModule as configureHelmModule } from "./config" import { buildHelmModule } from "./build" import { getServiceStatus } from "./status" import { deployService, deleteService } from "./deployment" @@ -16,35 +16,14 @@ import { runHelmTask, runHelmModule } from "./run" import { hotReloadHelmChart } from "./hot-reload" import { getServiceLogs } from "./logs" import { testHelmModule } from "./test" -import { dedent } from "../../../util/string" -import { joi } from "../../../config/common" import { getPortForwardHandler } from "../port-forward" -const helmModuleOutputsSchema = joi.object() - .keys({ - "release-name": joi.string() - .required() - .description("The Helm release name of the service."), - }) - -async function describeType() { - return { - docs: dedent` - Specify a Helm chart (either in your repository or remote from a registry) to deploy. - Refer to the [Helm guide](https://docs.garden.io/using-garden/using-helm-charts) for usage instructions. - `, - moduleOutputsSchema: helmModuleOutputsSchema, - schema: helmModuleSpecSchema, - } -} - -export const helmHandlers: Partial> = { +export const helmHandlers: Partial> = { build: buildHelmModule, configure: configureHelmModule, // TODO: add execInService handler deleteService, deployService, - describeType, getPortForward: getPortForwardHandler, getServiceLogs, getServiceStatus, diff --git a/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts b/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts index ad1aafa222..1a7fe56f0f 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts @@ -14,7 +14,7 @@ import { Service } from "../../../types/service" import { ContainerModule } from "../../container/config" import { baseBuildSpecSchema } from "../../../config/module" import { KubernetesResource } from "../types" -import { deline, dedent } from "../../../util/string" +import { deline } from "../../../util/string" // A Kubernetes Module always maps to a single Service export type KubernetesModuleSpec = KubernetesServiceSpec @@ -49,7 +49,7 @@ const kubernetesResourceSchema = joi.object() }) .unknown(true) -const kubernetesModuleSpecSchema = joi.object() +export const kubernetesModuleSpecSchema = joi.object() .keys({ build: baseBuildSpecSchema, dependencies: dependenciesSchema, @@ -62,25 +62,6 @@ const kubernetesModuleSpecSchema = joi.object() .description("POSIX-style paths to YAML files to load manifests from. Each can contain multiple manifests."), }) -export async function describeType() { - return { - 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 - one or more files with existing manifests. - - Note that if you include the manifests in the \`garden.yml\` file, you can use - [template strings](https://docs.garden.io/reference/template-strings) to interpolate values into the manifests. - - If you need more advanced templating features you can use the - [helm](https://docs.garden.io/reference/module-types/helm) module type. - `, - moduleOutputsSchema: joi.object().keys({}), - schema: kubernetesModuleSpecSchema, - } -} - export async function configureKubernetesModule({ moduleConfig }: ConfigureModuleParams) : Promise> { moduleConfig.serviceConfigs = [{ diff --git a/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts b/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts index 373627657e..d9817965a1 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes-module/handlers.ts @@ -12,14 +12,14 @@ import Bluebird from "bluebird" import { flatten, set, uniq } from "lodash" import { safeLoadAll } from "js-yaml" -import { KubernetesModule, configureKubernetesModule, KubernetesService, describeType } from "./config" +import { KubernetesModule, configureKubernetesModule, KubernetesService } from "./config" import { getNamespace, getAppNamespace } from "../namespace" import { KubernetesPluginContext } from "../config" import { KubernetesResource, KubernetesServerResource } from "../types" import { ServiceStatus } from "../../../types/service" import { compareDeployedObjects, waitForResources } from "../status/status" import { KubeApi } from "../api" -import { ModuleAndRuntimeActions } from "../../../types/plugin/plugin" +import { ModuleAndRuntimeActionHandlers } from "../../../types/plugin/plugin" import { getAllLogs } from "../logs" import { deleteObjectsBySelector, apply } from "../kubectl" import { BuildModuleParams, BuildResult } from "../../../types/plugin/module/build" @@ -31,12 +31,11 @@ import { gardenAnnotationKey } from "../../../util/string" import { getForwardablePorts, getPortForwardHandler } from "../port-forward" import { LogEntry } from "../../../logger/log-entry" -export const kubernetesHandlers: Partial> = { +export const kubernetesHandlers: Partial> = { build, configure: configureKubernetesModule, deleteService, deployService, - describeType, getPortForward: getPortForwardHandler, getServiceLogs, getServiceStatus, diff --git a/garden-service/src/plugins/kubernetes/kubernetes.ts b/garden-service/src/plugins/kubernetes/kubernetes.ts index c46d53582f..79c5ab10b3 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes.ts @@ -8,7 +8,7 @@ import Bluebird from "bluebird" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { helmHandlers } from "./helm/handlers" import { getAppNamespace, getMetadataNamespace } from "./namespace" import { getSecret, setSecret, deleteSecret } from "./secrets" @@ -28,8 +28,9 @@ import { uninstallGardenServices } from "./commands/uninstall-garden-services" import chalk from "chalk" import { joi, joiIdentifier } from "../../config/common" import { resolve } from "path" - -export const name = "kubernetes" +import { dedent } from "../../util/string" +import { kubernetesModuleSpecSchema } from "./kubernetes-module/config" +import { helmModuleSpecSchema, helmModuleOutputsSchema } from "./helm/config" export async function configureProvider( { projectName, projectRoot, config }: ConfigureProviderParams, @@ -119,31 +120,64 @@ const outputsSchema = joi.object() .description("The namespace used for Garden metadata."), }) -export function gardenPlugin(): GardenPlugin { - return { - configSchema, - outputsSchema, - commands: [ - cleanupClusterRegistry, - clusterInit, - uninstallGardenServices, - ], - actions: { - configureProvider, - getEnvironmentStatus, - prepareEnvironment, - cleanupEnvironment, - getSecret, - setSecret, - deleteSecret, - getDebugInfo: debugInfo, +export const gardenPlugin = createGardenPlugin({ + name: "kubernetes", + dependencies: ["container", "maven-container"], + configSchema, + outputsSchema, + commands: [ + cleanupClusterRegistry, + clusterInit, + uninstallGardenServices, + ], + 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](https://docs.garden.io/using-garden/using-helm-charts) for usage instructions. + `, + moduleOutputsSchema: helmModuleOutputsSchema, + schema: helmModuleSpecSchema, + handlers: helmHandlers, }, - moduleActions: { - "container": containerHandlers, - // TODO: we should find a way to avoid having to explicitly specify the key here - "maven-container": mavenContainerHandlers, - "helm": helmHandlers, - "kubernetes": kubernetesHandlers, + { + 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 + one or more files with existing manifests. + + Note that if you include the manifests in the \`garden.yml\` file, you can use + [template strings](https://docs.garden.io/reference/template-strings) to interpolate values into the manifests. + + If you need more advanced templating features you can use the + [helm](https://docs.garden.io/reference/module-types/helm) module type. + `, + moduleOutputsSchema: joi.object().keys({}), + schema: kubernetesModuleSpecSchema, + handlers: kubernetesHandlers, }, - } -} + ], + extendModuleTypes: [ + { + name: "container", + handlers: containerHandlers, + }, + { + name: "maven-container", + handlers: mavenContainerHandlers, + }, + ], +}) diff --git a/garden-service/src/plugins/kubernetes/local/local.ts b/garden-service/src/plugins/kubernetes/local/local.ts index 6466d66117..7502c33351 100644 --- a/garden-service/src/plugins/kubernetes/local/local.ts +++ b/garden-service/src/plugins/kubernetes/local/local.ts @@ -6,18 +6,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GardenPlugin } from "../../../types/plugin/plugin" import { gardenPlugin as k8sPlugin } from "../kubernetes" import { configureProvider, configSchema } from "./config" +import { createGardenPlugin } from "../../../types/plugin/plugin" -export const name = "local-kubernetes" - -export function gardenPlugin(): GardenPlugin { - const plugin = k8sPlugin() - - plugin.configSchema = configSchema - - plugin.actions!.configureProvider = configureProvider - - return plugin -} +export const gardenPlugin = createGardenPlugin({ + ...k8sPlugin, + name: "local-kubernetes", + configSchema, + handlers: { + ...k8sPlugin.handlers!, + configureProvider, + }, +}) diff --git a/garden-service/src/plugins/local/local-docker-swarm.ts b/garden-service/src/plugins/local/local-docker-swarm.ts index f0ef3c1603..b71696cd8f 100644 --- a/garden-service/src/plugins/local/local-docker-swarm.ts +++ b/garden-service/src/plugins/local/local-docker-swarm.ts @@ -10,7 +10,7 @@ import Docker from "dockerode" import { exec } from "child-process-promise" import { DeploymentError } from "../../exceptions" import { PluginContext } from "../../plugin-context" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { ContainerModule } from "../container/config" import { map, sortBy } from "lodash" import { sleep } from "../../util/util" @@ -26,13 +26,15 @@ const DEPLOY_TIMEOUT = 30 const pluginName = "local-docker-swarm" -export const gardenPlugin = (): GardenPlugin => ({ - actions: { +export const gardenPlugin = createGardenPlugin({ + name: pluginName, + handlers: { getEnvironmentStatus, prepareEnvironment, }, - moduleActions: { - container: { + extendModuleTypes: [{ + name: "container", + handlers: { getServiceStatus, async deployService( @@ -214,7 +216,7 @@ export const gardenPlugin = (): GardenPlugin => ({ return { code: 0, output: "", stdout: res.stdout, stderr: res.stderr } }, }, - }, + }], }) async function getEnvironmentStatus(): Promise { diff --git a/garden-service/src/plugins/local/local-google-cloud-functions.ts b/garden-service/src/plugins/local/local-google-cloud-functions.ts index 520fb6d298..9aba3aa0e1 100644 --- a/garden-service/src/plugins/local/local-google-cloud-functions.ts +++ b/garden-service/src/plugins/local/local-google-cloud-functions.ts @@ -8,7 +8,7 @@ import { join } from "path" import { GcfModule, configureGcfModule } from "../google/google-cloud-functions" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { STATIC_DIR, DEFAULT_API_VERSION } from "../../constants" import { ServiceConfig } from "../../config/service" import { ContainerModuleConfig } from "../container/config" @@ -23,8 +23,9 @@ const baseContainerName = `${pluginName}--${emulatorModuleName}` const emulatorBaseModulePath = join(STATIC_DIR, emulatorModuleName) const emulatorPort = 8010 -export const gardenPlugin = (): GardenPlugin => ({ - actions: { +export const gardenPlugin = createGardenPlugin({ + name: pluginName, + handlers: { async configureProvider({ config }: ConfigureProviderParams) { const emulatorConfig: ContainerModuleConfig = { allowPublish: false, @@ -60,8 +61,9 @@ export const gardenPlugin = (): GardenPlugin => ({ }, }, - moduleActions: { - "google-cloud-function": { + extendModuleTypes: [{ + name: "google-cloud-function", + handlers: { async configure(params: ConfigureModuleParams) { const parsed = await configureGcfModule(params) @@ -143,5 +145,5 @@ export const gardenPlugin = (): GardenPlugin => ({ } }, }, - }, + }], }) diff --git a/garden-service/src/plugins/maven-container/maven-container.ts b/garden-service/src/plugins/maven-container/maven-container.ts index 418a772e10..1889351aff 100644 --- a/garden-service/src/plugins/maven-container/maven-container.ts +++ b/garden-service/src/plugins/maven-container/maven-container.ts @@ -8,7 +8,7 @@ import { omit, get } from "lodash" import { copy, pathExists, readFile } from "fs-extra" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { ContainerModuleSpec, ContainerServiceSpec, @@ -80,25 +80,12 @@ export const mavenContainerConfigSchema = providerConfigBaseSchema name: joiProviderName("maven-container"), }) -export const gardenPlugin = (): GardenPlugin => { - const basePlugin = containerPlugin() +export const gardenPlugin = createGardenPlugin({ + ...containerPlugin, + name: "maven-container", - return { - ...basePlugin, - moduleActions: { - "maven-container": { - ...basePlugin.moduleActions!.container, - describeType, - configure: configureMavenContainerModule, - getBuildStatus, - build, - }, - }, - } -} - -async function describeType() { - return { + createModuleTypes: [{ + name: "maven-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. @@ -115,8 +102,14 @@ async function describeType() { `, moduleOutputsSchema: containerModuleOutputsSchema, schema: mavenContainerModuleSpecSchema, - } -} + handlers: { + ...containerPlugin.createModuleTypes![0].handlers, + configure: configureMavenContainerModule, + getBuildStatus, + build, + }, + }], +}) export async function configureMavenContainerModule(params: ConfigureModuleParams) { const { moduleConfig } = params diff --git a/garden-service/src/plugins/npm-package.ts b/garden-service/src/plugins/npm-package.ts index f1d29f6355..f67fef9f71 100644 --- a/garden-service/src/plugins/npm-package.ts +++ b/garden-service/src/plugins/npm-package.ts @@ -6,13 +6,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GardenPlugin } from "../types/plugin/plugin" -import { - execPlugin, -} from "./exec" +import { createGardenPlugin } from "../types/plugin/plugin" +import { execPlugin, execModuleSpecSchema } from "./exec" -export const gardenPlugin = (): GardenPlugin => ({ - moduleActions: { - "npm-package": execPlugin.moduleActions!.exec, - }, +export const gardenPlugin = createGardenPlugin({ + name: "npm-package", + createModuleTypes: [ + { + name: "npm-package", + docs: "[DEPRECATED]", + schema: execModuleSpecSchema, + handlers: { + ...execPlugin.createModuleTypes![0].handlers, + }, + }, + ], }) diff --git a/garden-service/src/plugins/openfaas/config.ts b/garden-service/src/plugins/openfaas/config.ts index 70767e85ee..f799e9cdc4 100644 --- a/garden-service/src/plugins/openfaas/config.ts +++ b/garden-service/src/plugins/openfaas/config.ts @@ -87,17 +87,6 @@ export const configSchema = providerConfigBaseSchema export type OpenFaasProvider = Provider export type OpenFaasPluginContext = PluginContext -export async function describeType() { - return { - docs: dedent` - Deploy [OpenFaaS](https://www.openfaas.com/) functions using Garden. Requires either the \`openfaas\` or - \`local-openfaas\` provider to be configured. - `, - moduleOutputsSchema: openfaasModuleOutputsSchema, - schema: openfaasModuleSpecSchema, - } -} - export function getK8sProvider(providers: Provider[]): KubernetesProvider { const providerMap = keyBy(providers, "name") const provider = (providerMap["local-kubernetes"] || providerMap.kubernetes) diff --git a/garden-service/src/plugins/openfaas/local.ts b/garden-service/src/plugins/openfaas/local.ts index 24bbd4d5cb..df773c7913 100644 --- a/garden-service/src/plugins/openfaas/local.ts +++ b/garden-service/src/plugins/openfaas/local.ts @@ -6,14 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { gardenPlugin as o6sPlugin } from "./openfaas" -export const name = "local-openfaas" - // TODO: avoid having to configure separate plugins, by allowing for this scenario in the plugin mechanism -export function gardenPlugin(): GardenPlugin { - const plugin = o6sPlugin() - plugin.dependencies = ["local-kubernetes"] - return plugin -} +export const gardenPlugin = createGardenPlugin({ + ...o6sPlugin, + name: "local-openfaas", + dependencies: ["local-kubernetes"], +}) diff --git a/garden-service/src/plugins/openfaas/openfaas.ts b/garden-service/src/plugins/openfaas/openfaas.ts index 0084769ed9..1e13146d31 100644 --- a/garden-service/src/plugins/openfaas/openfaas.ts +++ b/garden-service/src/plugins/openfaas/openfaas.ts @@ -15,7 +15,7 @@ import { findByName } from "../../util/util" import { KubeApi } from "../kubernetes/api" import { waitForResources } from "../kubernetes/status/status" import { checkWorkloadStatus } from "../kubernetes/status/workload" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { faasCli } from "./faas-cli" import { getAllLogs } from "../kubernetes/logs" import { DeployServiceParams } from "../../types/plugin/service/deployService" @@ -29,7 +29,6 @@ import { ConfigureProviderParams, ConfigureProviderResult } from "../../types/pl import { KubernetesDeployment } from "../kubernetes/types" import { configSchema, - describeType, getK8sProvider, OpenFaasConfig, OpenFaasModule, @@ -38,34 +37,42 @@ import { OpenFaasService, getServicePath, configureModule, + openfaasModuleOutputsSchema, + openfaasModuleSpecSchema, } from "./config" import { getOpenfaasModuleBuildStatus, buildOpenfaasModule, writeStackFile, stackFilename } from "./build" +import { dedent } from "../../util/string" const systemDir = join(STATIC_DIR, "openfaas", "system") -export function gardenPlugin(): GardenPlugin { - return { - configSchema, - dependencies: ["kubernetes"], - actions: { - configureProvider, - }, - moduleActions: { - openfaas: { - describeType, - configure: configureModule, - getBuildStatus: getOpenfaasModuleBuildStatus, - build: buildOpenfaasModule, - // TODO: design and implement a proper test flow for openfaas functions - testModule: testExecModule, - getServiceStatus, - getServiceLogs, - deployService, - deleteService, - }, +export const gardenPlugin = createGardenPlugin({ + name: "openfaas", + configSchema, + dependencies: ["kubernetes"], + handlers: { + configureProvider, + }, + createModuleTypes: [{ + name: "openfaas", + docs: dedent` + Deploy [OpenFaaS](https://www.openfaas.com/) functions using Garden. Requires either the \`openfaas\` or + \`local-openfaas\` provider to be configured. + `, + moduleOutputsSchema: openfaasModuleOutputsSchema, + schema: openfaasModuleSpecSchema, + handlers: { + configure: configureModule, + getBuildStatus: getOpenfaasModuleBuildStatus, + build: buildOpenfaasModule, + // TODO: design and implement a proper test flow for openfaas functions + testModule: testExecModule, + getServiceStatus, + getServiceLogs, + deployService, + deleteService, }, - } -} + }], +}) const templateModuleConfig: ExecModuleConfig = { allowPublish: false, diff --git a/garden-service/src/plugins/plugins.ts b/garden-service/src/plugins/plugins.ts index 6a809b3752..1cf3fd4cdd 100644 --- a/garden-service/src/plugins/plugins.ts +++ b/garden-service/src/plugins/plugins.ts @@ -6,39 +6,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { mapValues } from "lodash" - -const exec = require("./exec") -const container = require("./container/container") -const gcf = require("./google/google-cloud-functions") -const localGcf = require("./local/local-google-cloud-functions") -const kubernetes = require("./kubernetes/kubernetes") -const localKubernetes = require("./kubernetes/local/local") -const npmPackage = require("./npm-package") -const gae = require("./google/google-app-engine") -const localOpenfaas = require("./openfaas/local") -const openfaas = require("./openfaas/openfaas") -const mavenContainer = require("./maven-container/maven-container") -const terraform = require("./terraform/terraform") - // These plugins are always registered -export const builtinPlugins = mapValues({ - exec, - container, - "google-cloud-functions": gcf, - "local-google-cloud-functions": localGcf, - kubernetes, - "local-kubernetes": localKubernetes, - "npm-package": npmPackage, - "google-app-engine": gae, - "local-openfaas": localOpenfaas, - openfaas, - "maven-container": mavenContainer, - terraform, -}, (m => m.gardenPlugin)) - -// These plugins are always loaded -export const fixedPlugins = [ - "exec", - "container", -] +export const builtinPlugins = [ + require("./exec"), + require("./container/container"), + require("./google/google-cloud-functions"), + require("./local/local-google-cloud-functions"), + require("./kubernetes/kubernetes"), + require("./kubernetes/local/local"), + require("./npm-package"), + require("./google/google-app-engine"), + require("./openfaas/local"), + require("./openfaas/openfaas"), + require("./maven-container/maven-container"), + require("./terraform/terraform"), +].map(m => m.gardenPlugin) diff --git a/garden-service/src/plugins/terraform/module.ts b/garden-service/src/plugins/terraform/module.ts index 36feb30936..a5a38e1c52 100644 --- a/garden-service/src/plugins/terraform/module.ts +++ b/garden-service/src/plugins/terraform/module.ts @@ -9,9 +9,8 @@ import { join } from "path" import { pathExists } from "fs-extra" import { joi } from "../../config/common" -import { deline, dedent } from "../../util/string" +import { deline } from "../../util/string" import { supportedVersions, defaultTerraformVersion } from "./cli" -import { DescribeModuleTypeParams } from "../../types/plugin/module/describeType" import { Module } from "../../types/module" import { ConfigureModuleParams } from "../../types/plugin/module/configure" import { ConfigurationError, PluginError } from "../../exceptions" @@ -29,7 +28,7 @@ export interface TerraformModuleSpec extends TerraformBaseSpec { export interface TerraformModule extends Module { } -const schema = joi.object() +export const schema = joi.object() .keys({ build: baseBuildSpecSchema, autoApply: joi.boolean() @@ -65,33 +64,6 @@ const schema = joi.object() `), }) -export async function describeTerraformModuleType({ }: DescribeModuleTypeParams) { - return { - docs: dedent` - Resolves a Terraform stack and either applies it automatically (if \`autoApply: true\`) or errors when the stack - resources are not up-to-date. - - Stack outputs are made available as service outputs, that can be referenced by other modules under - \`\${runtime.services..outputs.}\`. You can template in those values as e.g. command arguments - or environment variables for other services. - - Note that you can also declare a Terraform root in the \`terraform\` provider configuration by setting the - \`initRoot\` parameter. - This may be preferable if you need the outputs of the Terraform stack to be available to other provider - configurations, e.g. if you spin up an environment with the Terraform provider, and then use outputs from - that to configure another provider or other modules via \`\${providers.terraform.outputs.}\` template - strings. - - See the [Terraform guide](../../using-garden/terraform.md) for a high-level introduction to the \`terraform\` - provider. - `, - serviceOutputsSchema: joi.object() - .pattern(/.+/, joi.any()) - .description("A map of all the outputs defined in the Terraform stack."), - schema, - } -} - export async function configureTerraformModule({ ctx, moduleConfig }: ConfigureModuleParams) { // Make sure the configured root path exists const root = moduleConfig.spec.root diff --git a/garden-service/src/plugins/terraform/terraform.ts b/garden-service/src/plugins/terraform/terraform.ts index 5b2e0fdcd2..c76ab9b56b 100644 --- a/garden-service/src/plugins/terraform/terraform.ts +++ b/garden-service/src/plugins/terraform/terraform.ts @@ -8,7 +8,7 @@ import { join } from "path" import { pathExists } from "fs-extra" -import { GardenPlugin } from "../../types/plugin/plugin" +import { createGardenPlugin } from "../../types/plugin/plugin" import { getEnvironmentStatus, prepareEnvironment } from "./init" import { providerConfigBaseSchema, ProviderConfig, Provider } from "../../config/provider" import { joi } from "../../config/common" @@ -17,7 +17,7 @@ import { supportedVersions, defaultTerraformVersion } from "./cli" import { ConfigureProviderParams, ConfigureProviderResult } from "../../types/plugin/provider/configureProvider" import { ConfigurationError } from "../../exceptions" import { variablesSchema, TerraformBaseSpec } from "./common" -import { describeTerraformModuleType, configureTerraformModule, getTerraformStatus, deployTerraform } from "./module" +import { schema, configureTerraformModule, getTerraformStatus, deployTerraform } from "./module" type TerraformProviderConfig = ProviderConfig & TerraformBaseSpec & { initRoot?: string, @@ -59,23 +59,46 @@ const configSchema = providerConfigBaseSchema }) .unknown(false) -export const gardenPlugin = (): GardenPlugin => ({ +export const gardenPlugin = createGardenPlugin({ + name: "terraform", configSchema, - actions: { + handlers: { configureProvider, getEnvironmentStatus, prepareEnvironment, }, - moduleActions: { - terraform: { - describeType: describeTerraformModuleType, + createModuleTypes: [{ + name: "terraform", + docs: dedent` + Resolves a Terraform stack and either applies it automatically (if \`autoApply: true\`) or errors when the stack + resources are not up-to-date. + + Stack outputs are made available as service outputs, that can be referenced by other modules under + \`\${runtime.services..outputs.}\`. You can template in those values as e.g. command arguments + or environment variables for other services. + + Note that you can also declare a Terraform root in the \`terraform\` provider configuration by setting the + \`initRoot\` parameter. + This may be preferable if you need the outputs of the Terraform stack to be available to other provider + configurations, e.g. if you spin up an environment with the Terraform provider, and then use outputs from + that to configure another provider or other modules via \`\${providers.terraform.outputs.}\` template + strings. + + See the [Terraform guide](../../using-garden/terraform.md) for a high-level introduction to the \`terraform\` + provider. + `, + serviceOutputsSchema: joi.object() + .pattern(/.+/, joi.any()) + .description("A map of all the outputs defined in the Terraform stack."), + schema, + handlers: { configure: configureTerraformModule, // FIXME: it should not be strictly necessary to provide this handler build: async () => ({}), getServiceStatus: getTerraformStatus, deployService: deployTerraform, }, - }, + }], }) async function configureProvider( diff --git a/garden-service/src/tasks/resolve-provider.ts b/garden-service/src/tasks/resolve-provider.ts index 9d7eca89bf..67850e0d32 100644 --- a/garden-service/src/tasks/resolve-provider.ts +++ b/garden-service/src/tasks/resolve-provider.ts @@ -104,7 +104,7 @@ export class ResolveProviderTask extends BaseTask { resolvedConfig.path = this.garden.projectRoot - const configureHandler = (this.plugin.actions || {}).configureProvider + const configureHandler = (this.plugin.handlers || {}).configureProvider let moduleConfigs: ModuleConfig[] = [] diff --git a/garden-service/src/types/plugin/module/describeType.ts b/garden-service/src/types/plugin/module/describeType.ts deleted file mode 100644 index 9a842ab10c..0000000000 --- a/garden-service/src/types/plugin/module/describeType.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2018 Garden Technologies, Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import Joi = require("@hapi/joi") -import { dedent } from "../../../util/string" -import { joi } from "../../../config/common" - -export interface DescribeModuleTypeParams { } -export const describeModuleTypeParamsSchema = joi.object() - .keys({}) - -export interface ModuleTypeDescription { - docs: string - // TODO: specify the schemas using primitives (e.g. JSONSchema/OpenAPI) and not Joi objects - moduleOutputsSchema?: Joi.ObjectSchema - schema: Joi.ObjectSchema - serviceOutputsSchema?: Joi.ObjectSchema, - taskOutputsSchema?: Joi.ObjectSchema, - title?: string -} - -export const describeType = { - description: dedent` - Return documentation and a schema description of the module type. - - The documentation should be in markdown format. A reference for the module type is automatically - generated based on the provided schema, and a section appended to the provided documentation. - - The schema should be a valid Joi schema describing the configuration keys that the user - should use under the \`module\` key in a \`garden.yml\` configuration file. - - Used when auto-generating framework documentation. - - This action is called on every execution of Garden, so it should return quickly and avoid doing - any network calls. - `, - - paramsSchema: joi.object().keys({}), - - resultSchema: joi.object() - .keys({ - docs: joi.string() - .required() - .description("Documentation for the module type, in markdown format."), - // TODO: specify the schemas using primitives and not Joi objects - moduleOutputsSchema: joi.object() - .default(() => joi.object().keys({}), "{}") - .description(dedent` - A valid Joi schema describing the keys that each module outputs at config time, for use in template strings - (e.g. \`\${modules.my-module.outputs.some-key}\`). - - If no schema is provided, an error may be thrown if a module attempts to return an output. - `), - schema: joi.object() - .required() - .description( - "A valid Joi schema describing the configuration keys for the `module` " + - "field in the module's `garden.yml`.", - ), - serviceOutputsSchema: joi.object() - .default(() => joi.object().keys({}), "{}") - .description(dedent` - A valid Joi schema describing the keys that each service outputs at runtime, for use in template strings - and environment variables (e.g. \`\${runtime.services.my-service.outputs.some-key}\` and - \`GARDEN_SERVICES_MY_SERVICE__OUTPUT_SOME_KEY\`). - - If no schema is provided, an error may be thrown if a service attempts to return an output. - `), - taskOutputsSchema: joi.object() - .default(() => joi.object().keys({}), "{}") - .description(dedent` - A valid Joi schema describing the keys that each task outputs at runtime, for use in template strings - and environment variables (e.g. \`\${runtime.tasks.my-task.outputs.some-key}\` and - \`GARDEN_TASKS_MY_TASK__OUTPUT_SOME_KEY\`). - - If no schema is provided, an error may be thrown if a task attempts to return an output. - `), - title: joi.string() - .description( - "Readable title for the module type. Defaults to the title-cased type name, with dashes replaced by spaces.", - ), - }), -} diff --git a/garden-service/src/types/plugin/plugin.ts b/garden-service/src/types/plugin/plugin.ts index c70079ea34..53477e8d96 100644 --- a/garden-service/src/types/plugin/plugin.ts +++ b/garden-service/src/types/plugin/plugin.ts @@ -15,7 +15,6 @@ import { ConfigureProviderParams, ConfigureProviderResult, configureProvider } f import { DeleteSecretParams, DeleteSecretResult, deleteSecret } from "./provider/deleteSecret" import { DeleteServiceParams, deleteService } from "./service/deleteService" import { DeployServiceParams, deployService } from "./service/deployService" -import { DescribeModuleTypeParams, ModuleTypeDescription, describeType } from "./module/describeType" import { EnvironmentStatus, GetEnvironmentStatusParams, getEnvironmentStatus } from "./provider/getEnvironmentStatus" import { ExecInServiceParams, ExecInServiceResult, execInService } from "./service/execInService" import { GetSecretParams, GetSecretResult, getSecret } from "./provider/getSecret" @@ -31,38 +30,36 @@ import { RunServiceParams, runService } from "./service/runService" import { RunTaskParams, RunTaskResult, runTask } from "./task/runTask" import { SetSecretParams, SetSecretResult, setSecret } from "./provider/setSecret" import { TestModuleParams, testModule } from "./module/testModule" -import { joiArray, joiIdentifier, joiIdentifierMap, joi } from "../../config/common" - -import { LogNode } from "../../logger/log-node" +import { joiArray, joiIdentifier, joi } from "../../config/common" import { Module } from "../module" import { RunResult } from "./base" import { ServiceStatus } from "../service" import { mapValues } from "lodash" import { getDebugInfo, DebugInfo, GetDebugInfoParams } from "./provider/getDebugInfo" -import { deline } from "../../util/string" +import { deline, dedent } from "../../util/string" import { pluginCommandSchema, PluginCommand } from "./command" import { getPortForward, GetPortForwardParams, GetPortForwardResult } from "./service/getPortForward" import { StopPortForwardParams, stopPortForward } from "./service/stopPortForward" -export type ServiceActions = { +export type ServiceActionHandlers = { [P in keyof ServiceActionParams]: (params: ServiceActionParams[P]) => ServiceActionOutputs[P] } -export type TaskActions = { +export type TaskActionHandlers = { [P in keyof TaskActionParams]: (params: TaskActionParams[P]) => TaskActionOutputs[P] } -export type ModuleActions = { +export type ModuleActionHandlers = { [P in keyof ModuleActionParams]: (params: ModuleActionParams[P]) => ModuleActionOutputs[P] } -export type ModuleAndRuntimeActions = - ModuleActions & ServiceActions & TaskActions +export type ModuleAndRuntimeActionHandlers = + ModuleActionHandlers & ServiceActionHandlers & TaskActionHandlers -export type PluginActionName = keyof PluginActions -export type ServiceActionName = keyof ServiceActions -export type TaskActionName = keyof TaskActions -export type ModuleActionName = keyof ModuleActions +export type PluginActionName = keyof PluginActionHandlers +export type ServiceActionName = keyof ServiceActionHandlers +export type TaskActionName = keyof TaskActionHandlers +export type ModuleActionName = keyof ModuleActionHandlers export interface PluginActionDescription { description: string @@ -99,7 +96,7 @@ export interface PluginActionOutputs { getDebugInfo: Promise } -export type PluginActions = { +export type PluginActionHandlers = { [P in keyof PluginActionParams]: (params: PluginActionParams[P]) => PluginActionOutputs[P] } @@ -168,7 +165,6 @@ export const taskActionDescriptions: { [P in TaskActionName]: PluginActionDescri } export interface ModuleActionParams { - describeType: DescribeModuleTypeParams, configure: ConfigureModuleParams getBuildStatus: GetBuildStatusParams build: BuildModuleParams @@ -179,7 +175,6 @@ export interface ModuleActionParams { } export interface ModuleActionOutputs extends ServiceActionOutputs { - describeType: Promise configure: Promise getBuildStatus: Promise build: Promise @@ -191,7 +186,6 @@ export interface ModuleActionOutputs extends ServiceActionOutputs { export const moduleActionDescriptions: { [P in ModuleActionName | ServiceActionName | TaskActionName]: PluginActionDescription } = { - describeType, configure, getBuildStatus, build, @@ -207,53 +201,130 @@ export const moduleActionDescriptions: export const pluginActionNames: PluginActionName[] = Object.keys(pluginActionDescriptions) export const moduleActionNames: ModuleActionName[] = Object.keys(moduleActionDescriptions) -export interface GardenPlugin { +export interface ModuleTypeExtension { + handlers: Partial + name: string +} + +export interface ModuleTypeDefinition extends ModuleTypeExtension { + docs: string + // TODO: specify the schemas using primitives (e.g. JSONSchema/OpenAPI) and not Joi objects + moduleOutputsSchema?: Joi.ObjectSchema + schema: Joi.ObjectSchema + serviceOutputsSchema?: Joi.ObjectSchema + taskOutputsSchema?: Joi.ObjectSchema + title?: string +} + +export interface GardenPluginSpec { + name: string + base?: string + configSchema?: Joi.ObjectSchema, configKeys?: string[] outputsSchema?: Joi.ObjectSchema, dependencies?: string[] - actions?: Partial - moduleActions?: { [moduleType: string]: Partial } - + handlers?: Partial commands?: PluginCommand[] -} -export interface PluginFactoryParams { - log: LogNode, - projectName: string, + createModuleTypes?: ModuleTypeDefinition[] + extendModuleTypes?: ModuleTypeExtension[] } -export interface PluginFactory { - (params: PluginFactoryParams): GardenPlugin | Promise -} -export type RegisterPluginParam = string | PluginFactory -export interface Plugins { - [name: string]: RegisterPluginParam -} +export interface GardenPlugin extends GardenPluginSpec { } + +export type RegisterPluginParam = string | GardenPlugin + +const extendModuleTypeSchema = joi.object() + .keys({ + name: joiIdentifier() + .required() + .description("The name of module type."), + handlers: joi.object().keys(mapValues(moduleActionDescriptions, () => joi.func())) + .description("A map of module action handlers provided by the plugin."), + }) + +const createModuleTypeSchema = extendModuleTypeSchema + .keys({ + // base: joiIdentifier() + // .description(dedent` + // Name of module type to use as a base for this module type. + // `), + docs: joi.string() + .required() + .description("Documentation for the module type, in markdown format."), + // TODO: specify the schemas using primitives and not Joi objects + moduleOutputsSchema: joi.object() + .default(() => joi.object().keys({}), "{}") + .description(dedent` + A valid Joi schema describing the keys that each module outputs at config time, for use in template strings + (e.g. \`\${modules.my-module.outputs.some-key}\`). + + If no schema is provided, an error may be thrown if a module attempts to return an output. + `), + schema: joi.object() + .required() + .description( + "A valid Joi schema describing the configuration keys for the `module` " + + "field in the module's `garden.yml`.", + ), + serviceOutputsSchema: joi.object() + .default(() => joi.object().keys({}), "{}") + .description(dedent` + A valid Joi schema describing the keys that each service outputs at runtime, for use in template strings + and environment variables (e.g. \`\${runtime.services.my-service.outputs.some-key}\` and + \`GARDEN_SERVICES_MY_SERVICE__OUTPUT_SOME_KEY\`). + + If no schema is provided, an error may be thrown if a service attempts to return an output. + `), + taskOutputsSchema: joi.object() + .default(() => joi.object().keys({}), "{}") + .description(dedent` + A valid Joi schema describing the keys that each task outputs at runtime, for use in template strings + and environment variables (e.g. \`\${runtime.tasks.my-task.outputs.some-key}\` and + \`GARDEN_TASKS_MY_TASK__OUTPUT_SOME_KEY\`). + + If no schema is provided, an error may be thrown if a task attempts to return an output. + `), + title: joi.string() + .description( + "Readable title for the module type. Defaults to the title-cased type name, with dashes replaced by spaces.", + ), + }) export const pluginSchema = joi.object() .keys({ - // TODO: make this a JSON/OpenAPI schema for portability - configSchema: joi.object({ isJoi: joi.boolean().only(true).required() }).unknown(true), - outputsSchema: joi.object({ isJoi: joi.boolean().only(true).required() }).unknown(true), + name: joiIdentifier() + .required() + .description("The name of the plugin."), + base: joiIdentifier() + .description(dedent` + Name of a plugin to use as a base for this plugin. If + `), dependencies: joiArray(joi.string()) .description(deline` Names of plugins that need to be configured prior to this plugin. This plugin will be able to reference the configuration from the listed plugins. Note that the dependencies will not be implicitly configured—the user will need to explicitly configure them in their project configuration. `), - // TODO: document plugin actions further - actions: joi.object().keys(mapValues(pluginActionDescriptions, () => joi.func())) + + // TODO: make this a JSON/OpenAPI schema for portability + configSchema: joi.object({ isJoi: joi.boolean().only(true).required() }).unknown(true), + outputsSchema: joi.object({ isJoi: joi.boolean().only(true).required() }).unknown(true), + + handlers: joi.object().keys(mapValues(pluginActionDescriptions, () => joi.func())) .description("A map of plugin action handlers provided by the plugin."), - moduleActions: joiIdentifierMap( - joi.object().keys(mapValues(moduleActionDescriptions, () => joi.func()), - ).description("A map of module names and module action handlers provided by the plugin."), - ), commands: joi.array().items(pluginCommandSchema) .unique("name") .description("List of commands that this plugin exposes (via \`garden plugins \`"), + createModuleTypes: joi.array().items(createModuleTypeSchema) + .unique("name") + .description("List of module types to create."), + extendModuleTypes: joi.array().items(extendModuleTypeSchema) + .unique("name") + .description("List of module types to extend/override with additional handlers."), }) .description("The schema for Garden plugins.") @@ -265,3 +336,8 @@ export const pluginModuleSchema = joi.object() }) .unknown(true) .description("A module containing a Garden plugin.") + +// This doesn't do much at the moment, but it makes sense to make this an SDK function to make it more future-proof +export function createGardenPlugin(spec: (GardenPluginSpec | (() => GardenPluginSpec))): GardenPlugin { + return typeof spec === "function" ? spec() : spec +} diff --git a/garden-service/src/util/util.ts b/garden-service/src/util/util.ts index 6b67311c94..16a5e3ac23 100644 --- a/garden-service/src/util/util.ts +++ b/garden-service/src/util/util.ts @@ -396,3 +396,14 @@ export function hashString(s: string, length: number) { urlHash.update(s) return urlHash.digest("hex").slice(0, length) } + +/** + * Ensures that `obj` has an array at `key`, creating it if necessary, and then pushes `value` on that array. + */ +export function pushToKey(obj: object, key: string, value: any) { + if (obj[key]) { + obj[key].push(value) + } else { + obj[key] = [value] + } +} diff --git a/garden-service/test/helpers.ts b/garden-service/test/helpers.ts index 167cd0e310..ae2a18a24c 100644 --- a/garden-service/test/helpers.ts +++ b/garden-service/test/helpers.ts @@ -16,14 +16,8 @@ import execa = require("execa") import { containerModuleSpecSchema, containerTestSchema, containerTaskSchema } from "../src/plugins/container/config" import { testExecModule, buildExecModule, execBuildSpecSchema } from "../src/plugins/exec" import { TaskResults } from "../src/task-graph" -import { validate, joiArray, joi } from "../src/config/common" -import { - GardenPlugin, - PluginActions, - PluginFactory, - ModuleActions, - Plugins, -} from "../src/types/plugin/plugin" +import { joiArray, joi } from "../src/config/common" +import { PluginActionHandlers, ModuleActionHandlers, createGardenPlugin, RegisterPluginParam } from "../src/types/plugin/plugin" import { Garden, GardenParams } from "../src/garden" import { ModuleConfig } from "../src/config/module" import { mapValues, fromPairs } from "lodash" @@ -43,6 +37,7 @@ import { RunServiceParams } from "../src/types/plugin/service/runService" import { RunTaskParams, RunTaskResult } from "../src/types/plugin/task/runTask" import { RunResult } from "../src/types/plugin/base" import { ExternalSourceType, getRemoteSourceRelPath, hashRepoUrl } from "../src/util/ext-source-util" +import { ConfigureProviderParams } from "../src/types/plugin/provider/configureProvider" export const dataDir = resolve(GARDEN_SERVICE_ROOT, "test", "unit", "data") export const examplesDir = resolve(GARDEN_SERVICE_ROOT, "..", "examples") @@ -101,12 +96,6 @@ export const testModuleSpecSchema = containerModuleSpecSchema }) export async function configureTestModule({ moduleConfig }: ConfigureModuleParams) { - moduleConfig.spec = validate( - moduleConfig.spec, - testModuleSpecSchema, - { context: `test module ${moduleConfig.name}` }, - ) - moduleConfig.outputs = { foo: "bar" } // validate services @@ -134,17 +123,25 @@ export async function configureTestModule({ moduleConfig }: ConfigureModuleParam return moduleConfig } -export const testPlugin: PluginFactory = (): GardenPlugin => { - const secrets = {} +export const testPlugin = createGardenPlugin(() => { + const secrets: { [key: string]: string } = {} return { - actions: { + name: "test-plugin", + handlers: { + async configureProvider({ config }: ConfigureProviderParams) { + for (let member in secrets) { + delete secrets[member] + } + return { config } + }, + async prepareEnvironment() { return { status: { ready: true, outputs: {} } } }, async setSecret({ key, value }: SetSecretParams) { - secrets[key] = value + secrets[key] = "" + value return {} }, @@ -169,8 +166,11 @@ export const testPlugin: PluginFactory = (): GardenPlugin => { } }, }, - moduleActions: { - test: { + createModuleTypes: [{ + name: "test", + docs: "Test module type", + schema: testModuleSpecSchema, + handlers: { testModule: testExecModule, configure: configureTestModule, build: buildExecModule, @@ -215,27 +215,37 @@ export const testPlugin: PluginFactory = (): GardenPlugin => { }, } }, - }, - }, + }], } -} - -export const testPluginB: PluginFactory = async (params) => { - const plugin = await testPlugin(params) - plugin.moduleActions = { - test: plugin.moduleActions!.test, - } - return plugin -} +}) + +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: PluginFactory = async (params) => { - const plugin = await testPlugin(params) - plugin.moduleActions = { - "test-c": plugin.moduleActions!.test, - } - return plugin -} +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, + }, + ], +}) const defaultModuleConfig: ModuleConfig = { apiVersion: "garden.io/v0", @@ -295,11 +305,7 @@ class TestEventBus extends EventBus { } } -export const testPlugins = { - "test-plugin": testPlugin, - "test-plugin-b": testPluginB, - "test-plugin-c": testPluginC, -} +export const testPlugins = [testPlugin, testPluginB, testPluginC] export class TestGarden extends Garden { events: TestEventBus @@ -311,18 +317,19 @@ export class TestGarden extends Garden { } export const makeTestGarden = async ( - projectRoot: string, { extraPlugins, gardenDirPath }: { extraPlugins?: Plugins, gardenDirPath?: string } = {}, + projectRoot: string, + { extraPlugins, gardenDirPath }: { extraPlugins?: RegisterPluginParam[], gardenDirPath?: string } = {}, ): Promise => { - const plugins = { ...testPlugins, ...extraPlugins } + const plugins = [...testPlugins, ...extraPlugins || []] return TestGarden.factory(projectRoot, { plugins, gardenDirPath }) } -export const makeTestGardenA = async (extraPlugins: Plugins = {}) => { +export const makeTestGardenA = async (extraPlugins: RegisterPluginParam[] = []) => { return makeTestGarden(projectRootA, { extraPlugins }) } -export function stubAction( - garden: Garden, pluginName: string, type: T, handler?: PluginActions[T], +export function stubAction( + garden: Garden, pluginName: string, type: T, handler?: PluginActionHandlers[T], ) { if (handler) { handler["pluginName"] = pluginName @@ -330,8 +337,8 @@ export function stubAction( return td.replace(garden["actionHandlers"][type], pluginName, handler) } -export function stubModuleAction>( - garden: Garden, moduleType: string, pluginName: string, actionType: T, handler: ModuleActions[T], +export function stubModuleAction>( + garden: Garden, moduleType: string, pluginName: string, actionType: T, handler: ModuleActionHandlers[T], ) { handler["actionType"] = actionType handler["pluginName"] = pluginName diff --git a/garden-service/test/unit/src/actions.ts b/garden-service/test/unit/src/actions.ts index f88d9100ab..73f80dd47c 100644 --- a/garden-service/test/unit/src/actions.ts +++ b/garden-service/test/unit/src/actions.ts @@ -1,10 +1,10 @@ import { - ModuleAndRuntimeActions, - PluginActions, - PluginFactory, + ModuleAndRuntimeActionHandlers, + PluginActionHandlers, moduleActionDescriptions, pluginActionDescriptions, + createGardenPlugin, } from "../../../src/types/plugin/plugin" import { Service, ServiceState } from "../../../src/types/service" import { RuntimeContext, prepareRuntimeContext } from "../../../src/runtime-context" @@ -32,7 +32,7 @@ describe("ActionHelper", () => { let task: Task before(async () => { - const plugins = { "test-plugin": testPlugin, "test-plugin-b": testPluginB } + const plugins = [testPlugin, testPluginB] garden = await makeTestGardenA(plugins) log = garden.log actions = await garden.getActionHelper() @@ -418,7 +418,7 @@ describe("ActionHelper", () => { describe("callServiceHandler", () => { it("should interpolate runtime template strings", async () => { - const emptyActions = new ActionHelper(garden, {}) + const emptyActions = new ActionHelper(garden, []) garden["moduleConfigs"]["module-a"].spec.foo = "\${runtime.services.service-b.outputs.foo}" @@ -464,7 +464,7 @@ describe("ActionHelper", () => { }) it("should throw if one or more runtime variables remain unresolved after re-resolution", async () => { - const emptyActions = new ActionHelper(garden, {}) + const emptyActions = new ActionHelper(garden, []) garden["moduleConfigs"]["module-a"].spec.services[0].foo = "\${runtime.services.service-b.outputs.foo}" @@ -509,7 +509,7 @@ describe("ActionHelper", () => { describe("callTaskHandler", () => { it("should interpolate runtime template strings", async () => { - const emptyActions = new ActionHelper(garden, {}) + const emptyActions = new ActionHelper(garden, []) garden["moduleConfigs"]["module-a"].spec.tasks[0].foo = "\${runtime.services.service-b.outputs.foo}" @@ -565,7 +565,7 @@ describe("ActionHelper", () => { }) it("should throw if one or more runtime variables remain unresolved after re-resolution", async () => { - const emptyActions = new ActionHelper(garden, {}) + const emptyActions = new ActionHelper(garden, []) garden["moduleConfigs"]["module-a"].spec.tasks[0].foo = "\${runtime.services.service-b.outputs.foo}" @@ -610,8 +610,9 @@ describe("ActionHelper", () => { }) }) -const testPlugin: PluginFactory = async () => ({ - actions: { +const testPlugin = createGardenPlugin({ + name: "test-plugin", + handlers: { getEnvironmentStatus: async (params) => { validate(params, pluginActionDescriptions.getEnvironmentStatus.paramsSchema) return { @@ -645,20 +646,17 @@ const testPlugin: PluginFactory = async () => ({ return { found: true } }, }, - moduleActions: { - test: { - describeType: async (params) => { - validate(params, moduleActionDescriptions.describeType.paramsSchema) - return { - docs: "bla bla bla", - moduleOutputsSchema: joi.object(), - serviceOutputsSchema: joi.object(), - taskOutputsSchema: joi.object(), - schema: joi.object(), - title: "Bla", - } - }, + createModuleTypes: [{ + name: "test", + docs: "bla bla bla", + moduleOutputsSchema: joi.object(), + serviceOutputsSchema: joi.object(), + taskOutputsSchema: joi.object(), + schema: joi.object(), + title: "Bla", + + handlers: { configure: async (params) => { validate(params, moduleActionDescriptions.configure.paramsSchema) @@ -839,7 +837,10 @@ const testPlugin: PluginFactory = async () => ({ } }, }, - }, + }], }) -const testPluginB: PluginFactory = async (params) => omit(await testPlugin(params), ["moduleActions"]) +const testPluginB = createGardenPlugin({ + ...omit(testPlugin, ["createModuleTypes"]), + name: "test-plugin-b", +}) diff --git a/garden-service/test/unit/src/commands/call.ts b/garden-service/test/unit/src/commands/call.ts index 5b933ac008..27d2cce13c 100644 --- a/garden-service/test/unit/src/commands/call.ts +++ b/garden-service/test/unit/src/commands/call.ts @@ -2,11 +2,11 @@ import { join } from "path" import { Garden } from "../../../../src/garden" import { CallCommand } from "../../../../src/commands/call" import { expect } from "chai" -import { PluginFactory } from "../../../../src/types/plugin/plugin" +import { GardenPlugin } from "../../../../src/types/plugin/plugin" import { GetServiceStatusParams } from "../../../../src/types/plugin/service/getServiceStatus" import { ServiceStatus } from "../../../../src/types/service" import nock = require("nock") -import { configureTestModule, withDefaultGlobalOpts, dataDir } from "../../../helpers" +import { configureTestModule, withDefaultGlobalOpts, dataDir, testModuleSpecSchema } from "../../../helpers" const testStatusesA: { [key: string]: ServiceStatus } = { "service-a": { @@ -60,24 +60,29 @@ const testStatusesB: { [key: string]: ServiceStatus } = { }, } -function makeTestProvider(serviceStatuses: { [key: string]: ServiceStatus }): PluginFactory { - return () => { - const getServiceStatus = async (params: GetServiceStatusParams): Promise => { - return serviceStatuses[params.service.name] || {} - } +function makeTestProvider(serviceStatuses: { [key: string]: ServiceStatus }): GardenPlugin { + const getServiceStatus = async (params: GetServiceStatusParams): Promise => { + return serviceStatuses[params.service.name] || {} + } - return { - moduleActions: { - test: { configure: configureTestModule, getServiceStatus }, + return { + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: testModuleSpecSchema, + handlers: { + configure: configureTestModule, + getServiceStatus, }, - } + }], } } describe("commands.call", () => { const projectRootB = join(dataDir, "test-project-b") - const pluginsA = { "test-plugin": makeTestProvider(testStatusesA) } - const pluginsB = { "test-plugin": makeTestProvider(testStatusesB) } + const pluginsA = [makeTestProvider(testStatusesA)] + const pluginsB = [makeTestProvider(testStatusesB)] beforeEach(() => { nock.disableNetConnect() diff --git a/garden-service/test/unit/src/commands/delete.ts b/garden-service/test/unit/src/commands/delete.ts index e53a5147a7..bc3a72e4f7 100644 --- a/garden-service/test/unit/src/commands/delete.ts +++ b/garden-service/test/unit/src/commands/delete.ts @@ -4,12 +4,19 @@ import { DeleteServiceCommand, } from "../../../../src/commands/delete" import { Garden } from "../../../../src/garden" -import { PluginFactory } from "../../../../src/types/plugin/plugin" -import { expectError, makeTestGardenA, getDataDir, configureTestModule, withDefaultGlobalOpts } from "../../../helpers" +import { + expectError, + makeTestGardenA, + getDataDir, + configureTestModule, + withDefaultGlobalOpts, +} from "../../../helpers" import { expect } from "chai" import { ServiceStatus } from "../../../../src/types/service" import { EnvironmentStatus } from "../../../../src/types/plugin/provider/getEnvironmentStatus" import { DeleteServiceParams } from "../../../../src/types/plugin/service/deleteService" +import { createGardenPlugin } from "../../../../src/types/plugin/plugin" +import { testModuleSpecSchema } from "../../../helpers" describe("DeleteSecretCommand", () => { const pluginName = "test-plugin" @@ -64,7 +71,7 @@ const getServiceStatus = async (): Promise => { describe("DeleteEnvironmentCommand", () => { let deletedServices: string[] = [] - const testProvider: PluginFactory = () => { + const testProvider = createGardenPlugin(() => { const name = "test-plugin" const testEnvStatuses: { [key: string]: EnvironmentStatus } = {} @@ -84,19 +91,23 @@ describe("DeleteEnvironmentCommand", () => { } return { - actions: { + name: "test-plugin", + handlers: { cleanupEnvironment, getEnvironmentStatus, }, - moduleActions: { - test: { + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: testModuleSpecSchema, + handlers: { configure: configureTestModule, getServiceStatus, deleteService, }, - }, + }], } - } + }) beforeEach(() => { deletedServices = [] @@ -104,7 +115,7 @@ describe("DeleteEnvironmentCommand", () => { const projectRootB = getDataDir("test-project-b") const command = new DeleteEnvironmentCommand() - const plugins = { "test-plugin": testProvider } + const plugins = [testProvider] it("should delete environment with services", async () => { const garden = await Garden.factory(projectRootB, { plugins }) @@ -131,7 +142,7 @@ describe("DeleteEnvironmentCommand", () => { }) describe("DeleteServiceCommand", () => { - const testProvider: PluginFactory = () => { + const testProvider = createGardenPlugin(() => { const testStatuses: { [key: string]: ServiceStatus } = { "service-a": { state: "unknown", @@ -150,17 +161,21 @@ describe("DeleteServiceCommand", () => { } return { - moduleActions: { - test: { + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: testModuleSpecSchema, + handlers: { configure: configureTestModule, getServiceStatus, deleteService, }, - }, + }], } - } + }) - const plugins = { "test-plugin": testProvider } + const plugins = [testProvider] const command = new DeleteServiceCommand() const projectRootB = getDataDir("test-project-b") diff --git a/garden-service/test/unit/src/commands/deploy.ts b/garden-service/test/unit/src/commands/deploy.ts index fd8ce012c2..b115405357 100644 --- a/garden-service/test/unit/src/commands/deploy.ts +++ b/garden-service/test/unit/src/commands/deploy.ts @@ -3,12 +3,18 @@ import { Garden } from "../../../../src/garden" import { DeployCommand } from "../../../../src/commands/deploy" import { expect } from "chai" import { buildExecModule } from "../../../../src/plugins/exec" -import { PluginFactory } from "../../../../src/types/plugin/plugin" import { ServiceState, ServiceStatus } from "../../../../src/types/service" -import { taskResultOutputs, configureTestModule, withDefaultGlobalOpts, dataDir } from "../../../helpers" +import { + taskResultOutputs, + configureTestModule, + withDefaultGlobalOpts, + dataDir, + testModuleSpecSchema, +} from "../../../helpers" import { GetServiceStatusParams } from "../../../../src/types/plugin/service/getServiceStatus" import { DeployServiceParams } from "../../../../src/types/plugin/service/deployService" import { RunTaskParams, RunTaskResult } from "../../../../src/types/plugin/task/runTask" +import { createGardenPlugin } from "../../../../src/types/plugin/plugin" const placeholderTimestamp = new Date() @@ -29,7 +35,7 @@ const placeholderTaskResult = (moduleName: string, taskName: string, command: st const taskResultA = placeholderTaskResult("module-a", "task-a", ["echo", "A"]) const taskResultC = placeholderTaskResult("module-c", "task-c", ["echo", "C"]) -const testProvider: PluginFactory = () => { +const testProvider = () => createGardenPlugin(() => { const testStatuses: { [key: string]: ServiceStatus } = { "service-a": { state: "ready", @@ -68,26 +74,30 @@ const testProvider: PluginFactory = () => { } return { - moduleActions: { - test: { + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: testModuleSpecSchema, + handlers: { configure: configureTestModule, build: buildExecModule, deployService, getServiceStatus, runTask, }, - }, + }], } -} +}) describe("DeployCommand", () => { const projectRootB = join(dataDir, "test-project-b") - const plugins = { "test-plugin": testProvider } // TODO: Verify that services don't get redeployed when same version is already deployed. // TODO: Test with --watch flag it("should build and deploy all modules in a project", async () => { + const plugins = [testProvider()] const garden = await Garden.factory(projectRootB, { plugins }) const log = garden.log const command = new DeployCommand() @@ -156,6 +166,7 @@ describe("DeployCommand", () => { }) it("should optionally build and deploy single service and its dependencies", async () => { + const plugins = [testProvider()] const garden = await Garden.factory(projectRootB, { plugins }) const log = garden.log const command = new DeployCommand() diff --git a/garden-service/test/unit/src/commands/get/get-task-result.ts b/garden-service/test/unit/src/commands/get/get-task-result.ts index 9395a7c4ff..84073d849e 100644 --- a/garden-service/test/unit/src/commands/get/get-task-result.ts +++ b/garden-service/test/unit/src/commands/get/get-task-result.ts @@ -4,10 +4,11 @@ import { expectError, withDefaultGlobalOpts, configureTestModule, + testModuleSpecSchema, } from "../../../../helpers" import { GetTaskResultCommand } from "../../../../../src/commands/get/get-task-result" import { expect } from "chai" -import { PluginFactory } from "../../../../../src/types/plugin/plugin" +import { createGardenPlugin } from "../../../../../src/types/plugin/plugin" import { LogEntry } from "../../../../../src/logger/log-entry" import { Garden } from "../../../../../src/garden" import { GetTaskResultParams } from "../../../../../src/types/plugin/task/getTaskResult" @@ -31,13 +32,17 @@ const taskResults = { "task-c": null, } -const testPlugin: PluginFactory = async () => ({ - moduleActions: { - test: { +const testPlugin = createGardenPlugin({ + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "test", + schema: testModuleSpecSchema, + handlers: { configure: configureTestModule, getTaskResult: async (params: GetTaskResultParams) => taskResults[params.task.name], }, - }, + }], }) describe("GetTaskResultCommand", () => { @@ -46,9 +51,8 @@ describe("GetTaskResultCommand", () => { const command = new GetTaskResultCommand() before(async () => { - const plugins = { "test-plugin": testPlugin } const projectRootB = join(dataDir, "test-project-b") - garden = await Garden.factory(projectRootB, { plugins }) + garden = await Garden.factory(projectRootB, { plugins: [testPlugin] }) log = garden.log }) diff --git a/garden-service/test/unit/src/commands/get/get-test-result.ts b/garden-service/test/unit/src/commands/get/get-test-result.ts index 0ab62b7d6e..012c3eaa3a 100644 --- a/garden-service/test/unit/src/commands/get/get-test-result.ts +++ b/garden-service/test/unit/src/commands/get/get-test-result.ts @@ -6,10 +6,11 @@ import { } from "../../../../helpers" import { GetTestResultCommand } from "../../../../../src/commands/get/get-test-result" import { expect } from "chai" -import { PluginFactory } from "../../../../../src/types/plugin/plugin" import { GetTestResultParams } from "../../../../../src/types/plugin/module/getTestResult" import { Garden } from "../../../../../src/garden" import { LogEntry } from "../../../../../src/logger/log-entry" +import { createGardenPlugin } from "../../../../../src/types/plugin/plugin" +import { joi } from "../../../../../src/config/common" const now = new Date() @@ -30,13 +31,17 @@ const testResults = { integration: null, } -const testPlugin: PluginFactory = async () => ({ - moduleActions: { - test: { +const testPlugin = createGardenPlugin({ + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "test", + schema: joi.object(), + handlers: { configure: configureTestModule, getTestResult: async (params: GetTestResultParams) => testResults[params.testName], }, - }, + }], }) describe("GetTestResultCommand", () => { @@ -46,8 +51,7 @@ describe("GetTestResultCommand", () => { const module = "module-a" before(async () => { - const plugins = { "test-plugin": testPlugin } - garden = await makeTestGardenA(plugins) + garden = await makeTestGardenA([testPlugin]) log = garden.log }) diff --git a/garden-service/test/unit/src/commands/publish.ts b/garden-service/test/unit/src/commands/publish.ts index dfdae92490..b013159054 100644 --- a/garden-service/test/unit/src/commands/publish.ts +++ b/garden-service/test/unit/src/commands/publish.ts @@ -3,10 +3,16 @@ import { it } from "mocha" import { join } from "path" import { expect } from "chai" import { Garden } from "../../../../src/garden" -import { PluginFactory } from "../../../../src/types/plugin/plugin" import { PublishCommand } from "../../../../src/commands/publish" -import { makeTestGardenA, configureTestModule, withDefaultGlobalOpts, dataDir } from "../../../helpers" +import { + makeTestGardenA, + configureTestModule, + withDefaultGlobalOpts, + dataDir, + testModuleSpecSchema, +} from "../../../helpers" import { taskResultOutputs } from "../../../helpers" +import { createGardenPlugin } from "../../../../src/types/plugin/plugin" const projectRootB = join(dataDir, "test-project-b") @@ -22,22 +28,23 @@ const publishModule = async () => { return { published: true } } -const testProvider: PluginFactory = () => { - return { - moduleActions: { - test: { - configure: configureTestModule, - getBuildStatus, - build, - publish: publishModule, - }, +const testProvider = createGardenPlugin({ + name: "test-plugin", + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: testModuleSpecSchema, + handlers: { + configure: configureTestModule, + getBuildStatus, + build, + publish: publishModule, }, - } -} + }], +}) async function getTestGarden() { - const plugins = { "test-plugin": testProvider } - const garden = await Garden.factory(projectRootB, { plugins }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) await garden.clearBuilds() return garden } diff --git a/garden-service/test/unit/src/config/project.ts b/garden-service/test/unit/src/config/project.ts index 204abc442f..39fc8044be 100644 --- a/garden-service/test/unit/src/config/project.ts +++ b/garden-service/test/unit/src/config/project.ts @@ -480,6 +480,7 @@ describe("pickEnvironment", () => { providers: [ { name: "exec" }, { name: "container" }, + { name: "maven-container" }, ], variables: {}, }) @@ -509,6 +510,7 @@ describe("pickEnvironment", () => { providers: [ { name: "exec" }, { name: "container", newKey: "foo" }, + { name: "maven-container" }, { name: "my-provider", a: "c", b: "b" }, ], variables: {}, @@ -539,6 +541,7 @@ describe("pickEnvironment", () => { providers: [ { name: "exec" }, { name: "container", newKey: "foo" }, + { name: "maven-container" }, { name: "my-provider", b: "b" }, ], variables: {}, diff --git a/garden-service/test/unit/src/config/provider.ts b/garden-service/test/unit/src/config/provider.ts index bfe2c4276f..9e2c12188a 100644 --- a/garden-service/test/unit/src/config/provider.ts +++ b/garden-service/test/unit/src/config/provider.ts @@ -4,7 +4,9 @@ import { expectError } from "../../../helpers" import { GardenPlugin } from "../../../../src/types/plugin/plugin" describe("getProviderDependencies", () => { - const plugin: GardenPlugin = {} + const plugin: GardenPlugin = { + name: "test", + } it("should extract implicit provider dependencies from template strings", async () => { const config: ProviderConfig = { diff --git a/garden-service/test/unit/src/garden.ts b/garden-service/test/unit/src/garden.ts index 37e9a6b74b..50cfeb7898 100644 --- a/garden-service/test/unit/src/garden.ts +++ b/garden-service/test/unit/src/garden.ts @@ -24,7 +24,7 @@ import { MOCK_CONFIG } from "../../../src/cli/cli" import { LinkedSource } from "../../../src/config-store" import { ModuleVersion } from "../../../src/vcs/vcs" import { getModuleCacheContext } from "../../../src/types/module" -import { Plugins, GardenPlugin, PluginFactory } from "../../../src/types/plugin/plugin" +import { createGardenPlugin } from "../../../src/types/plugin/plugin" import { ConfigureProviderParams } from "../../../src/types/plugin/provider/configureProvider" import { ProjectConfig } from "../../../src/config/project" import { ModuleConfig } from "../../../src/config/module" @@ -35,7 +35,7 @@ import stripAnsi from "strip-ansi" import { joi } from "../../../src/config/common" import { defaultDotIgnoreFiles } from "../../../src/util/fs" import { realpath, writeFile } from "fs-extra" -import { dedent } from "../../../src/util/string" +import { dedent, deline } from "../../../src/util/string" describe("Garden", () => { beforeEach(async () => { @@ -79,49 +79,28 @@ describe("Garden", () => { const garden = await makeTestGardenA() const projectRoot = garden.projectRoot + const testPluginProvider = { + name: "test-plugin", + config: { + name: "test-plugin", + environments: ["local"], + path: projectRoot, + }, + dependencies: [], + moduleConfigs: [], + status: { + ready: true, + outputs: {}, + }, + } + expect(garden.projectName).to.equal("test-project-a") expect(await garden.resolveProviders()).to.eql([ - { - name: "exec", - config: { - name: "exec", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, - { - name: "container", - config: { - name: "container", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, - { - name: "test-plugin", - config: { - name: "test-plugin", - environments: ["local"], - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, + emptyProvider(projectRoot, "exec"), + emptyProvider(projectRoot, "container"), + emptyProvider(projectRoot, "maven-container"), + testPluginProvider, { name: "test-plugin-b", config: { @@ -129,7 +108,7 @@ describe("Garden", () => { environments: ["local"], path: projectRoot, }, - dependencies: [], + dependencies: [testPluginProvider], moduleConfigs: [], status: { ready: true, @@ -155,32 +134,9 @@ describe("Garden", () => { delete process.env.TEST_VARIABLE expect(await garden.resolveProviders()).to.eql([ - { - name: "exec", - config: { - name: "exec", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, - { - name: "container", - config: { - name: "container", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, + emptyProvider(projectRoot, "exec"), + emptyProvider(projectRoot, "container"), + emptyProvider(projectRoot, "maven-container"), { name: "test-plugin", config: { @@ -212,36 +168,29 @@ describe("Garden", () => { }) it("should throw if plugin module exports invalid name", async () => { - const pluginPath = join(__dirname, "plugins", "invalid-exported-name.js") - const plugins = { foo: pluginPath } - const projectRoot = join(dataDir, "test-project-empty") - await expectError(async () => Garden.factory(projectRoot, { plugins }), "plugin") - }) - - it("should throw if plugin module name is not a valid identifier", async () => { - const pluginPath = join(__dirname, "plugins", "invalidModuleName.js") - const plugins = { foo: pluginPath } + const pluginPath = join(__dirname, "plugins", "invalid-name.js") + const plugins = [pluginPath] const projectRoot = join(dataDir, "test-project-empty") await expectError(async () => Garden.factory(projectRoot, { plugins }), "plugin") }) - it("should throw if plugin module doesn't contain factory function", async () => { - const pluginPath = join(__dirname, "plugins", "missing-factory.js") - const plugins = { foo: pluginPath } + 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 () => Garden.factory(projectRoot, { plugins }), "plugin") }) it("should set .garden as the default cache dir", async () => { const projectRoot = join(dataDir, "test-project-empty") - const garden = await Garden.factory(projectRoot, { plugins: { "test-plugin": testPlugin } }) + const garden = await Garden.factory(projectRoot, { plugins: [testPlugin] }) expect(garden.gardenDirPath).to.eql(join(projectRoot, ".garden")) }) it("should optionally set a custom cache dir relative to project root", async () => { const projectRoot = join(dataDir, "test-project-empty") const garden = await Garden.factory(projectRoot, { - plugins: { "test-plugin": testPlugin }, + plugins: [testPlugin], gardenDirPath: "my/cache/dir", }) expect(garden.gardenDirPath).to.eql(join(projectRoot, "my/cache/dir")) @@ -251,7 +200,7 @@ describe("Garden", () => { const projectRoot = join(dataDir, "test-project-empty") const gardenDirPath = join(dataDir, "test-garden-dir") const garden = await Garden.factory(projectRoot, { - plugins: { "test-plugin": testPlugin }, + plugins: [testPlugin], gardenDirPath, }) expect(garden.gardenDirPath).to.eql(gardenDirPath) @@ -293,6 +242,77 @@ describe("Garden", () => { }) }) + describe("getPlugins", () => { + it("should throw if multiple plugins declare the same module type", async () => { + const testPluginDupe = { + ...testPlugin, + name: "test-plugin-dupe", + } + const garden = await makeTestGardenA([testPluginDupe]) + + garden["providerConfigs"].push({ name: "test-plugin-dupe" }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "Module type 'test' is declared in multiple providers: test-plugin, test-plugin-dupe.", + ), + ) + }) + + it("should throw if a plugin extends a module type that hasn't been declared elsewhere", async () => { + const plugin = { + name: "foo", + extendModuleTypes: [{ + name: "bar", + handlers: { + configure: async ({ moduleConfig }) => { + return moduleConfig + }, + }, + }], + } + const garden = await makeTestGardenA([plugin]) + + garden["providerConfigs"].push({ name: "foo" }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal(deline` + Plugin 'foo' extends module type 'bar' but the module type has not been declared. + The 'foo' plugin is likely missing a dependency declaration. + Please report an issue with the author. + `), + ) + }) + + it("should throw if a plugin extends a module type but doesn't declare a dependency on the base", async () => { + const plugin = { + name: "foo", + extendModuleTypes: [{ + name: "test", + handlers: { + configure: async ({ moduleConfig }) => { + return moduleConfig + }, + }, + }], + } + const garden = await makeTestGardenA([plugin]) + + garden["providerConfigs"].push({ name: "foo" }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal(deline` + Plugin 'foo' extends module type 'test', declared by the 'test-plugin' plugin, + but does not specify a dependency on that plugin. Plugins must explicitly declare dependencies on plugins + that define module types they reference. Please report an issue with the author. + `), + ) + }) + }) + describe("resolveProviders", () => { it("should throw when when plugins are missing", async () => { const garden = await Garden.factory(projectRootA) @@ -303,47 +323,26 @@ describe("Garden", () => { const garden = await makeTestGardenA() const projectRoot = garden.projectRoot - expect(await garden.resolveProviders()).to.eql([ - { - name: "exec", - config: { - name: "exec", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, - { - name: "container", - config: { - name: "container", - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, - }, - { + const testPluginProvider = { + name: "test-plugin", + config: { name: "test-plugin", - config: { - name: "test-plugin", - environments: ["local"], - path: projectRoot, - }, - dependencies: [], - moduleConfigs: [], - status: { - ready: true, - outputs: {}, - }, + environments: ["local"], + path: projectRoot, + }, + dependencies: [], + moduleConfigs: [], + status: { + ready: true, + outputs: {}, }, + } + + expect(await garden.resolveProviders()).to.eql([ + emptyProvider(projectRoot, "exec"), + emptyProvider(projectRoot, "container"), + emptyProvider(projectRoot, "maven-container"), + testPluginProvider, { name: "test-plugin-b", config: { @@ -351,7 +350,7 @@ describe("Garden", () => { environments: ["local"], path: projectRoot, }, - dependencies: [], + dependencies: [testPluginProvider], moduleConfigs: [], status: { ready: true, @@ -362,20 +361,19 @@ describe("Garden", () => { }) it("should call a configureProvider handler if applicable", async () => { - const test: PluginFactory = (): GardenPlugin => { - return { - actions: { - async configureProvider({ config }: ConfigureProviderParams) { - expect(config).to.eql({ - name: "test", - path: projectRootA, - foo: "bar", - }) - return { config } - }, + const test = createGardenPlugin({ + name: "test", + handlers: { + async configureProvider({ config }: ConfigureProviderParams) { + expect(config).to.eql({ + name: "test", + path: projectRootA, + foo: "bar", + }) + return { config } }, - } - } + }, + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -393,15 +391,14 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) await garden.resolveProviders() }) it("should give a readable error if provider configs have invalid template strings", async () => { - const test: PluginFactory = (): GardenPlugin => { - return {} - } + const test = createGardenPlugin({ + name: "test", + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -419,8 +416,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) await expectError( () => garden.resolveProviders(), err => expect(err.message).to.equal( @@ -431,11 +427,10 @@ describe("Garden", () => { }) it("should give a readable error if providers reference non-existent providers", async () => { - const test: PluginFactory = (): GardenPlugin => { - return { - dependencies: ["foo"], - } - } + const test = createGardenPlugin({ + name: "test", + dependencies: ["foo"], + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -453,8 +448,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) await expectError( () => garden.resolveProviders(), err => expect(err.message).to.equal( @@ -479,22 +473,24 @@ describe("Garden", () => { type: "exec", } - const test: PluginFactory = (): GardenPlugin => { - return { - actions: { - async configureProvider({ config }: ConfigureProviderParams) { - return { config, moduleConfigs: [pluginModule] } - }, + const test = createGardenPlugin({ + name: "test", + handlers: { + async configureProvider({ config }: ConfigureProviderParams) { + return { config, moduleConfigs: [pluginModule] } }, - moduleActions: { - test: { - configure: async ({ moduleConfig }) => { - return moduleConfig - }, + }, + createModuleTypes: [{ + name: "test", + docs: "Test plugin", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => { + return moduleConfig }, }, - } - } + }], + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -512,25 +508,22 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) const graph = await garden.getConfigGraph() expect(await graph.getModule("test--foo")).to.exist }) it("should throw if plugins have declared circular dependencies", async () => { - const testA: PluginFactory = (): GardenPlugin => { - return { - dependencies: ["test-b"], - } - } + const testA = createGardenPlugin({ + name: "test-a", + dependencies: ["test-b"], + }) - const testB: PluginFactory = (): GardenPlugin => { - return { - dependencies: ["test-a"], - } - } + const testB = createGardenPlugin({ + name: "test-b", + dependencies: ["test-a"], + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -549,7 +542,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { "test-a": testA, "test-b": testB } + const plugins = [testA, testB] const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) await expectError( @@ -562,11 +555,10 @@ describe("Garden", () => { }) it("should throw if plugins reference themselves as dependencies", async () => { - const testA: PluginFactory = (): GardenPlugin => { - return { - dependencies: ["test-a"], - } - } + const testA = createGardenPlugin({ + name: "test-a", + dependencies: ["test-a"], + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -584,8 +576,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { "test-a": testA } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [testA] }) await expectError( () => garden.resolveProviders(), @@ -597,13 +588,12 @@ describe("Garden", () => { }) it("should throw if provider configs have implicit circular dependencies", async () => { - const testA: PluginFactory = (): GardenPlugin => { - return {} - } - - const testB: PluginFactory = (): GardenPlugin => { - return {} - } + const testA = createGardenPlugin({ + name: "test-a", + }) + const testB = createGardenPlugin({ + name: "test-b", + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -622,7 +612,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { "test-a": testA, "test-b": testB } + const plugins = [testA, testB] const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) await expectError( @@ -635,15 +625,14 @@ describe("Garden", () => { }) it("should throw if provider configs have combined implicit and declared circular dependencies", async () => { - const testA: PluginFactory = (): GardenPlugin => { - return {} - } + const testA = createGardenPlugin({ + name: "test-a", + }) - const testB: PluginFactory = (): GardenPlugin => { - return { - dependencies: ["test-a"], - } - } + const testB = createGardenPlugin({ + name: "test-b", + dependencies: ["test-a"], + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -662,7 +651,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { "test-a": testA, "test-b": testB } + const plugins = [testA, testB] const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) await expectError( @@ -675,14 +664,13 @@ describe("Garden", () => { }) it("should apply default values from a plugin's configuration schema if specified", async () => { - const test: PluginFactory = (): GardenPlugin => { - return { - configSchema: providerConfigBaseSchema - .keys({ - foo: joi.string().default("bar"), - }), - } - } + const test = createGardenPlugin({ + name: "test", + configSchema: providerConfigBaseSchema + .keys({ + foo: joi.string().default("bar"), + }), + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -700,8 +688,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) const providers = keyBy(await garden.resolveProviders(), "name") expect(providers.test).to.exist @@ -709,14 +696,13 @@ describe("Garden", () => { }) it("should throw if a config doesn't match a plugin's configuration schema", async () => { - const test: PluginFactory = (): GardenPlugin => { - return { - configSchema: providerConfigBaseSchema - .keys({ - foo: joi.string(), - }), - } - } + const test = createGardenPlugin({ + name: "test", + configSchema: providerConfigBaseSchema + .keys({ + foo: joi.string(), + }), + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -734,8 +720,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { test } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) await expectError( () => garden.resolveProviders(), @@ -747,22 +732,21 @@ describe("Garden", () => { }) it("should allow providers to reference each others' outputs", async () => { - const testA: PluginFactory = (): GardenPlugin => { - return { - actions: { - getEnvironmentStatus: async () => { - return { - ready: true, - outputs: { foo: "bar" }, - } - }, + const testA = createGardenPlugin({ + name: "test-a", + handlers: { + getEnvironmentStatus: async () => { + return { + ready: true, + outputs: { foo: "bar" }, + } }, - } - } + }, + }) - const testB: PluginFactory = (): GardenPlugin => { - return {} - } + const testB = createGardenPlugin({ + name: "test-b", + }) const projectConfig: ProjectConfig = { apiVersion: "garden.io/v0", @@ -781,7 +765,7 @@ describe("Garden", () => { variables: {}, } - const plugins: Plugins = { "test-a": testA, "test-b": testB } + const plugins = [testA, testB] const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) const providerB = await garden.resolveProvider("test-b") @@ -965,10 +949,23 @@ describe("Garden", () => { it("should handle module references within single file", async () => { const projectRoot = getDataDir("test-projects", "1067-module-ref-within-file") const garden = await makeTestGarden(projectRoot) - // This should just complete successfully await garden.resolveModuleConfigs() }) + + it("should throw if a module type is not recognized", async () => { + const garden = await makeTestGardenA() + const config = (await garden.getRawModuleConfigs(["module-a"]))[0] + + config.type = "foo" + + await expectError( + () => garden.resolveModuleConfigs(), + (err) => expect(err.message).to.equal( + "Unrecognized module type 'foo' (defined at module-a/garden.yml). Are you missing a provider configuration?", + ), + ) + }) }) describe("resolveVersion", () => { @@ -1107,3 +1104,19 @@ describe("Garden", () => { }) }) }) + +function emptyProvider(projectRoot: string, name: string) { + return { + name, + config: { + name, + path: projectRoot, + }, + dependencies: [], + moduleConfigs: [], + status: { + ready: true, + outputs: {}, + }, + } +} diff --git a/garden-service/test/unit/src/plugins/container/container.ts b/garden-service/test/unit/src/plugins/container/container.ts index dce079500f..dfd9feda22 100644 --- a/garden-service/test/unit/src/plugins/container/container.ts +++ b/garden-service/test/unit/src/plugins/container/container.ts @@ -30,11 +30,12 @@ describe("plugins.container", () => { const modulePath = resolve(dataDir, "test-project-container", "module-a") const relDockerfilePath = "docker-dir/Dockerfile" - const handler = gardenPlugin() - const configure = handler.moduleActions!.container!.configure! - const build = handler.moduleActions!.container!.build! - const publishModule = handler.moduleActions!.container!.publish! - const getBuildStatus = handler.moduleActions!.container!.getBuildStatus! + const plugin = gardenPlugin + const handlers = plugin.createModuleTypes![0].handlers + const configure = handlers.configure! + const build = handlers.build! + const publishModule = handlers.publish! + const getBuildStatus = handlers.getBuildStatus! const baseConfig: ModuleConfig = { allowPublish: false, @@ -69,7 +70,7 @@ describe("plugins.container", () => { let log: LogEntry beforeEach(async () => { - garden = await makeTestGarden(projectRoot, { extraPlugins: { container: gardenPlugin } }) + garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) log = garden.log const provider = await garden.resolveProvider("container") ctx = await garden.getPluginContext(provider) diff --git a/garden-service/test/unit/src/plugins/container/helpers.ts b/garden-service/test/unit/src/plugins/container/helpers.ts index c146920d58..b6b8d332c5 100644 --- a/garden-service/test/unit/src/plugins/container/helpers.ts +++ b/garden-service/test/unit/src/plugins/container/helpers.ts @@ -25,8 +25,8 @@ describe("containerHelpers", () => { const modulePath = resolve(dataDir, "test-project-container", "module-a") const relDockerfilePath = "docker-dir/Dockerfile" - const handler = gardenPlugin() - const configure = handler.moduleActions!.container!.configure! + const plugin = gardenPlugin + const configure = plugin.createModuleTypes![0].handlers.configure! const baseConfig: ModuleConfig = { allowPublish: false, @@ -61,7 +61,7 @@ describe("containerHelpers", () => { let log: LogEntry beforeEach(async () => { - garden = await makeTestGarden(projectRoot, { extraPlugins: { container: gardenPlugin } }) + garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) log = garden.log const provider = await garden.resolveProvider("container") ctx = await garden.getPluginContext(provider) diff --git a/garden-service/test/unit/src/plugins/exec.ts b/garden-service/test/unit/src/plugins/exec.ts index 4daa62e4bf..31e4673d2d 100644 --- a/garden-service/test/unit/src/plugins/exec.ts +++ b/garden-service/test/unit/src/plugins/exec.ts @@ -20,7 +20,7 @@ describe("exec plugin", () => { let log: LogEntry beforeEach(async () => { - garden = await makeTestGarden(projectRoot, { extraPlugins: { exec: gardenPlugin } }) + garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) log = garden.log graph = await garden.getConfigGraph() await garden.clearBuilds() diff --git a/garden-service/test/unit/src/plugins/invalid-exported-name.ts b/garden-service/test/unit/src/plugins/invalid-exported-name.ts deleted file mode 100644 index 2365c2ca17..0000000000 --- a/garden-service/test/unit/src/plugins/invalid-exported-name.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const name = "BAt1%!2f" - -export const gardenPlugin = () => ({ - actions: {}, -}) diff --git a/garden-service/test/unit/src/plugins/invalid-name.ts b/garden-service/test/unit/src/plugins/invalid-name.ts new file mode 100644 index 0000000000..0cdb69f9b7 --- /dev/null +++ b/garden-service/test/unit/src/plugins/invalid-name.ts @@ -0,0 +1,6 @@ +import { createGardenPlugin } from "../../../../src/types/plugin/plugin" + +export const gardenPlugin = createGardenPlugin({ + name: "BAt1%!2f", + handlers: {}, +}) diff --git a/garden-service/test/unit/src/plugins/invalidModuleName.ts b/garden-service/test/unit/src/plugins/invalidModuleName.ts deleted file mode 100644 index e87d9b0f7c..0000000000 --- a/garden-service/test/unit/src/plugins/invalidModuleName.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const gardenPlugin = () => ({ - actions: {}, -}) diff --git a/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts b/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts index f479ab5f40..c73721abee 100644 --- a/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts +++ b/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts @@ -308,8 +308,8 @@ const wildcardDomainCertSecret = { describe("createIngressResources", () => { const projectRoot = resolve(dataDir, "test-project-container") - const handler = gardenPlugin() - const configure = handler.moduleActions!.container!.configure! + const plugin = gardenPlugin + const configure = plugin.createModuleTypes![0].handlers.configure! let garden: Garden @@ -326,7 +326,7 @@ describe("createIngressResources", () => { }) beforeEach(async () => { - garden = await makeTestGarden(projectRoot, { extraPlugins: { container: gardenPlugin } }) + garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) td.replace(garden.buildDir, "syncDependencyProducts", () => null) diff --git a/garden-service/test/unit/src/plugins/kubernetes/container/service.ts b/garden-service/test/unit/src/plugins/kubernetes/container/service.ts index 49071c7fb1..2408082ec8 100644 --- a/garden-service/test/unit/src/plugins/kubernetes/container/service.ts +++ b/garden-service/test/unit/src/plugins/kubernetes/container/service.ts @@ -12,7 +12,7 @@ describe("createServiceResources", () => { let garden: Garden beforeEach(async () => { - garden = await makeTestGarden(projectRoot, { extraPlugins: { container: gardenPlugin } }) + garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) }) it("should return service resources", async () => { diff --git a/garden-service/test/unit/src/plugins/missing-factory.ts b/garden-service/test/unit/src/plugins/missing-plugin.ts similarity index 100% rename from garden-service/test/unit/src/plugins/missing-factory.ts rename to garden-service/test/unit/src/plugins/missing-plugin.ts