diff --git a/examples/openfaas/garden.yml b/examples/openfaas/garden.yml index dc33ddf5ea..58d3ce83b4 100644 --- a/examples/openfaas/garden.yml +++ b/examples/openfaas/garden.yml @@ -2,17 +2,14 @@ kind: Project name: openfaas environments: - name: local - providers: - - name: local-kubernetes - - name: local-openfaas - hostname: openfaas-gateway.local.app.garden - name: testing - providers: - - name: kubernetes - context: gke_garden-dev-200012_europe-west1-b_garden-dev-1 - namespace: openfaas-testing-${local.env.CIRCLE_BUILD_NUM || local.username} - defaultHostname: openfaas-testing-${local.env.CIRCLE_BUILD_NUM || local.username}.dev-1.sys.garden - buildMode: cluster-docker - - name: openfaas -variables: - my-variable: hello-variable +providers: + - name: local-kubernetes + environments: [local] + - name: kubernetes + environments: [testing] + context: gke_garden-dev-200012_europe-west1-b_garden-dev-1 + namespace: openfaas-testing-${local.env.CIRCLE_BUILD_NUM || local.username} + defaultHostname: openfaas-testing-${local.env.CIRCLE_BUILD_NUM || local.username}.dev-1.sys.garden + buildMode: cluster-docker + - name: openfaas diff --git a/garden-service/src/actions.ts b/garden-service/src/actions.ts index c1ea141a67..a5a7fae035 100644 --- a/garden-service/src/actions.ts +++ b/garden-service/src/actions.ts @@ -9,14 +9,14 @@ import Bluebird = require("bluebird") import chalk from "chalk" -import { fromPairs, mapValues, omit, pickBy } from "lodash" +import { fromPairs, mapValues, omit, pickBy, keyBy } from "lodash" import { PublishModuleParams, PublishResult } from "./types/plugin/module/publishModule" import { SetSecretParams, SetSecretResult } from "./types/plugin/provider/setSecret" import { validate } from "./config/common" import { defaultProvider } from "./config/provider" -import { ParameterError, PluginError, ConfigurationError } from "./exceptions" -import { ActionHandlerMap, Garden, ModuleActionHandlerMap, ModuleActionMap, PluginActionMap } from "./garden" +import { ParameterError, PluginError, ConfigurationError, InternalError } from "./exceptions" +import { Garden } from "./garden" import { LogEntry } from "./logger/log-entry" import { ProcessResults, processServices } from "./process" import { getDependantTasksForModule } from "./tasks/helpers" @@ -53,6 +53,9 @@ import { pluginActionDescriptions, pluginActionNames, GardenPlugin, + PluginMap, + WrappedModuleActionHandler, + WrappedActionHandler, } from "./types/plugin/plugin" import { CleanupEnvironmentParams } from "./types/plugin/provider/cleanupEnvironment" import { DeleteSecretParams, DeleteSecretResult } from "./types/plugin/provider/deleteSecret" @@ -72,7 +75,7 @@ import { RunServiceParams } from "./types/plugin/service/runService" import { GetTaskResultParams } from "./types/plugin/task/getTaskResult" import { RunTaskParams, RunTaskResult } from "./types/plugin/task/runTask" import { ServiceStatus, ServiceStatusMap, ServiceState } from "./types/service" -import { Omit } from "./util/util" +import { Omit, getNames } from "./util/util" import { DebugInfoMap } from "./types/plugin/provider/getDebugInfo" import { PrepareEnvironmentParams, PrepareEnvironmentResult } from "./types/plugin/provider/prepareEnvironment" import { GetPortForwardParams } from "./types/plugin/service/getPortForward" @@ -81,6 +84,8 @@ import { emptyRuntimeContext, RuntimeContext } from "./runtime-context" import { GetServiceStatusTask } from "./tasks/get-service-status" import { getServiceStatuses } from "./tasks/base" import { getRuntimeTemplateReferences } from "./template-string" +import { getPluginBases, getPluginDependencies } from "./tasks/resolve-provider" +import { ConfigureProviderParams, ConfigureProviderResult } from "./types/plugin/provider/configureProvider" type TypeGuard = { readonly [P in keyof (PluginActionParams | ModuleActionParams)]: (...args: any[]) => Promise @@ -98,33 +103,30 @@ export interface DeployServicesParams { forceBuild?: boolean } -// avoid having to specify common params on each action helper call -type ActionHelperParams = - Omit & { pluginName?: string } - -type ModuleActionHelperParams = - Omit & { pluginName?: string } -// additionally make runtimeContext param optional - -type ServiceActionHelperParams = - Omit - & { pluginName?: string } - -type TaskActionHelperParams = - Omit - & { pluginName?: string } - -type RequirePluginName = T & { pluginName: string } - +/** + * The ActionHelper takes care of choosing which plugin should be responsible for handling an action, + * and preparing common parameters (so as to reduce boilerplate on the usage side). + * + * Each plugin and module action has a corresponding method on this class (aside from configureProvider, which + * is handled especially elsewhere). + */ export class ActionHelper implements TypeGuard { - private readonly actionHandlers: PluginActionMap - private readonly moduleActionHandlers: ModuleActionMap + private readonly actionHandlers: WrappedPluginActionMap + private readonly moduleActionHandlers: WrappedModuleActionMap + private readonly loadedPlugins: PluginMap + + constructor( + private readonly garden: Garden, + configuredPlugins: GardenPlugin[], + loadedPlugins: GardenPlugin[], + ) { + this.actionHandlers = fromPairs(pluginActionNames.map(n => [n, {}])) + this.moduleActionHandlers = fromPairs(moduleActionNames.map(n => [n, {}])) + this.loadedPlugins = keyBy(loadedPlugins, "name") - constructor(private garden: Garden, plugins: GardenPlugin[]) { - this.actionHandlers = fromPairs(pluginActionNames.map(n => [n, {}])) - this.moduleActionHandlers = fromPairs(moduleActionNames.map(n => [n, {}])) + garden.log.silly(`Creating ActionHelper with ${configuredPlugins.length} configured plugins`) - for (const plugin of plugins) { + for (const plugin of configuredPlugins) { const handlers = plugin.handlers || {} for (const actionType of pluginActionNames) { @@ -152,6 +154,31 @@ export class ActionHelper implements TypeGuard { //region Environment Actions //=========================================================================== + async configureProvider( + params: ConfigureProviderParams & { pluginName: string }, + ): Promise { + const pluginName = params.pluginName + + this.garden.log.silly(`Calling 'configureProvider' handler on '${pluginName}'`) + + const handler = await this.getActionHandler({ + actionType: "configureProvider", + pluginName, + defaultHandler: async ({ config }) => ({ config }), + }) + + const handlerParams: PluginActionParams["configureProvider"] = { + ...omit(params, ["pluginName"]), + base: this.wrapBase(handler.base), + } + + const result = (handler)(handlerParams) + + this.garden.log.silly(`Called 'configureProvider' handler on '${pluginName}'`) + + return result + } + async getEnvironmentStatus( params: RequirePluginName>, ): Promise { @@ -436,16 +463,20 @@ export class ActionHelper implements TypeGuard { //endregion // TODO: find a nicer way to do this (like a type-safe wrapper function) - private async commonParams(handler, log: LogEntry): Promise { - const provider = await this.garden.resolveProvider(handler["pluginName"]) + private async commonParams( + handler: WrappedActionHandler, log: LogEntry, + ): Promise { + const provider = await this.garden.resolveProvider(handler.pluginName) + return { - ctx: await this.garden.getPluginContext(provider), - // TODO: find a better way for handlers to log during execution + ctx: this.garden.getPluginContext(provider), log, + base: handler.base, } } - private async callActionHandler>( + // We special-case the configureProvider handlers and don't call them through this + private async callActionHandler>( { params, actionType, pluginName, defaultHandler }: { params: ActionHelperParams, @@ -454,18 +485,23 @@ export class ActionHelper implements TypeGuard { defaultHandler?: PluginActionHandlers[T], }, ): Promise { - this.garden.log.silly(`Calling '${actionType}' handler on '${pluginName}'`) + this.garden.log.silly(`Calling ${actionType} handler on plugin '${pluginName}'`) + const handler = await this.getActionHandler({ actionType, pluginName, defaultHandler, }) + const handlerParams: PluginActionParams[T] = { - ...await this.commonParams(handler, (params).log), + ...await this.commonParams(handler, params.log), ...params, } - const result = (handler)(handlerParams) - this.garden.log.silly(`Called '${actionType}' handler on ${pluginName}'`) + + const result = await (handler)(handlerParams) + + this.garden.log.silly(`Called ${actionType} handler on plugin '${pluginName}'`) + return result } @@ -485,12 +521,12 @@ export class ActionHelper implements TypeGuard { moduleType: module.type, actionType, pluginName, - defaultHandler: defaultHandler as ModuleAndRuntimeActionHandlers[T], + defaultHandler: defaultHandler as WrappedModuleAndRuntimeActionHandlers[T], }) const handlerParams = { ...await this.commonParams(handler, (params).log), - ...params, + ...params, module: omit(module, ["_ConfigType"]), } @@ -602,25 +638,29 @@ export class ActionHelper implements TypeGuard { return (handler)(handlerParams) } - private addActionHandler( + private addActionHandler( plugin: GardenPlugin, actionType: T, handler: PluginActionHandlers[T], ) { const pluginName = plugin.name const schema = pluginActionDescriptions[actionType].resultSchema - const wrapped = async (...args) => { - const result = await handler.apply(plugin, args) - if (result === undefined) { - throw new PluginError(`Got empty response from ${actionType} handler on ${pluginName}`, { - args, - actionType, - pluginName, - }) - } - return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) - } - wrapped["actionType"] = actionType - wrapped["pluginName"] = pluginName + // Wrap the handler with identifying attributes + const wrapped: WrappedPluginActionHandlers[T] = Object.assign( + async (...args: any[]) => { + const result = await handler.apply(plugin, args) + if (result === undefined) { + throw new PluginError(`Got empty response from ${actionType} handler on ${pluginName}`, { + args, + actionType, + pluginName, + }) + } + return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) + }, + { actionType, pluginName }, + ) + + wrapped.base = this.wrapBase(handler.base) // I'm not sure why we need the cast here - JE const typeHandlers: any = this.actionHandlers[actionType] @@ -633,20 +673,23 @@ export class ActionHelper implements TypeGuard { const pluginName = plugin.name const schema = moduleActionDescriptions[actionType].resultSchema - const wrapped = async (...args: any[]) => { - const result = await handler.apply(plugin, args) - if (result === undefined) { - throw new PluginError(`Got empty response from ${moduleType}.${actionType} handler on ${pluginName}`, { - args, - actionType, - pluginName, - }) - } - return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) - } - wrapped["actionType"] = actionType - wrapped["pluginName"] = pluginName - wrapped["moduleType"] = moduleType + // Wrap the handler with identifying attributes + const wrapped = Object.assign( + (async (...args: any[]) => { + const result = await handler.apply(plugin, args) + if (result === undefined) { + throw new PluginError(`Got empty response from ${moduleType}.${actionType} handler on ${pluginName}`, { + args, + actionType, + pluginName, + }) + } + return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) + }), + { actionType, pluginName, moduleType }, + ) + + wrapped.base = this.wrapBase(handler.base) if (!this.moduleActionHandlers[actionType]) { this.moduleActionHandlers[actionType] = {} @@ -660,12 +703,38 @@ export class ActionHelper implements TypeGuard { this.moduleActionHandlers[actionType][moduleType][pluginName] = wrapped } + /** + * Recursively wraps the base handler (if any) on an action handler, such that the base handler receives the _next_ + * base handler as the `base` parameter when called from within the handler. + */ + private wrapBase | WrappedModuleActionHandler>( + handler?: T, + ): T | undefined { + if (!handler) { + return undefined + } + + const base = this.wrapBase(handler.base) + + const wrapped = Object.assign( + async (params) => { + // Override the base parameter, to recursively allow each base to call its base. + params.log.silly(`Calling base handler for ${handler.actionType} handler on plugin '${handler.pluginName}'`) + + return handler({ ...params, base }) + }, + { ...handler, base }, + ) + + return wrapped + } + /** * Get a handler for the specified action. */ - public async getActionHandlers( + public async getActionHandlers( actionType: T, pluginName?: string, - ): Promise> { + ): Promise> { return this.filterActionHandlers(this.actionHandlers[actionType], pluginName) } @@ -675,7 +744,7 @@ export class ActionHelper implements TypeGuard { public async getModuleActionHandlers( { actionType, moduleType, pluginName }: { actionType: T, moduleType: string, pluginName?: string }, - ): Promise> { + ): Promise> { return this.filterActionHandlers((this.moduleActionHandlers[actionType] || {})[moduleType], pluginName) } @@ -689,26 +758,30 @@ export class ActionHelper implements TypeGuard { handlers = {} } - return !pluginName ? handlers : pickBy(handlers, (handler) => handler["pluginName"] === pluginName) + return !pluginName ? handlers : pickBy(handlers, (handler) => handler.pluginName === pluginName) } /** * 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?: PluginActionHandlers[T] }, - ): Promise { + ): Promise { const handlers = Object.values(await this.getActionHandlers(actionType, pluginName)) + // Since we only allow retrieving by plugin name, the length is always either 0 or 1 if (handlers.length) { this.garden.log.silly(`Found '${actionType}' handler on '${pluginName}'`) return handlers[handlers.length - 1] } else if (defaultHandler) { - defaultHandler["pluginName"] = defaultProvider.name this.garden.log.silly(`Returned default '${actionType}' handler for '${pluginName}'`) - return defaultHandler + return Object.assign( + // TODO: figure out why we need the cast here + defaultHandler, + { actionType, pluginName: defaultProvider.name }, + ) } const errorDetails = { @@ -734,39 +807,150 @@ export class ActionHelper implements TypeGuard { public async getModuleActionHandler( { actionType, moduleType, pluginName, defaultHandler }: { actionType: T, moduleType: string, pluginName?: string, defaultHandler?: ModuleAndRuntimeActionHandlers[T] }, - ): Promise { - + ): Promise { const handlers = Object.values(await this.getModuleActionHandlers({ actionType, moduleType, pluginName })) - if (handlers.length) { - return handlers[handlers.length - 1] - } else if (defaultHandler) { - defaultHandler["pluginName"] = defaultProvider.name - return defaultHandler - } + if (handlers.length === 1) { + // Nice and simple, just return the only applicable handler + return handlers[0] + } else if (handlers.length > 0) { + // Multiple matches. We start by filtering down to "leaf nodes", i.e. handlers which are not being overridden + // by other matched handlers. + const filtered = handlers.filter(handler => { + for (const other of handlers) { + if (other === handler) { + continue + } + + const plugin = this.loadedPlugins[other.pluginName] + const bases = getPluginBases(plugin, this.loadedPlugins) + const deps = getPluginDependencies(plugin, this.loadedPlugins) + const allDepNames = [...getNames(bases), ...getNames(deps)] + + if (allDepNames.includes(handler.pluginName)) { + // This handler is in `other`'s dependency chain, so `other` is overriding it + return false + } + } + return true + }) - const errorDetails = { - requestedHandlerType: actionType, - requestedModuleType: moduleType, - environment: this.garden.environmentName, - pluginName, - } + if (filtered.length > 1) { + // If we still end up with multiple handlers with no obvious best candidate, we use the order of configuration + // as a tie-breaker. + const configs = this.garden.getRawProviderConfigs() + + for (const config of configs.reverse()) { + for (const handler of handlers) { + if (handler.pluginName === config.name) { + return handler + } + } + } - if (pluginName) { - throw new PluginError( - `Plugin '${pluginName}' does not have a '${actionType}' handler for module type '${moduleType}'.`, - errorDetails, + // This should never happen + throw new InternalError( + `Unable to find any matching configuration when selecting ${moduleType}/${actionType} handler ` + + `(please report this as a bug).`, + { handlers, configs }, + ) + } else { + return filtered[0] + } + + } else if (defaultHandler) { + // Return the default handler, but wrap it to match the expected interface. + return Object.assign( + defaultHandler, + { actionType, moduleType, pluginName: defaultProvider.name }, ) } else { - throw new ParameterError( - `No '${actionType}' handler configured for module type '${moduleType}' in environment ` + - `'${this.garden.environmentName}'. Are you missing a provider configuration?`, - errorDetails, - ) + // Nothing matched, throw error. + const errorDetails = { + requestedHandlerType: actionType, + requestedModuleType: moduleType, + environment: this.garden.environmentName, + pluginName, + } + + if (pluginName) { + throw new PluginError( + `Plugin '${pluginName}' does not have a '${actionType}' handler for module type '${moduleType}'.`, + errorDetails, + ) + } else { + throw new ParameterError( + `No '${actionType}' handler configured for module type '${moduleType}' in environment ` + + `'${this.garden.environmentName}'. Are you missing a provider configuration?`, + errorDetails, + ) + } } } } +type CommonParams = keyof PluginActionContextParams + +type WrappedServiceActionHandlers = { + [P in keyof ServiceActionParams]: WrappedModuleActionHandler[P], ServiceActionOutputs[P]> +} + +type WrappedTaskActionHandlers = { + [P in keyof TaskActionParams]: WrappedModuleActionHandler[P], TaskActionOutputs[P]> +} + +type WrappedModuleActionHandlers = { + [P in keyof ModuleActionParams]: WrappedModuleActionHandler[P], ModuleActionOutputs[P]> +} + +type WrappedModuleAndRuntimeActionHandlers = + WrappedModuleActionHandlers & WrappedServiceActionHandlers & WrappedTaskActionHandlers + +type WrappedPluginActionHandlers = { + [P in keyof PluginActionParams]: + WrappedActionHandler +} + +interface WrappedActionHandlerMap { + [actionName: string]: WrappedPluginActionHandlers[T] +} + +interface WrappedModuleActionHandlerMap { + [actionName: string]: WrappedModuleAndRuntimeActionHandlers[T] +} + +type WrappedPluginActionMap = { + [A in keyof WrappedPluginActionHandlers]: { + [pluginName: string]: WrappedPluginActionHandlers[A], + } +} + +type WrappedModuleActionMap = { + [A in keyof ModuleAndRuntimeActionHandlers]: { + [moduleType: string]: { + [pluginName: string]: WrappedModuleAndRuntimeActionHandlers[A], + }, + } +} + +// avoid having to specify common params on each action helper call +type ActionHelperParams = + Omit & { pluginName?: string } + +type ModuleActionHelperParams = + Omit & { pluginName?: string } +// additionally make runtimeContext param optional + +type ServiceActionHelperParams = + Omit + & { pluginName?: string } + +type TaskActionHelperParams = + Omit + & { pluginName?: string } + +type RequirePluginName = T & { pluginName: string } + const dummyLogStreamer = async ({ service, log }: GetServiceLogsParams) => { log.warn({ section: service.name, diff --git a/garden-service/src/commands/plugins.ts b/garden-service/src/commands/plugins.ts index 2d3eba8bce..7ef080cb37 100644 --- a/garden-service/src/commands/plugins.ts +++ b/garden-service/src/commands/plugins.ts @@ -54,7 +54,7 @@ export class PluginsCommand extends Command { arguments = pluginArgs async action({ garden, log, args }: CommandParams): Promise { - const providerConfigs = await garden.getRawProviderConfigs() + const providerConfigs = garden.getRawProviderConfigs() const configuredPlugins = providerConfigs.map(p => p.name) if (!args.command) { @@ -85,7 +85,7 @@ export class PluginsCommand extends Command { } const provider = await garden.resolveProvider(args.plugin) - const ctx = await garden.getPluginContext(provider) + const ctx = garden.getPluginContext(provider) try { const { result, errors = [] } = await command.handler({ ctx, log }) diff --git a/garden-service/src/config/provider.ts b/garden-service/src/config/provider.ts index d39cc53a16..0eb62b425a 100644 --- a/garden-service/src/config/provider.ts +++ b/garden-service/src/config/provider.ts @@ -90,7 +90,12 @@ export function providerFromConfig( } } -export async function getProviderDependencies(plugin: GardenPlugin, config: ProviderConfig) { +/** + * Given a plugin and its provider config, return a list of dependency names based on declared dependencies, + * as well as implicit dependencies based on template strings. + */ +export async function getAllProviderDependencyNames(plugin: GardenPlugin, config: ProviderConfig) { + // Declared dependencies from config const deps: string[] = [...plugin.dependencies || []] // Implicit dependencies from template strings diff --git a/garden-service/src/docs/config.ts b/garden-service/src/docs/config.ts index 7a51f8900c..dac0acd7cc 100644 --- a/garden-service/src/docs/config.ts +++ b/garden-service/src/docs/config.ts @@ -24,7 +24,8 @@ 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, ModuleTypeDefinition } from "../types/plugin/plugin" +import { GardenPlugin, ModuleTypeDefinition, PluginMap } from "../types/plugin/plugin" +import { getPluginBases } from "../tasks/resolve-provider" export const TEMPLATES_DIR = resolve(GARDEN_SERVICE_ROOT, "src", "docs", "templates") const partialTemplatePath = resolve(TEMPLATES_DIR, "config-partial.hbs") @@ -423,8 +424,21 @@ function renderTemplateStringReference( * Generates the provider reference from the provider.hbs template. * The reference includes the rendered output from the config-partial.hbs template. */ -function renderProviderReference(name: string, plugin: GardenPlugin) { - const schema = populateProviderSchema(plugin.configSchema || providerConfigBaseSchema) +function renderProviderReference(name: string, plugin: GardenPlugin, allPlugins: PluginMap) { + let configSchema = plugin.configSchema + + // If the plugin doesn't specify its own config schema, we need to walk through its bases to get a schema to document + if (!configSchema) { + for (const base of getPluginBases(plugin, allPlugins)) { + if (base.configSchema) { + configSchema = base.configSchema + break + } + } + } + + const schema = populateProviderSchema(configSchema || providerConfigBaseSchema) + const moduleOutputsSchema = plugin.outputsSchema const providerTemplatePath = resolve(TEMPLATES_DIR, "provider.hbs") @@ -555,7 +569,12 @@ export async function writeConfigReferenceDocs(docsRoot: string) { }) const providerDir = resolve(referenceDir, "providers") - for (const [name, plugin] of Object.entries(await garden.getPlugins())) { + const plugins = await garden.getPlugins() + const pluginsByName = keyBy(plugins, "name") + + for (const plugin of plugins) { + const name = plugin.name + // Currently nothing to document for these if (name === "container" || name === "exec") { continue @@ -563,7 +582,7 @@ export async function writeConfigReferenceDocs(docsRoot: string) { const path = resolve(providerDir, `${name}.md`) console.log("->", path) - writeFileSync(path, renderProviderReference(name, plugin)) + writeFileSync(path, renderProviderReference(name, plugin, pluginsByName)) } // Render module type docs diff --git a/garden-service/src/garden.ts b/garden-service/src/garden.ts index d537cbf6dd..5b095638a9 100644 --- a/garden-service/src/garden.ts +++ b/garden-service/src/garden.ts @@ -6,17 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import Bluebird = require("bluebird") +import Bluebird from "bluebird" import { parse, relative, resolve, dirname } from "path" -import { flatten, isString, cloneDeep, sortBy, set, fromPairs, keyBy } from "lodash" +import { flatten, isString, cloneDeep, sortBy, fromPairs, keyBy, uniq } from "lodash" const AsyncLock = require("async-lock") import { TreeCache } from "./cache" import { builtinPlugins } from "./plugins/plugins" import { Module, getModuleCacheContext, getModuleKey, ModuleConfigMap } from "./types/module" -import { pluginModuleSchema, pluginSchema } from "./types/plugin/plugin" +import { pluginModuleSchema, pluginSchema, ModuleTypeDefinition, ModuleTypeExtension } from "./types/plugin/plugin" import { SourceConfig, ProjectConfig, resolveProjectConfig, pickEnvironment } from "./config/project" -import { findByName, pickKeys, getPackageVersion, pushToKey } from "./util/util" +import { findByName, pickKeys, getPackageVersion, pushToKey, getNames } from "./util/util" import { ConfigurationError, PluginError, RuntimeError } from "./exceptions" import { VcsHandler, ModuleVersion } from "./vcs/vcs" import { GitHandler } from "./vcs/git" @@ -41,10 +41,10 @@ import { LogEntry } from "./logger/log-entry" import { EventBus } from "./events" import { Watcher } from "./watch" import { findConfigPathsInPath, getConfigFilePath, getWorkingCopyId, fixedExcludes } from "./util/fs" -import { Provider, ProviderConfig, getProviderDependencies, defaultProvider } from "./config/provider" -import { ResolveProviderTask } from "./tasks/resolve-provider" +import { Provider, ProviderConfig, getAllProviderDependencyNames, defaultProvider } from "./config/provider" +import { ResolveProviderTask, getPluginBaseNames } from "./tasks/resolve-provider" import { ActionHelper } from "./actions" -import { DependencyGraph, detectCycles, cyclesToString } from "./util/validate-dependencies" +import { detectCycles, cyclesToString, Dependency } from "./util/validate-dependencies" import chalk from "chalk" import { RuntimeContext } from "./runtime-context" import { deline } from "./util/string" @@ -189,7 +189,9 @@ export class Garden { } static async factory( - this: T, currentDirectory: string, opts: GardenOpts = {}, + this: T, + currentDirectory: string, + opts: GardenOpts = {}, ): Promise> { let { environmentName, config, gardenDirPath, plugins = [] } = opts @@ -217,15 +219,24 @@ export class Garden { environmentName = defaultEnvironment } - const { providers, variables } = await pickEnvironment(config, environmentName) + const { providers, variables } = await pickEnvironment( + config, + environmentName, + ) - gardenDirPath = resolve(projectRoot, gardenDirPath || DEFAULT_GARDEN_DIR_NAME) + gardenDirPath = resolve( + projectRoot, + gardenDirPath || DEFAULT_GARDEN_DIR_NAME, + ) const buildDir = await BuildDir.factory(projectRoot, gardenDirPath) const workingCopyId = await getWorkingCopyId(gardenDirPath) const log = opts.log || getLogger().placeholder() // We always exclude the garden dir - const gardenDirExcludePattern = `${relative(projectRoot, gardenDirPath)}/**/*` + const gardenDirExcludePattern = `${relative( + projectRoot, + gardenDirPath, + )}/**/*` const moduleExcludePatterns = [ ...((config.modules || {}).exclude || []), gardenDirExcludePattern, @@ -273,7 +284,10 @@ export class Garden { return this.buildDir.clear() } - async processTasks(tasks: BaseTask[], opts?: ProcessTasksOpts): Promise { + async processTasks( + tasks: BaseTask[], + opts?: ProcessTasksOpts, + ): Promise { return this.taskGraph.process(tasks, opts) } @@ -305,18 +319,18 @@ export class Garden { pluginModule = require(moduleNameOrLocation) } catch (error) { throw new ConfigurationError( - `Unable to load plugin "${moduleNameOrLocation}" (could not load module: ${error.message})`, { - message: error.message, - moduleNameOrLocation, - }) + `Unable to load plugin "${moduleNameOrLocation}" (could not load module: ${error.message})`, + { + message: error.message, + moduleNameOrLocation, + }, + ) } try { - pluginModule = validate( - pluginModule, - pluginModuleSchema, - { context: `plugin module "${moduleNameOrLocation}"` }, - ) + pluginModule = validate(pluginModule, pluginModuleSchema, { + context: `plugin module "${moduleNameOrLocation}"`, + }) } catch (err) { throw new PluginError(`Unable to load plugin: ${err}`, { moduleNameOrLocation, @@ -325,7 +339,6 @@ export class Garden { } plugin = pluginModule.gardenPlugin - } else { plugin = nameOrPlugin } @@ -333,81 +346,257 @@ export class Garden { this.registeredPlugins[plugin.name] = plugin } - private async loadPlugin(pluginName: string) { - this.log.silly(`Loading plugin ${pluginName}`) - let plugin = this.registeredPlugins[pluginName] - - if (!plugin) { - throw new ConfigurationError(`Configured plugin '${pluginName}' has not been registered`, { - name: pluginName, - availablePlugins: Object.keys(this.registeredPlugins), - }) - } - - plugin = validate(plugin, pluginSchema, { context: `plugin "${pluginName}"` }) - - this.log.silly(`Done loading plugin ${pluginName}`) - - return plugin - } - async getPlugin(pluginName: string): Promise { const plugins = await this.getPlugins() const plugin = findByName(plugins, pluginName) if (!plugin) { - throw new PluginError(`Could not find plugin '${pluginName}'. Are you missing a provider configuration?`, { - pluginName, - availablePlugins: Object.keys(this.loadedPlugins), - }) + const availablePlugins = getNames(plugins) + throw new PluginError( + `Could not find plugin '${pluginName}'. Are you missing a provider configuration? ` + + `Currently configured plugins: ${availablePlugins.join(", ")}`, + { + pluginName, + availablePlugins, + }, + ) } return plugin } async getPlugins() { + // The duplicated check is a small optimization to avoid the async lock when possible, + // since this is called quite frequently. + if (this.loadedPlugins) { + return this.loadedPlugins + } + await this.asyncLock.acquire("load-plugins", async () => { + // This check is necessary since we could in theory have two calls waiting for the lock at the same time. if (this.loadedPlugins) { return } this.log.silly(`Loading plugins`) const rawConfigs = this.getRawProviderConfigs() - const loadedPlugins: GardenPlugin[] = [] - const moduleDeclarations: { [moduleType: string]: GardenPlugin[] } = {} - const moduleExtensions: { [moduleType: string]: GardenPlugin[] } = {} + // Plugins that are explicitly configured for the project+environment + const configuredPlugins: { [name: string]: GardenPlugin } = {} + + // All loaded plugins, including base plugins + const loadedPlugins: { [name: string]: GardenPlugin } = {} - await Bluebird.map(rawConfigs, async (config) => { - const plugin = await this.loadPlugin(config.name) - loadedPlugins.push(plugin) + const deps: Dependency[] = [] - for (const spec of plugin.createModuleTypes || []) { - pushToKey(moduleDeclarations, spec.name, plugin) + // TODO: split this out of this method + const loadPlugin = (name: string) => { + this.log.silly(`Loading plugin ${name}`) + let plugin = this.registeredPlugins[name] + + if (!plugin) { + return null } - for (const spec of plugin.extendModuleTypes || []) { - pushToKey(moduleExtensions, spec.name, plugin) + plugin = validate(plugin, pluginSchema, { + context: `plugin "${name}"`, + }) + + loadedPlugins[name] = plugin + + if (plugin.base) { + if (plugin.base === plugin.name) { + throw new PluginError( + `Plugin '${plugin.name}' references itself as a base plugin.`, + { pluginName: plugin.name }, + ) + } + + deps.push({ from: name, to: plugin.base }) + + if (!loadedPlugins[plugin.base]) { + loadPlugin(plugin.base) + } } - }) - // Make sure only one plugin declares each module type - for (const [moduleType, plugins] of Object.entries(moduleDeclarations)) { - if (plugins.length > 1) { + this.log.silly(`Done loading plugin ${name}`) + return plugin + } + + for (const config of rawConfigs) { + const plugin = loadPlugin(config.name) + + if (!plugin) { + throw new ConfigurationError( + `Configured plugin '${config.name}' has not been registered.`, + { + name: config.name, + availablePlugins: Object.keys(this.registeredPlugins), + }, + ) + } + + configuredPlugins[config.name] = plugin + } + + // Check for circular base declarations + const cycles = detectCycles(deps) + + if (cycles.length) { + const cyclesStr = cyclesToString(cycles) + + throw new PluginError( + `One or more circular dependencies found between plugins and their bases: ${cyclesStr}`, + { cycles }, + ) + } + + // Takes a plugin and resolves it against its base plugin, if applicable + // TODO: split this out of this method + const resolvePlugin = (plugin: GardenPlugin): GardenPlugin => { + if (!plugin.base) { + return plugin + } + + // Resolve the plugin base + let base = loadedPlugins[plugin.base] || loadPlugin(plugin.base) + + if (!base) { + throw new ConfigurationError( + `Plugin '${plugin.name}' is based on plugin '${plugin.base}' which has not been registered.`, + { pluginName: plugin.name, base: plugin.base }, + ) + } + + base = resolvePlugin(base) + + const baseIsConfigured = plugin.base in configuredPlugins + + const resolved = { + configKeys: base.configKeys, + outputsSchema: base.outputsSchema, + ...plugin, + } + + // Merge dependencies with base + resolved.dependencies = uniq([ + ...(plugin.dependencies || []), + ...(base.dependencies || []), + ]).sort() + + // TODO: Make sure the plugin doesn't redeclare module types from the base + + // Merge plugin handlers + resolved.handlers = { ...(plugin.handlers || {}) } + + for (const [name, handler] of Object.entries(base.handlers || {})) { + if (!handler) { + continue + } else if (resolved.handlers[name]) { + // Attach the overridden handler as a base, and attach metadata + resolved.handlers[name].base = Object.assign(handler, { actionType: name, pluginName: base.name }) + } else { + resolved.handlers[name] = handler + } + } + + // Merge commands + resolved.commands = [...(plugin.commands || [])] + + for (const baseCommand of base.commands || []) { + const command = findByName(resolved.commands, baseCommand.name) + if (command) { + command.base = baseCommand + } else { + resolved.commands.push(baseCommand) + } + } + + // If the base is not expressly configured for the environment, we pull and coalesce its module declarations. + // We also make sure the plugin doesn't redeclare a module type from the base. + resolved.createModuleTypes = [...plugin.createModuleTypes || []] + resolved.extendModuleTypes = [...plugin.extendModuleTypes || []] + + for (const spec of base.createModuleTypes || []) { + if (findByName(plugin.createModuleTypes || [], spec.name)) { + throw new PluginError( + `Plugin '${plugin.name}' redeclares the '${spec.name}' module type, already declared by its base.`, + { plugin, base }, + ) + } else if (!baseIsConfigured) { + resolved.createModuleTypes.push(spec) + } + } + + if (!baseIsConfigured) { + // Base is not explicitly configured, so we coalesce the module type extensions + for (const baseSpec of base.extendModuleTypes || []) { + const spec = findByName(plugin.extendModuleTypes || [], baseSpec.name) + if (spec) { + // Both plugin and base extend the module type, coalesce them + for (const [name, baseHandler] of Object.entries(baseSpec.handlers)) { + // Pull in handler from base, if it's not specified in the plugin + if (!spec.handlers[name]) { + spec.handlers[name] = cloneDeep(baseHandler) + } + } + } else { + // Only base has the extension for this type, pull it directly + resolved.extendModuleTypes.push(baseSpec) + } + } + } + + return resolved + } + + const moduleDeclarations: { [moduleType: string]: { plugin: GardenPlugin, spec: ModuleTypeDefinition }[] } = {} + const moduleExtensions: { [moduleType: string]: { plugin: GardenPlugin, spec: ModuleTypeExtension }[] } = {} + + for (const plugin of Object.values(configuredPlugins)) { + const resolved = resolvePlugin(plugin) + + // Note: We clone the specs to avoid possible circular references + // (plugin authors may re-use handlers for various reasons). + for (const spec of resolved.createModuleTypes || []) { + pushToKey(moduleDeclarations, spec.name, { + plugin: resolved, + spec: cloneDeep(spec), + }) + } + + for (const spec of resolved.extendModuleTypes || []) { + pushToKey(moduleExtensions, spec.name, { + plugin: resolved, + spec: cloneDeep(spec), + }) + } + + loadedPlugins[plugin.name] = configuredPlugins[plugin.name] = resolved + } + + for (const [moduleType, declarations] of Object.entries(moduleDeclarations)) { + // Make sure only one plugin declares each module type + if (declarations.length > 1) { + const plugins = declarations.map(d => d.plugin.name) + throw new ConfigurationError( - `Module type '${moduleType}' is declared in multiple providers: ${plugins.map(p => p.name).join(", ")}.`, - { moduleType, plugins: plugins.map(p => p.name) }, + `Module type '${moduleType}' is declared in multiple providers: ${plugins.join(", ")}.`, + { moduleType, plugins }, ) } } - // 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 [moduleType, extensions] of Object.entries(moduleExtensions)) { + // We validate above that there is only one declaration per module type + const declaration = moduleDeclarations[moduleType] && moduleDeclarations[moduleType][0] + const declaredBy = declaration && declaration.plugin.name - for (const plugin of plugins) { - if (!declaredBy) { - throw new PluginError(deline` + for (const { plugin, spec } of extensions) { + // Make sure plugins that extend module types correctly declare their dependencies + if (!declaration) { + 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. @@ -416,20 +605,48 @@ export class Garden { ) } - 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, + const bases = getPluginBaseNames(plugin.name, loadedPlugins) + + if ( + declaredBy !== plugin.name && + !bases.includes(declaredBy) && + !(plugin.dependencies && plugin.dependencies.includes(declaredBy)) + ) { + throw new PluginError( + deline` + Plugin '${plugin.name}' extends module type '${moduleType}', declared by the '${declaredBy}' 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 }, + { + moduleType, + pluginName: plugin.name, + declaredByName: declaredBy, + bases, + }, ) } + + // Attach base handlers (which are the corresponding declaration handlers, if any) + for (const [name, handler] of Object.entries(spec.handlers)) { + const baseHandler = declaration.spec.handlers[name] + + if (handler && baseHandler) { + // Note: We clone the handler to avoid possible circular references + // (plugin authors may re-use handlers for various reasons). + handler.base = cloneDeep(baseHandler) + handler.base!.actionType = name + handler.base!.moduleType = moduleType + handler.base!.pluginName = declaration.plugin.name + } + } } } - this.loadedPlugins = loadedPlugins - this.log.silly(`Loaded plugins: ${Object.keys(loadedPlugins).join(", ")}`) + this.loadedPlugins = Object.values(loadedPlugins) + this.log.silly( + `Loaded plugins: ${Object.keys(configuredPlugins).join(", ")}`, + ) }) return this.loadedPlugins @@ -445,6 +662,7 @@ export class Garden { } async resolveProvider(name: string) { + this.log.silly(`Resolving provider ${name}`) if (name === "_default") { return defaultProvider } @@ -453,7 +671,10 @@ export class Garden { const provider = findByName(providers, name) if (!provider) { - throw new PluginError(`Could not find provider '${name}'`, { name, providers }) + throw new PluginError(`Could not find provider '${name}'`, { + name, + providers, + }) } return provider @@ -467,34 +688,38 @@ export class Garden { this.log.silly(`Resolving providers`) - const log = this.log.info({ section: "providers", msg: "Getting status...", status: "active" }) + const log = this.log.info({ + section: "providers", + msg: "Getting status...", + status: "active", + }) const rawConfigs = this.getRawProviderConfigs() - const configsByName = keyBy(rawConfigs, "name") - const plugins = await this.getPlugins() + const plugins = keyBy(await this.getPlugins(), "name") // Detect circular deps here - const pluginGraph: DependencyGraph = {} + const pluginDeps: Dependency[] = [] - 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 }) + await Bluebird.map(rawConfigs, async config => { + const plugin = plugins[config.name] + for (const dep of await getAllProviderDependencyNames(plugin!, config!)) { + pluginDeps.push({ from: config!.name, to: dep }) } }) - const cycles = detectCycles(pluginGraph) + const cycles = detectCycles(pluginDeps) if (cycles.length > 0) { const cyclesStr = cyclesToString(cycles) throw new PluginError( - "One or more circular dependencies found between providers or their configurations: " + cyclesStr, + "One or more circular dependencies found between providers or their configurations: " + + cyclesStr, { cycles }, ) } - const tasks = plugins.map((plugin) => { + const tasks = rawConfigs.map(config => { // TODO: actually resolve version, based on the VCS version of the plugin and its dependencies const version = { versionString: getPackageVersion(), @@ -504,7 +729,7 @@ export class Garden { files: [], } - const config = configsByName[plugin.name] + const plugin = plugins[config.name] return new ResolveProviderTask({ garden: this, @@ -517,22 +742,26 @@ export class Garden { }) // Process as many providers in parallel as possible - const taskResults = await this.processTasks(tasks, { concurrencyLimit: plugins.length }) + const taskResults = await this.processTasks(tasks, { + concurrencyLimit: tasks.length, + }) const failed = Object.values(taskResults).filter(r => r && r.error) if (failed.length) { const messages = failed.map(r => `- ${r!.name}: ${r!.error!.message}`) throw new PluginError( - `Failed resolving one or more provider configurations:\n${messages.join("\n")}`, + `Failed resolving one or more providers:\n${messages.join( + "\n", + )}`, { rawConfigs, taskResults, messages }, ) } const providers: Provider[] = Object.values(taskResults).map(result => result!.output) - await Bluebird.map(providers, async (provider) => - Bluebird.map(provider.moduleConfigs, async (moduleConfig) => { + await Bluebird.map(providers, async provider => + Bluebird.map(provider.moduleConfigs, async moduleConfig => { // Make sure module and all nested entities are scoped to the plugin moduleConfig.plugin = provider.name return this.addModule(moduleConfig) @@ -542,7 +771,9 @@ export class Garden { this.resolvedProviders = providers log.setSuccess({ msg: chalk.green("Done"), append: true }) - this.log.silly(`Resolved providers: ${providers.map(p => p.name).join(", ")}`) + this.log.silly( + `Resolved providers: ${providers.map(p => p.name).join(", ")}`, + ) }) return this.resolvedProviders @@ -558,8 +789,13 @@ export class Garden { async getActionHelper() { if (!this.actionHelper) { - const plugins = await this.getPlugins() - this.actionHelper = new ActionHelper(this, plugins) + const loadedPlugins = await this.getPlugins() + const plugins = keyBy(loadedPlugins, "name") + + // We only pass configured plugins to the router (others won't have the required configuration to call handlers) + const configuredPlugins = this.getRawProviderConfigs().map(c => plugins[c.name]) + + this.actionHelper = new ActionHelper(this, configuredPlugins, loadedPlugins) } return this.actionHelper @@ -597,27 +833,43 @@ export class Garden { * plugin handlers). * Scans for modules in the project root and remote/linked sources if it hasn't already been done. */ - async resolveModuleConfigs(keys?: string[], opts: ModuleConfigResolveOpts = {}): Promise { + async resolveModuleConfigs( + keys?: string[], + opts: ModuleConfigResolveOpts = {}, + ): Promise { const actions = await this.getActionHelper() await this.resolveProviders() const configs = await this.getRawModuleConfigs(keys) - keys ? this.log.silly(`Resolving module configs ${keys.join(", ")}`) : this.log.silly(`Resolving module configs`) + keys + ? this.log.silly(`Resolving module configs ${keys.join(", ")}`) + : this.log.silly(`Resolving module configs`) if (!opts.configContext) { opts.configContext = await this.getModuleConfigContext() } - const moduleTypeDefinitions = keyBy(await this.getModuleTypeDefinitions(), "name") + const moduleTypeDefinitions = keyBy( + await this.getModuleTypeDefinitions(), + "name", + ) - return Bluebird.map(configs, async (config) => { - config = await resolveTemplateStrings(cloneDeep(config), opts.configContext!, opts) + return Bluebird.map(configs, async config => { + config = await resolveTemplateStrings( + cloneDeep(config), + opts.configContext!, + opts, + ) const description = moduleTypeDefinitions[config.type] if (!description) { - throw new ConfigurationError(deline` + throw new ConfigurationError( + deline` Unrecognized module type '${config.type}' - (defined at ${relative(this.projectRoot, config.configPath || config.path)}). + (defined at ${relative( + this.projectRoot, + config.configPath || config.path, + )}). Are you missing a provider configuration? `, { config, configuredModuleTypes: Object.keys(moduleTypeDefinitions) }, @@ -641,8 +893,9 @@ export class Garden { - name: foo-module // same as the above */ if (config.build && config.build.dependencies) { - config.build.dependencies = config.build.dependencies - .map(dep => typeof dep === "string" ? { name: dep, copy: [] } : dep) + config.build.dependencies = config.build.dependencies.map(dep => + typeof dep === "string" ? { name: dep, copy: [] } : dep, + ) } // Validate the base config schema @@ -668,9 +921,17 @@ export class Garden { moduleType: config.type, }) - const provider = await this.resolveProvider(configureHandler["pluginName"]) - const ctx = await this.getPluginContext(provider) - config = await configureHandler({ ctx, moduleConfig: config, log: this.log }) + const provider = await this.resolveProvider( + configureHandler.pluginName, + ) + const ctx = this.getPluginContext(provider) + const configureResult = await configureHandler({ + ctx, + moduleConfig: config, + log: this.log, + }) + + config = configureResult.moduleConfig // FIXME: We should be able to avoid this config.name = getModuleKey(config.name, config.plugin) @@ -694,7 +955,10 @@ export class Garden { /** * Returns the module with the specified name. Throws error if it doesn't exist. */ - async resolveModuleConfig(name: string, opts: ModuleConfigResolveOpts = {}): Promise { + async resolveModuleConfig( + name: string, + opts: ModuleConfigResolveOpts = {}, + ): Promise { return (await this.resolveModuleConfigs([name], opts))[0] } @@ -713,7 +977,11 @@ export class Garden { * The combined version is a either the latest dirty module version (if any), or the hash of the module version * and the versions of its dependencies (in sorted order). */ - async resolveVersion(moduleName: string, moduleDependencies: (Module | BuildDependencyConfig)[], force = false) { + async resolveVersion( + moduleName: string, + moduleDependencies: (Module | BuildDependencyConfig)[], + force = false, + ) { this.log.silly(`Resolving version for module ${moduleName}`) const depModuleNames = moduleDependencies.map(m => m.name) @@ -729,11 +997,19 @@ export class Garden { } const config = await this.resolveModuleConfig(moduleName) - const dependencyKeys = moduleDependencies.map(dep => getModuleKey(dep.name, dep.plugin)) + const dependencyKeys = moduleDependencies.map(dep => + getModuleKey(dep.name, dep.plugin), + ) const dependencies = await this.getRawModuleConfigs(dependencyKeys) - const cacheContexts = dependencies.concat([config]).map(c => getModuleCacheContext(c)) - - const version = await this.vcs.resolveVersion(this.log, config, dependencies) + const cacheContexts = dependencies + .concat([config]) + .map(c => getModuleCacheContext(c)) + + const version = await this.vcs.resolveVersion( + this.log, + config, + dependencies, + ) this.cache.set(cacheKey, version, ...cacheContexts) return version @@ -766,12 +1042,18 @@ export class Garden { // Add external sources that are defined at the project level. External sources are either kept in // the .garden/sources dir (and cloned there if needed), or they're linked to a local path via the link command. for (const { name, repositoryUrl } of this.projectSources) { - const path = await this.loadExtSourcePath({ name, repositoryUrl, sourceType: "project" }) + const path = await this.loadExtSourcePath({ + name, + repositoryUrl, + sourceType: "project", + }) extSourcePaths.push(path) } const dirsToScan = [this.projectRoot, ...extSourcePaths] - const modulePaths = flatten(await Bluebird.map(dirsToScan, (path) => this.scanForConfigs(path))) + const modulePaths = flatten( + await Bluebird.map(dirsToScan, path => this.scanForConfigs(path)), + ) const rawConfigs: ModuleConfig[] = [...this.pluginModuleConfigs] @@ -782,7 +1064,7 @@ export class Garden { } }) - await Bluebird.map(rawConfigs, async (config) => this.addModule(config)) + await Bluebird.map(rawConfigs, async config => this.addModule(config)) this.log.silly(`Scanned and found ${rawConfigs.length} modules`) @@ -807,9 +1089,9 @@ export class Garden { if (this.moduleConfigs[key]) { const paths = [this.moduleConfigs[key].path, config.path] - const [pathA, pathB] = (await Bluebird - .map(paths, async (path) => relative(this.projectRoot, await getConfigFilePath(path)))) - .sort() + const [pathA, pathB] = (await Bluebird.map(paths, async path => + relative(this.projectRoot, await getConfigFilePath(path)), + )).sort() throw new ConfigurationError( `Module ${key} is declared multiple times (in '${pathA}' and '${pathB}')`, @@ -840,12 +1122,15 @@ export class Garden { /** * Clones the project/module source if needed and returns the path (either from .garden/sources or from a local path) */ - public async loadExtSourcePath({ name, repositoryUrl, sourceType }: { - name: string, - repositoryUrl: string, - sourceType: ExternalSourceType, + public async loadExtSourcePath({ + name, + repositoryUrl, + sourceType, + }: { + name: string; + repositoryUrl: string; + sourceType: ExternalSourceType; }): Promise { - const linkedSources = await getLinkedSources(this, sourceType) const linked = findByName(linkedSources, name) @@ -854,7 +1139,12 @@ export class Garden { return linked.path } - const path = await this.vcs.ensureRemoteSource({ name, sourceType, url: repositoryUrl, log: this.log }) + const path = await this.vcs.ensureRemoteSource({ + name, + sourceType, + url: repositoryUrl, + log: this.log, + }) return path } diff --git a/garden-service/src/plugins/container/container.ts b/garden-service/src/plugins/container/container.ts index 55b63ae62c..e247de3c7a 100644 --- a/garden-service/src/plugins/container/container.ts +++ b/garden-service/src/plugins/container/container.ts @@ -151,7 +151,7 @@ export async function configureContainerModule({ ctx, moduleConfig }: ConfigureM "deployment-image-name": deploymentImageName, } - return moduleConfig + return { moduleConfig } } export const gardenPlugin = createGardenPlugin({ diff --git a/garden-service/src/plugins/exec.ts b/garden-service/src/plugins/exec.ts index e1bd263c5f..0d52f1df2b 100644 --- a/garden-service/src/plugins/exec.ts +++ b/garden-service/src/plugins/exec.ts @@ -110,7 +110,7 @@ export async function configureExecModule( timeout: t.timeout, })) - return moduleConfig + return { moduleConfig } } export async function getExecModuleBuildStatus({ module }: GetBuildStatusParams): Promise { diff --git a/garden-service/src/plugins/google/google-app-engine.ts b/garden-service/src/plugins/google/google-app-engine.ts index f188063748..dca4d6a12a 100644 --- a/garden-service/src/plugins/google/google-app-engine.ts +++ b/garden-service/src/plugins/google/google-app-engine.ts @@ -40,18 +40,18 @@ export const gardenPlugin = createGardenPlugin({ name: "container", handlers: { async configure(params: ConfigureModuleParams) { - const config = await configureContainerModule(params) + const { moduleConfig } = await configureContainerModule(params) // TODO: we may want to pull this from the service status instead, along with other outputs const project = params.ctx.provider.config.project - const endpoint = `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${config.name}` + const endpoint = `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${moduleConfig.name}` - config.outputs = { - ...config.outputs || {}, + moduleConfig.outputs = { + ...moduleConfig.outputs || {}, endpoint, } - return config + return { moduleConfig } }, async getServiceStatus(): Promise { diff --git a/garden-service/src/plugins/google/google-cloud-functions.ts b/garden-service/src/plugins/google/google-cloud-functions.ts index 41bd60df84..34189e2832 100644 --- a/garden-service/src/plugins/google/google-cloud-functions.ts +++ b/garden-service/src/plugins/google/google-cloud-functions.ts @@ -83,7 +83,7 @@ export async function configureGcfModule( spec: t, })) - return moduleConfig + return { moduleConfig } } const configSchema = providerConfigBaseSchema.keys({ diff --git a/garden-service/src/plugins/kubernetes/container/handlers.ts b/garden-service/src/plugins/kubernetes/container/handlers.ts index fa5c085807..e0d2dfb62a 100644 --- a/garden-service/src/plugins/kubernetes/container/handlers.ts +++ b/garden-service/src/plugins/kubernetes/container/handlers.ts @@ -25,13 +25,15 @@ import { k8sPublishContainerModule } from "./publish" import { getPortForwardHandler } from "../port-forward" async function configure(params: ConfigureModuleParams) { - params.moduleConfig = await configureContainerModule(params) + let { moduleConfig } = await configureContainerModule(params) + params.moduleConfig = moduleConfig return validateConfig(params) } // TODO: avoid having to special-case this (needs framework improvements) export async function configureMaven(params: ConfigureModuleParams) { - params.moduleConfig = await configureMavenContainerModule(params) + let { moduleConfig } = await configureMavenContainerModule(params) + params.moduleConfig = moduleConfig return validateConfig(params) } @@ -62,10 +64,10 @@ export const mavenContainerHandlers = { async function validateConfig(params: ConfigureModuleParams) { // validate ingress specs - const config = params.moduleConfig + const moduleConfig = params.moduleConfig const provider = params.ctx.provider - for (const serviceConfig of config.serviceConfigs) { + for (const serviceConfig of moduleConfig.serviceConfigs) { for (const ingressSpec of serviceConfig.spec.ingresses) { const hostname = ingressSpec.hostname || provider.config.defaultHostname @@ -85,5 +87,5 @@ async function validateConfig(params: ConfigureModule } } - return config + return { moduleConfig } } diff --git a/garden-service/src/plugins/kubernetes/helm/config.ts b/garden-service/src/plugins/kubernetes/helm/config.ts index 521fce60c4..9dbbd259e8 100644 --- a/garden-service/src/plugins/kubernetes/helm/config.ts +++ b/garden-service/src/plugins/kubernetes/helm/config.ts @@ -331,8 +331,8 @@ export async function validateHelmModule({ moduleConfig }: ConfigureModuleParams }) moduleConfig.outputs = { - "release-name": await getReleaseName(moduleConfig), + "release-name": getReleaseName(moduleConfig), } - return moduleConfig + return { moduleConfig } } diff --git a/garden-service/src/plugins/kubernetes/init.ts b/garden-service/src/plugins/kubernetes/init.ts index bae95f1d64..bef5e27519 100644 --- a/garden-service/src/plugins/kubernetes/init.ts +++ b/garden-service/src/plugins/kubernetes/init.ts @@ -82,7 +82,7 @@ export async function getEnvironmentStatus({ ctx, log }: GetEnvironmentStatusPar const variables = getKubernetesSystemVariables(provider.config) const sysGarden = await getSystemGarden(k8sCtx, variables || {}, log) const sysProvider = await sysGarden.resolveProvider(provider.name) - const sysCtx = await sysGarden.getPluginContext(sysProvider) + const sysCtx = sysGarden.getPluginContext(sysProvider) // Check Tiller status in system namespace const tillerStatus = await checkTillerStatus(sysCtx, log) @@ -195,7 +195,7 @@ export async function prepareSystem( // Install Tiller to system namespace const sysGarden = await getSystemGarden(k8sCtx, variables || {}, log) const sysProvider = await sysGarden.resolveProvider(k8sCtx.provider.name) - const sysCtx = await sysGarden.getPluginContext(sysProvider) + const sysCtx = sysGarden.getPluginContext(sysProvider) await sysGarden.clearBuilds() diff --git a/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts b/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts index 1a7fe56f0f..754b71f691 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes-module/config.ts @@ -71,5 +71,5 @@ export async function configureKubernetesModule({ moduleConfig }: ConfigureModul spec: moduleConfig.spec, }] - return moduleConfig + return { moduleConfig } } diff --git a/garden-service/src/plugins/kubernetes/kubernetes.ts b/garden-service/src/plugins/kubernetes/kubernetes.ts index 79c5ab10b3..9857ffb264 100644 --- a/garden-service/src/plugins/kubernetes/kubernetes.ts +++ b/garden-service/src/plugins/kubernetes/kubernetes.ts @@ -77,7 +77,7 @@ export async function configureProvider( config.kubeconfig = resolve(projectRoot, config.kubeconfig) } - return { name: config.name, config } + return { config } } export async function debugInfo({ ctx, log, includeProject }: GetDebugInfoParams): Promise { diff --git a/garden-service/src/plugins/kubernetes/local/local.ts b/garden-service/src/plugins/kubernetes/local/local.ts index 7502c33351..e52dcab7c2 100644 --- a/garden-service/src/plugins/kubernetes/local/local.ts +++ b/garden-service/src/plugins/kubernetes/local/local.ts @@ -6,16 +6,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { gardenPlugin as k8sPlugin } from "../kubernetes" import { configureProvider, configSchema } from "./config" import { createGardenPlugin } from "../../../types/plugin/plugin" export const gardenPlugin = createGardenPlugin({ - ...k8sPlugin, name: "local-kubernetes", + base: "kubernetes", configSchema, handlers: { - ...k8sPlugin.handlers!, configureProvider, }, }) diff --git a/garden-service/src/plugins/kubernetes/system.ts b/garden-service/src/plugins/kubernetes/system.ts index 2e58cb4040..ef347f61c1 100644 --- a/garden-service/src/plugins/kubernetes/system.ts +++ b/garden-service/src/plugins/kubernetes/system.ts @@ -212,12 +212,29 @@ export async function prepareSystemServices( forceBuild: force, }) - const failed = values(results.taskResults).filter(r => r && r.error).length - - if (failed) { - throw new PluginError(`${provider.name}: ${failed} errors occurred when configuring environment`, { - results, - }) + const failed = values(results.taskResults).filter(r => r && r.error).map(r => r!) + const errors = failed.map(r => r.error) + + if (failed.length === 1) { + const error = errors[0] + + throw new PluginError( + `${provider.name}—an error occurred when configuring environment:\n${error}`, + { + error, + results, + }, + ) + } else if (failed.length > 0) { + const errorsStr = errors.map(e => `- ${e}`).join("\n") + + throw new PluginError( + `${provider.name} — ${failed.length} errors occurred when configuring environment:\n${errorsStr}`, + { + errors, + results, + }, + ) } } } 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 9aba3aa0e1..a53de6ccdf 100644 --- a/garden-service/src/plugins/local/local-google-cloud-functions.ts +++ b/garden-service/src/plugins/local/local-google-cloud-functions.ts @@ -65,7 +65,7 @@ export const gardenPlugin = createGardenPlugin({ name: "google-cloud-function", handlers: { async configure(params: ConfigureModuleParams) { - const parsed = await configureGcfModule(params) + const { moduleConfig: parsed } = await configureGcfModule(params) // convert the module and services to containers to run locally const serviceConfigs: ServiceConfig[] = parsed.serviceConfigs.map((s) => { @@ -111,7 +111,7 @@ export const gardenPlugin = createGardenPlugin({ } }) - return { + const moduleConfig = { apiVersion: DEFAULT_API_VERSION, allowPublish: true, build: { @@ -143,6 +143,8 @@ export const gardenPlugin = createGardenPlugin({ taskConfigs: [], testConfigs: parsed.testConfigs, } + + return { moduleConfig } }, }, }], diff --git a/garden-service/src/plugins/maven-container/maven-container.ts b/garden-service/src/plugins/maven-container/maven-container.ts index 1889351aff..7c9e2be1a0 100644 --- a/garden-service/src/plugins/maven-container/maven-container.ts +++ b/garden-service/src/plugins/maven-container/maven-container.ts @@ -127,12 +127,14 @@ export async function configureMavenContainerModule(params: ConfigureModuleParam const configured = await configureContainerModule({ ...params, moduleConfig: containerConfig }) return { - ...configured, - spec: { - ...configured.spec, - jarPath: moduleConfig.spec.jarPath, - jdkVersion, - mvnOpts: moduleConfig.spec.mvnOpts, + moduleConfig: { + ...configured.moduleConfig, + spec: { + ...configured.moduleConfig.spec, + jarPath: moduleConfig.spec.jarPath, + jdkVersion, + mvnOpts: moduleConfig.spec.mvnOpts, + }, }, } } diff --git a/garden-service/src/plugins/openfaas/config.ts b/garden-service/src/plugins/openfaas/config.ts index f799e9cdc4..55a106acc3 100644 --- a/garden-service/src/plugins/openfaas/config.ts +++ b/garden-service/src/plugins/openfaas/config.ts @@ -74,6 +74,7 @@ export const configSchema = providerConfigBaseSchema name: joiProviderName("openfaas"), hostname: joi.string() .hostname() + .allow(null) .description(dedent` The hostname to configure for the function gateway. Defaults to the default hostname of the configured Kubernetes provider. @@ -168,7 +169,7 @@ export async function configureModule( endpoint: await getInternalServiceUrl(ctx, log, moduleConfig), } - return moduleConfig + return ({ moduleConfig }) } async function getInternalGatewayUrl(ctx: PluginContext, log: LogEntry) { diff --git a/garden-service/src/plugins/openfaas/local.ts b/garden-service/src/plugins/openfaas/local.ts index df773c7913..2926144dca 100644 --- a/garden-service/src/plugins/openfaas/local.ts +++ b/garden-service/src/plugins/openfaas/local.ts @@ -7,11 +7,10 @@ */ import { createGardenPlugin } from "../../types/plugin/plugin" -import { gardenPlugin as o6sPlugin } from "./openfaas" -// TODO: avoid having to configure separate plugins, by allowing for this scenario in the plugin mechanism +// TODO: This is deprecated, we should remove it in 0.11. export const gardenPlugin = createGardenPlugin({ - ...o6sPlugin, name: "local-openfaas", + base: "openfaas", dependencies: ["local-kubernetes"], }) diff --git a/garden-service/src/plugins/terraform/module.ts b/garden-service/src/plugins/terraform/module.ts index a5a38e1c52..75bf7b7f78 100644 --- a/garden-service/src/plugins/terraform/module.ts +++ b/garden-service/src/plugins/terraform/module.ts @@ -92,7 +92,7 @@ export async function configureTerraformModule({ ctx, moduleConfig }: ConfigureM spec: moduleConfig.spec, }] - return moduleConfig + return ({ moduleConfig }) } export async function getTerraformStatus( diff --git a/garden-service/src/tasks/resolve-provider.ts b/garden-service/src/tasks/resolve-provider.ts index 67850e0d32..92d2096404 100644 --- a/garden-service/src/tasks/resolve-provider.ts +++ b/garden-service/src/tasks/resolve-provider.ts @@ -8,15 +8,15 @@ import chalk from "chalk" import { BaseTask, TaskParams, TaskType } from "./base" -import { ProviderConfig, Provider, getProviderDependencies, providerFromConfig } from "../config/provider" +import { ProviderConfig, Provider, getAllProviderDependencyNames, providerFromConfig } from "../config/provider" import { resolveTemplateStrings } from "../template-string" -import { ConfigurationError, PluginError } from "../exceptions" -import { keyBy, omit } from "lodash" +import { ConfigurationError, PluginError, RuntimeError } from "../exceptions" +import { keyBy, omit, flatten, uniq } from "lodash" import { TaskResults } from "../task-graph" import { ProviderConfigContext } from "../config/config-context" import { ModuleConfig } from "../config/module" -import { GardenPlugin } from "../types/plugin/plugin" -import { validateWithPath } from "../config/common" +import { GardenPlugin, PluginMap } from "../types/plugin/plugin" +import { validateWithPath, joi } from "../config/common" import Bluebird from "bluebird" import { defaultEnvironmentStatus } from "../types/plugin/provider/getEnvironmentStatus" @@ -52,32 +52,38 @@ export class ResolveProviderTask extends BaseTask { } async getDependencies() { - const deps = await getProviderDependencies(this.plugin, this.config) + const depNames = await getAllProviderDependencyNames(this.plugin, this.config) - const rawProviderConfigs = keyBy(this.garden.getRawProviderConfigs(), "name") + const rawProviderConfigs = this.garden.getRawProviderConfigs() + const plugins = keyBy(await this.garden.getPlugins(), "name") - return Bluebird.map(deps, async (providerName) => { - const config = rawProviderConfigs[providerName] + return flatten(await Bluebird.map(depNames, async (depName) => { + // Match against a provider if its name matches directly, or it inherits from a base named `depName` + const matched = rawProviderConfigs.filter(c => + c.name === depName || getPluginBaseNames(c.name, plugins).includes(depName), + ) - if (!config) { + if (matched.length === 0) { throw new ConfigurationError( - `Missing provider dependency '${providerName}' in configuration for provider '${this.config.name}'. ` + + `Missing provider dependency '${depName}' in configuration for provider '${this.config.name}'. ` + `Are you missing a provider configuration?`, - { config: this.config, missingProviderName: providerName }, + { config: this.config, missingProviderName: depName }, ) } - const plugin = await this.garden.getPlugin(providerName) - - return new ResolveProviderTask({ - garden: this.garden, - plugin, - config, - log: this.log, - version: this.version, - forceInit: this.forceInit, + return matched.map(config => { + const plugin = plugins[depName] + + return new ResolveProviderTask({ + garden: this.garden, + plugin, + config, + log: this.log, + version: this.version, + forceInit: this.forceInit, + }) }) - }) + })) } async process(dependencyResults: TaskResults) { @@ -91,43 +97,70 @@ export class ResolveProviderTask extends BaseTask { const providerName = resolvedConfig.name this.log.silly(`Validating ${providerName} config`) - if (this.plugin.configSchema) { - resolvedConfig = validateWithPath({ - config: omit(resolvedConfig, "path"), - schema: this.plugin.configSchema, + + const validateConfig = (config: ProviderConfig) => { + return validateWithPath({ + config: omit(config, "path"), + schema: this.plugin.configSchema || joi.object(), path: this.garden.projectRoot, projectRoot: this.garden.projectRoot, - configType: "provider", + configType: "provider configuration", ErrorClass: ConfigurationError, }) } + resolvedConfig = validateConfig(resolvedConfig) resolvedConfig.path = this.garden.projectRoot - const configureHandler = (this.plugin.handlers || {}).configureProvider - let moduleConfigs: ModuleConfig[] = [] - if (configureHandler) { - this.log.silly(`Calling configureProvider on ${providerName}`) + this.log.silly(`Calling configureProvider on ${providerName}`) - const configureOutput = await configureHandler({ - log: this.log, - config: resolvedConfig, - configStore: this.garden.configStore, - projectName: this.garden.projectName, - projectRoot: this.garden.projectRoot, - dependencies: resolvedProviders, - }) + const actions = await this.garden.getActionHelper() + + const configureOutput = await actions.configureProvider({ + pluginName: providerName, + log: this.log, + config: resolvedConfig, + configStore: this.garden.configStore, + projectName: this.garden.projectName, + projectRoot: this.garden.projectRoot, + dependencies: resolvedProviders, + }) + + this.log.silly(`Validating ${providerName} config returned from configureProvider handler`) + resolvedConfig = validateConfig(configureOutput.config) + resolvedConfig.path = this.garden.projectRoot - resolvedConfig = configureOutput.config + if (configureOutput.moduleConfigs) { + moduleConfigs = configureOutput.moduleConfigs + } + + // Validating the output config against the base plugins. This is important to make sure base handlers are + // compatible with the config. + const plugins = await this.garden.getPlugins() + const pluginsByName = keyBy(plugins, "name") + const bases = getPluginBases(this.plugin, pluginsByName) - if (configureOutput.moduleConfigs) { - moduleConfigs = configureOutput.moduleConfigs + for (const base of bases) { + if (!base.configSchema) { + continue } + + this.log.silly(`Validating '${providerName}' config against '${base.name}' schema`) + + resolvedConfig = validateWithPath({ + config: omit(resolvedConfig, "path"), + schema: base.configSchema, + path: this.garden.projectRoot, + projectRoot: this.garden.projectRoot, + configType: `provider configuration (base schema from '${base.name}' plugin)`, + ErrorClass: ConfigurationError, + }) } this.log.silly(`Ensuring ${providerName} provider is ready`) + const tmpProvider = providerFromConfig(resolvedConfig, resolvedProviders, moduleConfigs, defaultEnvironmentStatus) const status = await this.ensurePrepared(tmpProvider) @@ -183,3 +216,41 @@ export class ResolveProviderTask extends BaseTask { return status } } + +/** + * Recursively resolves all the bases for the given plugin. + */ +export function getPluginBases(plugin: GardenPlugin, loadedPlugins: PluginMap): GardenPlugin[] { + if (!plugin.base) { + return [] + } + + const base = loadedPlugins[plugin.base] + + if (!base) { + throw new RuntimeError(`Unable to find base plugin '${plugin.base}' for plugin '${plugin.name}'`, { plugin }) + } + + return [base, ...getPluginBases(base, loadedPlugins)] +} + +/** + * Recursively resolves all the base names for the given plugin. + */ +export function getPluginBaseNames(name: string, loadedPlugins: PluginMap) { + return getPluginBases(loadedPlugins[name], loadedPlugins).map(p => p.name) +} + +/** + * Recursively get all declared dependencies for the given plugin, + * i.e. direct dependencies, and dependencies of those dependencies etc. + */ +export function getPluginDependencies(plugin: GardenPlugin, loadedPlugins: PluginMap): GardenPlugin[] { + return uniq(flatten((plugin.dependencies || []).map(depName => { + const depPlugin = loadedPlugins[depName] + if (!depPlugin) { + throw new RuntimeError(`Unable to find dependency '${depName} for plugin '${plugin.name}'`, { plugin }) + } + return [depPlugin, ...getPluginDependencies(depPlugin, loadedPlugins)] + }))) +} diff --git a/garden-service/src/types/plugin/base.ts b/garden-service/src/types/plugin/base.ts index b1b503f8ac..a0645c23c8 100644 --- a/garden-service/src/types/plugin/base.ts +++ b/garden-service/src/types/plugin/base.ts @@ -14,8 +14,9 @@ import { Service, serviceSchema } from "../service" import { Task } from "../task" import { taskSchema } from "../../config/task" import { joi } from "../../config/common" +import { ActionHandlerParamsBase } from "./plugin" -export interface PluginActionContextParams { +export interface PluginActionContextParams extends ActionHandlerParamsBase { ctx: PluginContext } diff --git a/garden-service/src/types/plugin/command.ts b/garden-service/src/types/plugin/command.ts index 3d552df489..4f96b29f2a 100644 --- a/garden-service/src/types/plugin/command.ts +++ b/garden-service/src/types/plugin/command.ts @@ -41,6 +41,7 @@ export interface PluginCommand { title?: string | ((params: { environmentName: string }) => string | Promise) // TODO: allow arguments handler: PluginCommandHandler + base?: PluginCommand } export const pluginCommandSchema = joi.object() diff --git a/garden-service/src/types/plugin/module/configure.ts b/garden-service/src/types/plugin/module/configure.ts index f3149e7454..0421f4f719 100644 --- a/garden-service/src/types/plugin/module/configure.ts +++ b/garden-service/src/types/plugin/module/configure.ts @@ -9,23 +9,25 @@ import { dedent } from "../../../util/string" import { Module } from "../../module" import { PluginContext, pluginContextSchema } from "../../../plugin-context" -import { LogEntry } from "../../../logger/log-entry" -import { logEntrySchema } from "../base" +import { logEntrySchema, PluginActionContextParams } from "../base" import { baseModuleSpecSchema, ModuleConfig, moduleConfigSchema } from "../../../config/module" import { joi } from "../../../config/common" +import { LogEntry } from "../../../logger/log-entry" -export interface ConfigureModuleParams { +export interface ConfigureModuleParams extends PluginActionContextParams { ctx: PluginContext log: LogEntry moduleConfig: T["_ConfigType"] } -export type ConfigureModuleResult = ModuleConfig< - T["spec"], - T["serviceConfigs"][0]["spec"], - T["testConfigs"][0]["spec"], - T["taskConfigs"][0]["spec"] -> +export interface ConfigureModuleResult { + moduleConfig: ModuleConfig< + T["spec"], + T["serviceConfigs"][0]["spec"], + T["testConfigs"][0]["spec"], + T["taskConfigs"][0]["spec"] + > +} export const configure = { description: dedent` @@ -52,5 +54,8 @@ export const configure = { .required(), }), - resultSchema: moduleConfigSchema, + resultSchema: joi.object() + .keys({ + moduleConfig: moduleConfigSchema, + }), } diff --git a/garden-service/src/types/plugin/plugin.ts b/garden-service/src/types/plugin/plugin.ts index 53477e8d96..06de744a82 100644 --- a/garden-service/src/types/plugin/plugin.ts +++ b/garden-service/src/types/plugin/plugin.ts @@ -36,26 +36,57 @@ import { RunResult } from "./base" import { ServiceStatus } from "../service" import { mapValues } from "lodash" import { getDebugInfo, DebugInfo, GetDebugInfoParams } from "./provider/getDebugInfo" -import { deline, dedent } from "../../util/string" +import { dedent } from "../../util/string" import { pluginCommandSchema, PluginCommand } from "./command" import { getPortForward, GetPortForwardParams, GetPortForwardResult } from "./service/getPortForward" import { StopPortForwardParams, stopPortForward } from "./service/stopPortForward" -export type ServiceActionHandlers = { - [P in keyof ServiceActionParams]: (params: ServiceActionParams[P]) => ServiceActionOutputs[P] +export interface ActionHandlerParamsBase { + base?: ActionHandler } -export type TaskActionHandlers = { - [P in keyof TaskActionParams]: (params: TaskActionParams[P]) => TaskActionOutputs[P] +export interface ActionHandler

{ + (params: P): O + base?: WrappedActionHandler +} + +export interface ModuleActionHandler

{ + (params: P): O + base?: WrappedModuleActionHandler +} + +export interface WrappedActionHandler

extends ActionHandler { + actionType: string + pluginName: string +} + +export interface WrappedModuleActionHandler

+ extends WrappedActionHandler { + moduleType: string + base?: WrappedModuleActionHandler +} + +export type PluginActionHandlers = { + [P in keyof PluginActionParams]: ActionHandler } export type ModuleActionHandlers = { - [P in keyof ModuleActionParams]: (params: ModuleActionParams[P]) => ModuleActionOutputs[P] + [P in keyof ModuleActionParams]: ModuleActionHandler[P], ModuleActionOutputs[P]> +} + +export type ServiceActionHandlers = { + [P in keyof ServiceActionParams]: ModuleActionHandler[P], ServiceActionOutputs[P]> +} + +export type TaskActionHandlers = { + [P in keyof TaskActionParams]: ModuleActionHandler[P], TaskActionOutputs[P]> } export type ModuleAndRuntimeActionHandlers = ModuleActionHandlers & ServiceActionHandlers & TaskActionHandlers +export type AllActionHandlers = PluginActionHandlers & ModuleAndRuntimeActionHandlers + export type PluginActionName = keyof PluginActionHandlers export type ServiceActionName = keyof ServiceActionHandlers export type TaskActionName = keyof TaskActionHandlers @@ -64,8 +95,8 @@ export type ModuleActionName = keyof ModuleActionHandlers export interface PluginActionDescription { description: string // TODO: specify the schemas using primitives and not Joi objects - paramsSchema: Joi.Schema - resultSchema: Joi.Schema + paramsSchema: Joi.ObjectSchema + resultSchema: Joi.ObjectSchema } export interface PluginActionParams { @@ -96,11 +127,7 @@ export interface PluginActionOutputs { getDebugInfo: Promise } -export type PluginActionHandlers = { - [P in keyof PluginActionParams]: (params: PluginActionParams[P]) => PluginActionOutputs[P] -} - -export const pluginActionDescriptions: { [P in PluginActionName]: PluginActionDescription } = { +const _pluginActionDescriptions: { [P in PluginActionName]: PluginActionDescription } = { configureProvider, getEnvironmentStatus, prepareEnvironment, @@ -113,7 +140,21 @@ export const pluginActionDescriptions: { [P in PluginActionName]: PluginActionDe getDebugInfo, } -export interface ServiceActionParams { +// No way currently to further validate the shape of the super function +const baseHandlerSchema = joi.func().arity(1) + .description( + "When a handler is overriding a handler from a base plugin, this is provided to call the base handler. " + + "This accepts the same parameters as the handler calling it.", + ) + +export const pluginActionDescriptions = mapValues(_pluginActionDescriptions, desc => ({ + ...desc, + paramsSchema: desc.paramsSchema.keys({ + base: baseHandlerSchema, + }), +})) + +interface _ServiceActionParams { deployService: DeployServiceParams deleteService: DeleteServiceParams execInService: ExecInServiceParams @@ -125,6 +166,13 @@ export interface ServiceActionParams { stopPortForward: StopPortForwardParams } +// Specify base parameter more precisely than the base schema +export type ServiceActionParams = { + [P in keyof _ServiceActionParams]: + _ServiceActionParams[P] + & { base?: WrappedModuleActionHandler<_ServiceActionParams[P], ServiceActionOutputs[P]> } +} + export interface ServiceActionOutputs { deployService: Promise deleteService: Promise @@ -149,11 +197,18 @@ export const serviceActionDescriptions: { [P in ServiceActionName]: PluginAction stopPortForward, } -export interface TaskActionParams { +interface _TaskActionParams { getTaskResult: GetTaskResultParams runTask: RunTaskParams } +// Specify base parameter more precisely than the base schema +export type TaskActionParams = { + [P in keyof _TaskActionParams]: + _TaskActionParams[P] + & { base?: WrappedModuleActionHandler<_TaskActionParams[P], TaskActionOutputs[P]> } +} + export interface TaskActionOutputs { runTask: Promise getTaskResult: Promise @@ -164,7 +219,7 @@ export const taskActionDescriptions: { [P in TaskActionName]: PluginActionDescri runTask, } -export interface ModuleActionParams { +interface _ModuleActionParams { configure: ConfigureModuleParams getBuildStatus: GetBuildStatusParams build: BuildModuleParams @@ -174,6 +229,13 @@ export interface ModuleActionParams { getTestResult: GetTestResultParams } +// Specify base parameter more precisely than the base schema +export type ModuleActionParams = { + [P in keyof _ModuleActionParams]: + _ModuleActionParams[P] + & { base?: WrappedModuleActionHandler<_ModuleActionParams[P], ModuleActionOutputs[P]> } +} + export interface ModuleActionOutputs extends ServiceActionOutputs { configure: Promise getBuildStatus: Promise @@ -184,7 +246,7 @@ export interface ModuleActionOutputs extends ServiceActionOutputs { getTestResult: Promise } -export const moduleActionDescriptions: +const _moduleActionDescriptions: { [P in ModuleActionName | ServiceActionName | TaskActionName]: PluginActionDescription } = { configure, getBuildStatus, @@ -198,6 +260,13 @@ export const moduleActionDescriptions: ...taskActionDescriptions, } +export const moduleActionDescriptions = mapValues(_moduleActionDescriptions, desc => ({ + ...desc, + paramsSchema: desc.paramsSchema.keys({ + base: baseHandlerSchema, + }), +})) + export const pluginActionNames: PluginActionName[] = Object.keys(pluginActionDescriptions) export const moduleActionNames: ModuleActionName[] = Object.keys(moduleActionDescriptions) @@ -235,6 +304,10 @@ export interface GardenPluginSpec { export interface GardenPlugin extends GardenPluginSpec { } +export interface PluginMap { + [name: string]: GardenPlugin +} + export type RegisterPluginParam = string | GardenPlugin const extendModuleTypeSchema = joi.object() @@ -259,8 +332,8 @@ const createModuleTypeSchema = extendModuleTypeSchema 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}\`). + A valid Joi schema describing the keys that each module outputs at config resolution 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. `), @@ -301,38 +374,84 @@ export const pluginSchema = joi.object() .description("The name of the plugin."), base: joiIdentifier() .description(dedent` - Name of a plugin to use as a base for this plugin. If + Name of a plugin to use as a base for this plugin. If you specify this, your provider will inherit all of the + schema and functionality from the base plugin. Please review other fields for information on how individual + fields can be overridden or extended. `), dependencies: joiArray(joi.string()) - .description(deline` + .description(dedent` 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. + + If you specify a \`base\`, these dependencies are added in addition to the dependencies of the base plugin. + + When you specify a dependency which matches another plugin's \`base\`, that plugin will be matched. This + allows you to depend on at least one instance of a plugin of a certain base type being configured, without + having to explicitly depend on any specific sub-type of that base. Note that this means that a single declared + dependency may result in a match with multiple other plugins, if they share a matching base plugin. `), // 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), + configSchema: joi.object({ isJoi: joi.boolean().only(true).required() }) + .unknown(true) + .description(dedent` + The schema for the provider configuration (which the user specifies in the Garden Project configuration). + + If the provider has a \`base\` configured, this schema must either describe a superset of the base plugin + \`configSchema\` _or_ you must specify a \`configureProvider\` handler which returns a configuration that + matches the base plugin's schema. This is to guarantee that the handlers from the base plugin get the + configuration schema they expect. + `), + + outputsSchema: joi.object({ isJoi: joi.boolean().only(true).required() }) + .unknown(true) + .description(dedent` + The schema for the provider configuration (which the user specifies in the Garden Project configuration). + + If the provider has a \`base\` configured, this schema must describe a superset of the base plugin + \`outputsSchema\`. + `), handlers: joi.object().keys(mapValues(pluginActionDescriptions, () => joi.func())) - .description("A map of plugin action handlers provided by the plugin."), + .description(dedent` + A map of plugin action handlers provided by the plugin. + + If you specify a \`base\`, you can use this field to add new handlers or override the handlers from the base + plugin. Any handlers you override will receive a \`base\` parameter with the overridden handler, so that you + can optionally call the original handler from the base plugin. + `), + commands: joi.array().items(pluginCommandSchema) .unique("name") - .description("List of commands that this plugin exposes (via \`garden plugins \`"), + .description(dedent` + List of commands that this plugin exposes (via \`garden plugins \`. + + If you specify a \`base\`, new commands are added in addition to the commands of the base plugin, and if you + specify a command with the same name as one in the base plugin, you can override the original. + Any command you override will receive a \`base\` parameter with the overridden handler, so that you can + optionally call the original command from the base plugin. + `), + createModuleTypes: joi.array().items(createModuleTypeSchema) .unique("name") - .description("List of module types to create."), + .description(dedent` + List of module types to create. + + If you specify a \`base\`, these module types are added in addition to the module types created by the base + plugin. To augment the base plugin's module types, use the \`extendModuleTypes\` field. + `), extendModuleTypes: joi.array().items(extendModuleTypeSchema) .unique("name") - .description("List of module types to extend/override with additional handlers."), + .description(dedent` + List of module types to extend/override with additional handlers. + `), }) .description("The schema for Garden plugins.") export const pluginModuleSchema = joi.object() .keys({ - name: joiIdentifier(), - gardenPlugin: joi.func().required() - .description("The initialization function for the plugin. Should return a valid Garden plugin object."), + gardenPlugin: pluginSchema.required(), }) .unknown(true) .description("A module containing a Garden plugin.") diff --git a/garden-service/src/types/plugin/provider/configureProvider.ts b/garden-service/src/types/plugin/provider/configureProvider.ts index f14036146a..3cf563a332 100644 --- a/garden-service/src/types/plugin/provider/configureProvider.ts +++ b/garden-service/src/types/plugin/provider/configureProvider.ts @@ -9,20 +9,23 @@ import dedent = require("dedent") import { projectNameSchema, projectRootSchema } from "../../../config/project" import { ProviderConfig, Provider, providerConfigBaseSchema, providersSchema } from "../../../config/provider" -import { LogEntry } from "../../../logger/log-entry" import { logEntrySchema } from "../base" import { configStoreSchema, ConfigStore } from "../../../config-store" import { joiArray, joi } from "../../../config/common" import { moduleConfigSchema, ModuleConfig } from "../../../config/module" import { deline } from "../../../util/string" +import { ActionHandler, ActionHandlerParamsBase } from "../plugin" +import { LogEntry } from "../../../logger/log-entry" -export interface ConfigureProviderParams { - config: T +// Note: These are the only plugin handler params that don't inherit from PluginActionParamsBase +export interface ConfigureProviderParams extends ActionHandlerParamsBase { log: LogEntry + config: T projectName: string projectRoot: string dependencies: Provider[] configStore: ConfigStore + base?: ActionHandler, Promise>> } export interface ConfigureProviderResult { diff --git a/garden-service/src/util/util.ts b/garden-service/src/util/util.ts index 16a5e3ac23..07f2c5c365 100644 --- a/garden-service/src/util/util.ts +++ b/garden-service/src/util/util.ts @@ -402,6 +402,12 @@ export function hashString(s: string, length: number) { */ export function pushToKey(obj: object, key: string, value: any) { if (obj[key]) { + if (!isArray(obj[key])) { + throw new RuntimeError(`Value at '${key}' is not an array`, { + obj, + key, + }) + } obj[key].push(value) } else { obj[key] = [value] diff --git a/garden-service/src/util/validate-dependencies.ts b/garden-service/src/util/validate-dependencies.ts index 5138ad7c44..f20f250449 100644 --- a/garden-service/src/util/validate-dependencies.ts +++ b/garden-service/src/util/validate-dependencies.ts @@ -106,8 +106,8 @@ export type Cycle = string[] */ export function detectCircularModuleDependencies(moduleConfigs: ModuleConfig[]): ConfigurationError | null { // Sparse matrices - const buildGraph: DependencyGraph = {} - const runtimeGraph: DependencyGraph = {} + const buildDeps: Dependency[] = [] + const runtimeDeps: Dependency[] = [] const services: ServiceConfig[] = [] const tasks: TaskConfig[] = [] @@ -119,28 +119,36 @@ export function detectCircularModuleDependencies(moduleConfigs: ModuleConfig[]): for (const module of moduleConfigs) { // Build dependencies for (const buildDep of module.build.dependencies) { - const depName = getModuleKey(buildDep.name, buildDep.plugin) - set(buildGraph, [module.name, depName], { distance: 1, next: depName }) + buildDeps.push({ + from: module.name, + to: getModuleKey(buildDep.name, buildDep.plugin), + }) } // Runtime (service & task) dependencies for (const service of module.serviceConfigs || []) { services.push(service) for (const depName of service.dependencies) { - set(runtimeGraph, [service.name, depName], { distance: 1, next: depName }) + runtimeDeps.push({ + from: service.name, + to: depName, + }) } } for (const task of module.taskConfigs || []) { tasks.push(task) for (const depName of task.dependencies) { - set(runtimeGraph, [task.name, depName], { distance: 1, next: depName }) + runtimeDeps.push({ + from: task.name, + to: depName, + }) } } } - const buildCycles = detectCycles(buildGraph) - const runtimeCycles = detectCycles(runtimeGraph) + const buildCycles = detectCycles(buildDeps) + const runtimeCycles = detectCycles(runtimeDeps) if (buildCycles.length > 0 || runtimeCycles.length > 0) { const detail = {} @@ -169,7 +177,12 @@ export function detectCircularModuleDependencies(moduleConfigs: ModuleConfig[]): return null } -export interface DependencyGraph { +export interface Dependency { + from: string + to: string +} + +interface DependencyGraph { [key: string]: { [target: string]: { distance: number, @@ -185,15 +198,15 @@ export interface DependencyGraph { * * Returns a list of cycles found. */ -export function detectCycles(graph: DependencyGraph): Cycle[] { - // Collect all the vertices - const vertices = uniq( - Object.keys(graph).concat( - flatten( - Object.values(graph).map(v => Object.keys(v)), - ), - ), - ) +export function detectCycles(dependencies: Dependency[]): Cycle[] { + // Collect all the vertices and build a graph object + const vertices = uniq(flatten(dependencies.map(d => [d.from, d.to]))) + + const graph: DependencyGraph = {} + + for (const { from, to } of dependencies) { + set(graph, [from, to], { distance: 1, next: to }) + } // Compute shortest paths for (const k of vertices) { diff --git a/garden-service/test/helpers.ts b/garden-service/test/helpers.ts index ae2a18a24c..6f4de15385 100644 --- a/garden-service/test/helpers.ts +++ b/garden-service/test/helpers.ts @@ -120,7 +120,7 @@ export async function configureTestModule({ moduleConfig }: ConfigureModuleParam timeout: t.timeout, })) - return moduleConfig + return { moduleConfig } } export const testPlugin = createGardenPlugin(() => { diff --git a/garden-service/test/mocha.opts b/garden-service/test/mocha.opts index b1cd8e9205..b0706b6b42 100644 --- a/garden-service/test/mocha.opts +++ b/garden-service/test/mocha.opts @@ -1,7 +1,7 @@ --require source-map-support/register --watch-dirs build/ --exclude build/test/e2e/**/*.js ---exclude test/,src/ +--exclude test/,src/,.garden --watch-extensions js,json,pegjs --reporter spec --timeout 20000 diff --git a/garden-service/test/unit/src/actions.ts b/garden-service/test/unit/src/actions.ts index 73f80dd47c..7ce94b055f 100644 --- a/garden-service/test/unit/src/actions.ts +++ b/garden-service/test/unit/src/actions.ts @@ -5,6 +5,9 @@ import { moduleActionDescriptions, pluginActionDescriptions, createGardenPlugin, + ActionHandler, + GardenPlugin, + ModuleActionHandler, } from "../../../src/types/plugin/plugin" import { Service, ServiceState } from "../../../src/types/service" import { RuntimeContext, prepareRuntimeContext } from "../../../src/runtime-context" @@ -19,6 +22,9 @@ import { Task } from "../../../src/types/task" import { expect } from "chai" import { omit } from "lodash" import { validate, joi } from "../../../src/config/common" +import { ProjectConfig } from "../../../src/config/project" +import { DEFAULT_API_VERSION } from "../../../src/constants" +import { defaultProvider } from "../../../src/config/provider" const now = new Date() @@ -57,6 +63,24 @@ describe("ActionHelper", () => { // Note: The test plugins below implicitly validate input params for each of the tests describe("environment actions", () => { + describe("configureProvider", () => { + it("should configure the provider", async () => { + const config = { foo: "bar" } + const result = await actions.configureProvider({ + pluginName: "test-plugin", + log, + config, + configStore: garden.configStore, + projectName: garden.projectName, + projectRoot: garden.projectRoot, + dependencies: [], + }) + expect(result).to.eql({ + config, + }) + }) + }) + describe("getEnvironmentStatus", () => { it("should return the environment status for a provider", async () => { const result = await actions.getEnvironmentStatus({ log, pluginName: "test-plugin" }) @@ -384,8 +408,8 @@ describe("ActionHelper", () => { const pluginName = "test-plugin-b" const handler = await actionsA.getActionHandler({ actionType: "prepareEnvironment", pluginName }) - expect(handler["actionType"]).to.equal("prepareEnvironment") - expect(handler["pluginName"]).to.equal(pluginName) + expect(handler.actionType).to.equal("prepareEnvironment") + expect(handler.pluginName).to.equal(pluginName) }) it("should throw if no handler is available", async () => { @@ -397,13 +421,22 @@ describe("ActionHelper", () => { }) describe("getModuleActionHandler", () => { - it("should return last configured handler for specified module action type", async () => { + const path = process.cwd() + + it("should return default handler, if specified and no handler is available", async () => { const gardenA = await makeTestGardenA() const actionsA = await gardenA.getActionHelper() - const handler = await actionsA.getModuleActionHandler({ actionType: "deployService", moduleType: "test" }) - - expect(handler["actionType"]).to.equal("deployService") - expect(handler["pluginName"]).to.equal("test-plugin-b") + const defaultHandler = async () => { + return { code: 0, output: "" } + } + const handler = await actionsA.getModuleActionHandler({ + actionType: "execInService", + moduleType: "container", + defaultHandler, + }) + expect(handler.actionType).to.equal("execInService") + expect(handler.moduleType).to.equal("container") + expect(handler.pluginName).to.equal(defaultProvider.name) }) it("should throw if no handler is available", async () => { @@ -414,11 +447,499 @@ describe("ActionHelper", () => { "parameter", ) }) + + context("when no providers extend the module type with requested handler", () => { + it("should return the handler from the provider that created it", async () => { + const foo: GardenPlugin = { + name: "foo", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "foo" }, + ], + variables: {}, + } + + const _garden = await Garden.factory(path, { + plugins: [foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const handler = await _actions.getModuleActionHandler({ actionType: "build", moduleType: "bar" }) + + expect(handler.actionType).to.equal("build") + expect(handler.moduleType).to.equal("bar") + expect(handler.pluginName).to.equal("foo") + }) + }) + + context("when one provider overrides the requested handler on the module type", () => { + it("should return the handler from the extending provider", async () => { + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "base" }, + { name: "foo" }, + ], + variables: {}, + } + + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + + const _garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const handler = await _actions.getModuleActionHandler({ actionType: "build", moduleType: "bar" }) + + expect(handler.actionType).to.equal("build") + expect(handler.moduleType).to.equal("bar") + expect(handler.pluginName).to.equal("foo") + }) + }) + + context("when multiple providers extend the module type with requested handler", () => { + it("should return the handler that is not being overridden by another handler", async () => { + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "base" }, + // The order here matters, to verify that the dependency ordering works + { name: "too" }, + { name: "foo" }, + ], + variables: {}, + } + + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + const too: GardenPlugin = { + name: "too", + dependencies: ["base", "foo"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + + const _garden = await Garden.factory(path, { + plugins: [base, too, foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const handler = await _actions.getModuleActionHandler({ actionType: "build", moduleType: "bar" }) + + expect(handler.actionType).to.equal("build") + expect(handler.moduleType).to.equal("bar") + expect(handler.pluginName).to.equal("too") + }) + + context("when multiple providers are side by side in the dependency graph", () => { + it("should return the last configured handler for the specified module action type", async () => { + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "base" }, + // The order here matters, since we use that as a "tie-breaker" + { name: "foo" }, + { name: "too" }, + ], + variables: {}, + } + + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + const too: GardenPlugin = { + name: "too", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + + const _garden = await Garden.factory(path, { + plugins: [base, too, foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const handler = await _actions.getModuleActionHandler({ actionType: "build", moduleType: "bar" }) + + expect(handler.actionType).to.equal("build") + expect(handler.moduleType).to.equal("bar") + expect(handler.pluginName).to.equal("too") + }) + }) + }) + + context("when the handler was added by a provider and not specified in the creating provider", () => { + it("should return the added handler", async () => { + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "base" }, + { name: "foo" }, + ], + variables: {}, + } + + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "bar", + handlers: { + build: async () => ({}), + }, + }, + ], + } + + const _garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const handler = await _actions.getModuleActionHandler({ actionType: "build", moduleType: "bar" }) + + expect(handler.actionType).to.equal("build") + expect(handler.moduleType).to.equal("bar") + expect(handler.pluginName).to.equal("foo") + }) + }) + }) + + describe("callActionHandler", () => { + it("should call the handler with a base argument if the handler is overriding another", async () => { + const emptyActions = new ActionHelper(garden, [], []) + + const base = Object.assign( + async () => ({ + ready: true, + outputs: {}, + }), + { actionType: "getEnvironmentStatus", pluginName: "base" }, + ) + + const handler: ActionHandler = async (params) => { + expect(params.base).to.equal(base) + + return { ready: true, outputs: {} } + } + + handler.base = base + + await emptyActions["callActionHandler"]({ + actionType: "getEnvironmentStatus", // Doesn't matter which one it is + pluginName: "test-plugin", + params: { + log, + }, + defaultHandler: handler, + }) + }) + + it("should recursively override the base parameter when calling a base handler", async () => { + const baseA: GardenPlugin = { + name: "base-a", + handlers: { + getSecret: async (params) => { + expect(params.base).to.not.exist + return { value: params.key } + }, + }, + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + handlers: { + getSecret: async (params) => { + expect(params.base).to.exist + expect(params.base!.base).to.not.exist + return params.base!(params) + }, + }, + } + const foo: GardenPlugin = { + name: "foo", + base: "base-b", + handlers: { + getSecret: async (params) => { + expect(params.base).to.exist + expect(params.base!.base).to.exist + return params.base!(params) + }, + }, + } + + const path = process.cwd() + + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "foo" }, + ], + variables: {}, + } + + const _garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + const _actions = await _garden.getActionHelper() + + const result = await _actions["callActionHandler"]({ + actionType: "getSecret", // Doesn't matter which one it is + pluginName: "foo", + params: { + key: "foo", + log, + }, + }) + + expect(result).to.eql({ value: "foo" }) + }) + }) + + describe("callModuleHandler", () => { + it("should call the handler with a base argument if the handler is overriding another", async () => { + const emptyActions = new ActionHelper(garden, [], []) + + const graph = await garden.getConfigGraph() + const moduleA = await graph.getModule("module-a") + + const base = Object.assign( + async () => ({ + ready: true, + outputs: {}, + }), + { actionType: "getBuildStatus", pluginName: "base", moduleType: "test" }, + ) + + const handler: ModuleActionHandler = async (params) => { + expect(params.base).to.equal(base) + return { ready: true, outputs: {} } + } + + handler.base = base + + await emptyActions["callModuleHandler"]({ + actionType: "getBuildStatus", // Doesn't matter which one it is + params: { + module: moduleA, + log, + }, + defaultHandler: handler, + }) + }) }) describe("callServiceHandler", () => { + it("should call the handler with a base argument if the handler is overriding another", async () => { + const emptyActions = new ActionHelper(garden, [], []) + + const graph = await garden.getConfigGraph() + const serviceA = await graph.getService("service-a") + + const base = Object.assign( + async () => ({ + forwardablePorts: [], + state: "ready", + detail: {}, + }), + { actionType: "deployService", pluginName: "base", moduleType: "test" }, + ) + + const handler: ModuleActionHandler = async (params) => { + expect(params.base).to.equal(base) + return { forwardablePorts: [], state: "ready", detail: {} } + } + + handler.base = base + + await emptyActions["callServiceHandler"]({ + actionType: "deployService", // Doesn't matter which one it is + params: { + service: serviceA, + runtimeContext, + log, + hotReload: false, + force: false, + }, + defaultHandler: handler, + }) + }) + 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 +985,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}" @@ -508,8 +1029,59 @@ describe("ActionHelper", () => { }) describe("callTaskHandler", () => { + it("should call the handler with a base argument if the handler is overriding another", async () => { + const emptyActions = new ActionHelper(garden, [], []) + + const graph = await garden.getConfigGraph() + const taskA = await graph.getTask("task-a") + + const base = Object.assign( + async () => ({ + moduleName: "module-a", + taskName: "task-a", + command: [], + outputs: { moo: "boo" }, + success: true, + version: task.module.version.versionString, + startedAt: new Date(), + completedAt: new Date(), + log: "boo", + }), + { actionType: "runTask", pluginName: "base", moduleType: "test" }, + ) + + const handler: ModuleActionHandler = async (params) => { + expect(params.base).to.equal(base) + return { + moduleName: "module-a", + taskName: "task-a", + command: [], + outputs: { moo: "boo" }, + success: true, + version: task.module.version.versionString, + startedAt: new Date(), + completedAt: new Date(), + log: "boo", + } + } + + handler.base = base + + await emptyActions["callTaskHandler"]({ + actionType: "runTask", + params: { + task: taskA, + runtimeContext, + log, + taskVersion: task.module.version, + interactive: false, + }, + defaultHandler: handler, + }) + }) + 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}" @@ -550,8 +1122,8 @@ describe("ActionHelper", () => { expect(params.task.spec.foo).to.equal("bar") return { - moduleName: "module-b", - taskName: "task-b", + moduleName: "module-a", + taskName: "task-a", command: [], outputs: { moo: "boo" }, success: true, @@ -565,7 +1137,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}" @@ -612,6 +1184,7 @@ describe("ActionHelper", () => { const testPlugin = createGardenPlugin({ name: "test-plugin", + handlers: { getEnvironmentStatus: async (params) => { validate(params, pluginActionDescriptions.getEnvironmentStatus.paramsSchema) @@ -646,6 +1219,7 @@ const testPlugin = createGardenPlugin({ return { found: true } }, }, + createModuleTypes: [{ name: "test", @@ -674,9 +1248,11 @@ const testPlugin = createGardenPlugin({ })) return { - ...params.moduleConfig, - serviceConfigs, - taskConfigs, + moduleConfig: { + ...params.moduleConfig, + serviceConfigs, + taskConfigs, + }, } }, diff --git a/garden-service/test/unit/src/config/provider.ts b/garden-service/test/unit/src/config/provider.ts index 9e2c12188a..1ce6493ef3 100644 --- a/garden-service/test/unit/src/config/provider.ts +++ b/garden-service/test/unit/src/config/provider.ts @@ -1,5 +1,5 @@ import { expect } from "chai" -import { ProviderConfig, getProviderDependencies } from "../../../../src/config/provider" +import { ProviderConfig, getAllProviderDependencyNames } from "../../../../src/config/provider" import { expectError } from "../../../helpers" import { GardenPlugin } from "../../../../src/types/plugin/plugin" @@ -14,7 +14,7 @@ describe("getProviderDependencies", () => { someKey: "\${providers.other-provider.foo}", anotherKey: "foo-\${providers.another-provider.bar}", } - expect(await getProviderDependencies(plugin, config)).to.eql([ + expect(await getAllProviderDependencyNames(plugin, config)).to.eql([ "another-provider", "other-provider", ]) @@ -26,7 +26,7 @@ describe("getProviderDependencies", () => { someKey: "\${providers.other-provider.foo}", anotherKey: "foo-\${some.other.ref}", } - expect(await getProviderDependencies(plugin, config)).to.eql([ + expect(await getAllProviderDependencyNames(plugin, config)).to.eql([ "other-provider", ]) }) @@ -38,7 +38,7 @@ describe("getProviderDependencies", () => { } await expectError( - () => getProviderDependencies(plugin, config), + () => getAllProviderDependencyNames(plugin, config), (err) => { expect(err.message).to.equal( "Invalid template key 'providers' in configuration for provider 'my-provider'. " + diff --git a/garden-service/test/unit/src/garden.ts b/garden-service/test/unit/src/garden.ts index 50cfeb7898..346566ef2a 100644 --- a/garden-service/test/unit/src/garden.ts +++ b/garden-service/test/unit/src/garden.ts @@ -19,12 +19,12 @@ import { resetLocalConfig, testGitUrl, } from "../../helpers" -import { getNames } from "../../../src/util/util" +import { getNames, findByName } from "../../../src/util/util" 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 { createGardenPlugin } from "../../../src/types/plugin/plugin" +import { createGardenPlugin, GardenPlugin } 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" @@ -243,6 +243,75 @@ describe("Garden", () => { }) describe("getPlugins", () => { + const path = process.cwd() + const projectConfig: ProjectConfig = { + apiVersion: DEFAULT_API_VERSION, + kind: "Project", + name: "test", + path, + defaultEnvironment: "default", + dotIgnoreFiles: [], + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "foo" }, + ], + variables: {}, + } + + it("should attach base from createModuleTypes when overriding a handler via extendModuleTypes", async () => { + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + dependencies: ["base"], + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + }, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: { + ...projectConfig, + providers: [ + ...projectConfig.providers, + { name: "base" }, + ], + }, + }) + + const parsed = await garden.getPlugin("foo") + const extended = findByName(parsed.extendModuleTypes || [], "foo")! + + expect(extended).to.exist + expect(extended.name).to.equal("foo") + expect(extended.handlers.build).to.exist + expect(extended.handlers.build!.base).to.exist + expect(extended.handlers.build!.base!.actionType).to.equal("build") + expect(extended.handlers.build!.base!.moduleType).to.equal("foo") + expect(extended.handlers.build!.base!.pluginName).to.equal("base") + expect(extended.handlers.build!.base!.base).to.not.exist + }) + it("should throw if multiple plugins declare the same module type", async () => { const testPluginDupe = { ...testPlugin, @@ -286,7 +355,7 @@ describe("Garden", () => { ) }) - it("should throw if a plugin extends a module type but doesn't declare a dependency on the base", async () => { + it("should throw if a plugin extends a known module type but doesn't declare dependency on the base", async () => { const plugin = { name: "foo", extendModuleTypes: [{ @@ -311,12 +380,676 @@ describe("Garden", () => { `), ) }) + + context("when a plugin has a base defined", () => { + it("should add and deduplicate declared dependencies on top of the dependencies of the base", async () => { + const base = { + name: "base", + dependencies: ["test-plugin", "test-plugin-b"], + } + const foo = { + name: "foo", + dependencies: ["test-plugin-b", "test-plugin-c"], + base: "base", + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.dependencies).to.eql(["test-plugin", "test-plugin-b", "test-plugin-c"]) + }) + + it("should combine handlers from both plugins and attach base to the handler when overriding", async () => { + const base = { + name: "base", + handlers: { + configureProvider: async ({ config }) => ({ config }), + getEnvironmentStatus: async () => ({ ready: true, outputs: {} }), + }, + } + const foo = { + name: "foo", + base: "base", + handlers: { + configureProvider: async ({ config }) => ({ config }), + }, + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.handlers!.getEnvironmentStatus).to.equal(base.handlers.getEnvironmentStatus) + expect(parsed.handlers!.configureProvider!.base).to.equal(base.handlers.configureProvider) + expect(parsed.handlers!.configureProvider!.base!.actionType).to.equal("configureProvider") + expect(parsed.handlers!.configureProvider!.base!.pluginName).to.equal("base") + expect(parsed.handlers!.configureProvider!.base!.base).to.be.undefined + }) + + it("should combine commands from both plugins and attach base handler when overriding", async () => { + const base = { + name: "base", + commands: [ + { + name: "foo", + description: "foo", + handler: () => ({ result: {} }), + }, + ], + } + const foo = { + name: "foo", + base: "base", + commands: [ + { + name: "foo", + description: "foo", + handler: () => ({ result: {} }), + }, + { + name: "bar", + description: "bar", + handler: () => ({ result: {} }), + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.commands!.length).to.equal(2) + expect(findByName(parsed.commands!, "foo")).to.eql({ + ...foo.commands[0], + base: base.commands[0], + }) + expect(findByName(parsed.commands!, "bar")).to.eql(foo.commands[1]) + }) + + it("should combine defined module types from both plugins", async () => { + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base", + createModuleTypes: [ + { + name: "bar", + docs: "bar", + schema: joi.object(), + handlers: {}, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(findByName(parsed.createModuleTypes || [], "foo")!.name).to.equal("foo") + expect(findByName(parsed.createModuleTypes || [], "bar")!.name).to.equal("bar") + }) + + it("should throw if attempting to redefine a module type defined in the base", async () => { + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base", + createModuleTypes: base.createModuleTypes, + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "Plugin 'foo' redeclares the 'foo' module type, already declared by its base.", + ), + ) + }) + + it("should allow extending a module type from the base", async () => { + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base", + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + getBuildStatus: async () => ({ ready: true }), + }, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + const created = findByName(parsed.createModuleTypes || [], "foo") + const extended = findByName(parsed.extendModuleTypes || [], "foo") + + expect(created).to.exist + expect(created!.name).to.equal("foo") + expect(extended).to.exist + expect(extended!.name).to.equal("foo") + }) + + it("should only extend (and not also create) a module type if the base is also a configured plugin", async () => { + const base: GardenPlugin = { + name: "base", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base", + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + getBuildStatus: async () => ({ ready: true }), + }, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [base, foo], + config: { + ...projectConfig, + providers: [ + ...projectConfig.providers, + { name: "base" }, + ], + }, + }) + + const parsedFoo = await garden.getPlugin("foo") + const parsedBase = await garden.getPlugin("base") + + expect(findByName(parsedBase.createModuleTypes || [], "foo")).to.exist + expect(findByName(parsedFoo.createModuleTypes || [], "foo")).to.not.exist + expect(findByName(parsedFoo.extendModuleTypes || [], "foo")).to.exist + }) + + it("should throw if the base plugin is not registered", async () => { + const foo = { + name: "foo", + base: "base", + } + + const garden = await Garden.factory(path, { + plugins: [foo], + config: projectConfig, + }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "Plugin 'foo' is based on plugin 'base' which has not been registered.", + ), + ) + }) + + it("should throw if plugins have circular bases", async () => { + const foo = { + name: "foo", + base: "bar", + } + const bar = { + name: "bar", + base: "foo", + } + + const garden = await Garden.factory(path, { + plugins: [foo, bar], + config: projectConfig, + }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "One or more circular dependencies found between plugins and their bases: foo <- bar <- foo", + ), + ) + }) + + context("when a plugin's base has a base defined", () => { + it("should add and deduplicate declared dependencies for the whole chain", async () => { + const baseA = { + name: "base-a", + dependencies: ["test-plugin"], + } + const b = { + name: "b", + dependencies: ["test-plugin", "test-plugin-b"], + base: "base-a", + } + const foo = { + name: "foo", + dependencies: ["test-plugin-c"], + base: "b", + } + + const garden = await Garden.factory(path, { + plugins: [baseA, b, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.dependencies).to.eql(["test-plugin", "test-plugin-b", "test-plugin-c"]) + }) + + it("should combine handlers from both plugins and recursively attach base handlers", async () => { + const baseA = { + name: "base-a", + handlers: { + configureProvider: async ({ config }) => ({ config }), + getEnvironmentStatus: async () => ({ ready: true, outputs: {} }), + }, + } + const baseB = { + name: "base-b", + base: "base-a", + handlers: { + configureProvider: async ({ config }) => ({ config }), + }, + } + const foo = { + name: "foo", + base: "base-b", + handlers: { + configureProvider: async ({ config }) => ({ config }), + }, + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.handlers!.getEnvironmentStatus).to.equal(baseA.handlers.getEnvironmentStatus) + expect(parsed.handlers!.configureProvider!.base).to.equal(baseB.handlers.configureProvider) + expect(parsed.handlers!.configureProvider!.base!.base).to.equal(baseA.handlers.configureProvider) + expect(parsed.handlers!.configureProvider!.base!.base!.base).to.be.undefined + }) + + it("should combine commands from all plugins and recursively set base handlers when overriding", async () => { + const baseA = { + name: "base-a", + commands: [ + { + name: "foo", + description: "foo", + handler: () => ({ result: {} }), + }, + ], + } + const baseB = { + name: "base-b", + base: "base-a", + commands: [ + { + name: "foo", + description: "foo", + handler: () => ({ result: {} }), + }, + { + name: "bar", + description: "bar", + handler: () => ({ result: {} }), + }, + ], + } + const foo = { + name: "foo", + base: "base-b", + commands: [ + { + name: "foo", + description: "foo", + handler: () => ({ result: {} }), + }, + { + name: "bar", + description: "bar", + handler: () => ({ result: {} }), + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(parsed.commands!.length).to.equal(2) + + const fooCommand = findByName(parsed.commands!, "foo")! + const barCommand = findByName(parsed.commands!, "bar")! + + expect(fooCommand).to.exist + expect(fooCommand.handler).to.equal(foo.commands[0].handler) + expect(fooCommand.base).to.exist + expect(fooCommand.base!.handler).to.equal(baseB.commands[0].handler) + expect(fooCommand.base!.base).to.exist + expect(fooCommand.base!.base!.handler).to.equal(baseA.commands[0].handler) + expect(fooCommand.base!.base!.base).to.be.undefined + + expect(barCommand).to.exist + expect(barCommand!.handler).to.equal(foo.commands[1].handler) + expect(barCommand!.base).to.exist + expect(barCommand!.base!.handler).to.equal(baseB.commands[1].handler) + expect(barCommand!.base!.base).to.be.undefined + }) + + it("should combine defined module types from all plugins", async () => { + const baseA: GardenPlugin = { + name: "base-a", + createModuleTypes: [ + { + name: "a", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + createModuleTypes: [ + { + name: "b", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base-b", + createModuleTypes: [ + { + name: "c", + docs: "bar", + schema: joi.object(), + handlers: {}, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(findByName(parsed.createModuleTypes || [], "a")!.name).to.equal("a") + expect(findByName(parsed.createModuleTypes || [], "b")!.name).to.equal("b") + expect(findByName(parsed.createModuleTypes || [], "c")!.name).to.equal("c") + }) + + it("should throw if attempting to redefine a module type defined in the base's base", async () => { + const baseA: GardenPlugin = { + name: "base-a", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + createModuleTypes: [], + } + const foo: GardenPlugin = { + name: "foo", + base: "base-b", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: {}, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "Plugin 'foo' redeclares the 'foo' module type, already declared by its base.", + ), + ) + }) + + it("should allow extending module types from the base's base", async () => { + const baseA: GardenPlugin = { + name: "base-a", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + } + const foo: GardenPlugin = { + name: "foo", + base: "base-b", + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + getBuildStatus: async () => ({ ready: true }), + }, + }, + ], + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(findByName(parsed.createModuleTypes || [], "foo")).to.exist + expect(findByName(parsed.extendModuleTypes || [], "foo")).to.exist + }) + + it("should coalesce module type extensions if base plugin is not configured", async () => { + const baseA: GardenPlugin = { + name: "base-a", + createModuleTypes: [ + { + name: "foo", + docs: "foo", + schema: joi.object(), + handlers: { + configure: async ({ moduleConfig }) => ({ moduleConfig }), + build: async () => ({}), + }, + }, + ], + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + }, + }, + ], + } + const baseC: GardenPlugin = { + name: "base-c", + base: "base-b", + extendModuleTypes: [ + { + name: "foo", + handlers: { + build: async () => ({}), + getBuildStatus: async () => ({ ready: true }), + }, + }, + ], + } + const foo: GardenPlugin = { + name: "foo", + base: "base-c", + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, baseC, foo], + config: projectConfig, + }) + + const parsed = await garden.getPlugin("foo") + + expect(findByName(parsed.createModuleTypes || [], "foo")).to.exist + + // Module type extensions should be a combination of base-b and base-c extensions + const fooExtension = findByName(parsed.extendModuleTypes || [], "foo")! + + expect(fooExtension).to.exist + expect(fooExtension.handlers.build).to.exist + expect(fooExtension.handlers.getBuildStatus).to.exist + expect(fooExtension.handlers.build!.base).to.exist + expect(fooExtension.handlers.build!.base!.actionType).to.equal("build") + expect(fooExtension.handlers.build!.base!.moduleType).to.equal("foo") + expect(fooExtension.handlers.build!.base!.pluginName).to.equal("foo") + }) + + it("should throw if plugins have circular bases", async () => { + const baseA = { + name: "base-a", + base: "foo", + } + const baseB = { + name: "base-b", + base: "base-a", + } + const foo = { + name: "foo", + base: "base-b", + } + + const garden = await Garden.factory(path, { + plugins: [baseA, baseB, foo], + config: projectConfig, + }) + + await expectError( + () => garden.getPlugins(), + (err) => expect(err.message).to.equal( + "One or more circular dependencies found between plugins and their bases: foo <- base-b <- base-a <- foo", + ), + ) + }) + }) + }) }) describe("resolveProviders", () => { it("should throw when when plugins are missing", async () => { const garden = await Garden.factory(projectRootA) - await expectError(() => garden.resolveProviders(), "configuration") + await expectError( + () => garden.resolveProviders(), + (err) => expect(err.message).to.equal("Configured plugin 'test-plugin' has not been registered."), + ) }) it("should pass through a basic provider config", async () => { @@ -361,25 +1094,11 @@ describe("Garden", () => { }) it("should call a configureProvider handler if applicable", async () => { - 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", kind: "Project", name: "test", - path: projectRootA, + path: process.cwd(), defaultEnvironment: "default", dotIgnoreFiles: defaultDotIgnoreFiles, environments: [ @@ -391,8 +1110,32 @@ describe("Garden", () => { variables: {}, } - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) - await garden.resolveProviders() + const test = createGardenPlugin({ + name: "test", + handlers: { + async configureProvider({ config }: ConfigureProviderParams) { + expect(config).to.eql({ + name: "test", + path: projectConfig.path, + foo: "bar", + }) + return { config: { ...config, foo: "bla" } } + }, + }, + }) + + const garden = await Garden.factory(projectConfig.path, { + plugins: [test], + config: projectConfig, + }) + + const provider = await garden.resolveProvider("test") + + expect(provider.config).to.eql({ + name: "test", + path: projectConfig.path, + foo: "bla", + }) }) it("should give a readable error if provider configs have invalid template strings", async () => { @@ -420,7 +1163,7 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(), err => expect(err.message).to.equal( - "Failed resolving one or more provider configurations:\n" + + "Failed resolving one or more providers:\n" + "- test: Invalid template string \${bla.ble}: Unable to resolve one or more keys.", ), ) @@ -486,7 +1229,7 @@ describe("Garden", () => { schema: joi.object(), handlers: { configure: async ({ moduleConfig }) => { - return moduleConfig + return { moduleConfig } }, }, }], @@ -725,8 +1468,49 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(), err => expect(stripAnsi(err.message)).to.equal( - "Failed resolving one or more provider configurations:\n- " + - "test: Error validating provider (/garden.yml): key .foo must be a string", + "Failed resolving one or more providers:\n- " + + "test: Error validating provider configuration (/garden.yml): key .foo must be a string", + ), + ) + }) + + it("should throw if configureProvider returns a config that doesn't match a plugin's config schema", async () => { + const test = createGardenPlugin({ + name: "test", + configSchema: providerConfigBaseSchema + .keys({ + foo: joi.string(), + }), + handlers: { + configureProvider: async () => ({ + config: { name: "test", foo: 123 }, + }), + }, + }) + + const projectConfig: ProjectConfig = { + apiVersion: "garden.io/v0", + kind: "Project", + name: "test", + path: projectRootA, + defaultEnvironment: "default", + dotIgnoreFiles: defaultDotIgnoreFiles, + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "test" }, + ], + variables: {}, + } + + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) + + await expectError( + () => garden.resolveProviders(), + err => expect(stripAnsi(err.message)).to.equal( + "Failed resolving one or more providers:\n- " + + "test: Error validating provider configuration (/garden.yml): key .foo must be a string", ), ) }) @@ -772,6 +1556,203 @@ describe("Garden", () => { expect(providerB.config.foo).to.equal("bar") }) + + it("should match a dependency to a plugin base", async () => { + const baseA = createGardenPlugin({ + name: "base-a", + handlers: { + getEnvironmentStatus: async () => { + return { + ready: true, + outputs: { foo: "bar" }, + } + }, + }, + }) + + const testA = createGardenPlugin({ + name: "test-a", + base: "base-a", + }) + + const testB = createGardenPlugin({ + name: "test-b", + dependencies: ["base-a"], + }) + + const projectConfig: ProjectConfig = { + apiVersion: "garden.io/v0", + kind: "Project", + name: "test", + path: projectRootA, + defaultEnvironment: "default", + dotIgnoreFiles: defaultDotIgnoreFiles, + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "test-a" }, + { name: "test-b" }, + ], + variables: {}, + } + + const plugins = [baseA, testA, testB] + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + + const providerA = await garden.resolveProvider("test-a") + const providerB = await garden.resolveProvider("test-b") + + expect(providerB.dependencies).to.eql([providerA]) + }) + + it("should match a dependency to a plugin base that's declared by multiple plugins", async () => { + const baseA = createGardenPlugin({ + name: "base-a", + handlers: { + getEnvironmentStatus: async () => { + return { + ready: true, + outputs: { foo: "bar" }, + } + }, + }, + }) + + // test-a and test-b share one base + const testA = createGardenPlugin({ + name: "test-a", + base: "base-a", + }) + + const testB = createGardenPlugin({ + name: "test-b", + base: "base-a", + }) + + const testC = createGardenPlugin({ + name: "test-c", + dependencies: ["base-a"], + }) + + const projectConfig: ProjectConfig = { + apiVersion: "garden.io/v0", + kind: "Project", + name: "test", + path: projectRootA, + defaultEnvironment: "default", + dotIgnoreFiles: defaultDotIgnoreFiles, + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "test-a" }, + { name: "test-b" }, + { name: "test-c" }, + ], + variables: {}, + } + + const plugins = [baseA, testA, testB, testC] + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins }) + + const providerA = await garden.resolveProvider("test-a") + const providerB = await garden.resolveProvider("test-b") + const providerC = await garden.resolveProvider("test-c") + + expect(providerC.dependencies).to.eql([providerA, providerB]) + }) + + context("when a plugin has a base", () => { + it("should throw if the config for the plugin doesn't match the base's config schema", async () => { + const base = createGardenPlugin({ + name: "base", + configSchema: providerConfigBaseSchema + .keys({ + foo: joi.string(), + }), + }) + + const test = createGardenPlugin({ + name: "test", + base: "base", + }) + + const projectConfig: ProjectConfig = { + apiVersion: "garden.io/v0", + kind: "Project", + name: "test", + path: projectRootA, + defaultEnvironment: "default", + dotIgnoreFiles: defaultDotIgnoreFiles, + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "test", foo: 123 }, + ], + variables: {}, + } + + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [base, test] }) + + await expectError( + () => garden.resolveProviders(), + err => expect(stripAnsi(err.message)).to.equal( + "Failed resolving one or more providers:\n" + + "- test: Error validating provider configuration (base schema from 'base' plugin) " + + "(/garden.yml): key .foo must be a string", + ), + ) + }) + + it("should throw if the configureProvider handler doesn't return a config matching the base", async () => { + const base = createGardenPlugin({ + name: "base", + configSchema: providerConfigBaseSchema + .keys({ + foo: joi.string(), + }), + }) + + const test = createGardenPlugin({ + name: "test", + base: "base", + handlers: { + configureProvider: async () => ({ + config: { name: "test", foo: 123 }, + }), + }, + }) + + const projectConfig: ProjectConfig = { + apiVersion: "garden.io/v0", + kind: "Project", + name: "test", + path: projectRootA, + defaultEnvironment: "default", + dotIgnoreFiles: defaultDotIgnoreFiles, + environments: [ + { name: "default", variables: {} }, + ], + providers: [ + { name: "test" }, + ], + variables: {}, + } + + const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [base, test] }) + + await expectError( + () => garden.resolveProviders(), + err => expect(stripAnsi(err.message)).to.equal( + "Failed resolving one or more providers:\n" + + "- test: Error validating provider configuration (base schema from 'base' plugin) " + + "(/garden.yml): key .foo must be a string", + ), + ) + }) + }) }) describe("scanForConfigs", () => { @@ -932,10 +1913,6 @@ describe("Garden", () => { expect(module!.path).to.equal(join(projectRoot, ".garden", "sources", "module", `module-a--${testGitUrlHash}`)) }) - it.skip("should set default values properly", async () => { - throw new Error("TODO") - }) - it("should handle template variables for non-string fields", async () => { const projectRoot = getDataDir("test-projects", "non-string-template-values") const garden = await makeTestGarden(projectRoot) diff --git a/garden-service/test/unit/src/plugins/container/container.ts b/garden-service/test/unit/src/plugins/container/container.ts index dfd9feda22..187dd8d28d 100644 --- a/garden-service/test/unit/src/plugins/container/container.ts +++ b/garden-service/test/unit/src/plugins/container/container.ts @@ -73,7 +73,7 @@ describe("plugins.container", () => { garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) log = garden.log const provider = await garden.resolveProvider("container") - ctx = await garden.getPluginContext(provider) + ctx = garden.getPluginContext(provider) td.replace(garden.buildDir, "syncDependencyProducts", () => null) @@ -87,7 +87,7 @@ describe("plugins.container", () => { async function getTestModule(moduleConfig: ContainerModuleConfig) { const parsed = await configure({ ctx, moduleConfig, log }) const graph = await garden.getConfigGraph() - return moduleFromConfig(garden, graph, parsed) + return moduleFromConfig(garden, graph, parsed.moduleConfig) } describe("validate", () => { @@ -173,135 +173,137 @@ describe("plugins.container", () => { const result = await configure({ ctx, moduleConfig, log }) expect(result).to.eql({ - allowPublish: false, - build: { dependencies: [] }, - apiVersion: "garden.io/v0", - name: "module-a", - outputs: { - "local-image-name": "module-a", - "deployment-image-name": "module-a", - }, - path: modulePath, - type: "container", - spec: - { - build: { - dependencies: [], - timeout: DEFAULT_BUILD_TIMEOUT, + moduleConfig: { + allowPublish: false, + build: { dependencies: [] }, + apiVersion: "garden.io/v0", + name: "module-a", + outputs: { + "local-image-name": "module-a", + "deployment-image-name": "module-a", }, - buildArgs: {}, - extraFlags: [], - services: + path: modulePath, + type: "container", + spec: + { + build: { + dependencies: [], + timeout: DEFAULT_BUILD_TIMEOUT, + }, + buildArgs: {}, + extraFlags: [], + services: + [{ + name: "service-a", + annotations: {}, + args: ["echo"], + dependencies: [], + daemon: false, + ingresses: [{ + annotations: {}, + path: "/", + port: "http", + }], + env: { + SOME_ENV_VAR: "value", + }, + healthCheck: + { httpGet: { path: "/health", port: "http" } }, + limits: { + cpu: 123, + memory: 456, + }, + ports: [{ name: "http", protocol: "TCP", containerPort: 8080, servicePort: 8080 }], + replicas: 1, + volumes: [], + }], + tasks: + [{ + name: "task-a", + args: ["echo", "OK"], + dependencies: [], + env: { + TASK_ENV_VAR: "value", + }, + timeout: null, + }], + tests: + [{ + name: "unit", + args: ["echo", "OK"], + dependencies: [], + env: { + TEST_ENV_VAR: "value", + }, + timeout: null, + }], + }, + serviceConfigs: [{ name: "service-a", - annotations: {}, - args: ["echo"], dependencies: [], - daemon: false, - ingresses: [{ + hotReloadable: false, + spec: + { + name: "service-a", annotations: {}, - path: "/", - port: "http", - }], - env: { - SOME_ENV_VAR: "value", - }, - healthCheck: - { httpGet: { path: "/health", port: "http" } }, - limits: { - cpu: 123, - memory: 456, + args: ["echo"], + dependencies: [], + daemon: false, + ingresses: [{ + annotations: {}, + path: "/", + port: "http", + }], + env: { + SOME_ENV_VAR: "value", + }, + healthCheck: + { httpGet: { path: "/health", port: "http" } }, + limits: { + cpu: 123, + memory: 456, + }, + ports: [{ name: "http", protocol: "TCP", containerPort: 8080, servicePort: 8080 }], + replicas: 1, + volumes: [], }, - ports: [{ name: "http", protocol: "TCP", containerPort: 8080, servicePort: 8080 }], - replicas: 1, - volumes: [], }], - tasks: + taskConfigs: [{ - name: "task-a", - args: ["echo", "OK"], dependencies: [], - env: { - TASK_ENV_VAR: "value", + name: "task-a", + spec: { + args: [ + "echo", + "OK", + ], + dependencies: [], + env: { + TASK_ENV_VAR: "value", + }, + name: "task-a", + timeout: null, }, timeout: null, }], - tests: + testConfigs: [{ name: "unit", - args: ["echo", "OK"], dependencies: [], - env: { - TEST_ENV_VAR: "value", + spec: + { + name: "unit", + args: ["echo", "OK"], + dependencies: [], + env: { + TEST_ENV_VAR: "value", + }, + timeout: null, }, timeout: null, }], }, - serviceConfigs: - [{ - name: "service-a", - dependencies: [], - hotReloadable: false, - spec: - { - name: "service-a", - annotations: {}, - args: ["echo"], - dependencies: [], - daemon: false, - ingresses: [{ - annotations: {}, - path: "/", - port: "http", - }], - env: { - SOME_ENV_VAR: "value", - }, - healthCheck: - { httpGet: { path: "/health", port: "http" } }, - limits: { - cpu: 123, - memory: 456, - }, - ports: [{ name: "http", protocol: "TCP", containerPort: 8080, servicePort: 8080 }], - replicas: 1, - volumes: [], - }, - }], - taskConfigs: - [{ - dependencies: [], - name: "task-a", - spec: { - args: [ - "echo", - "OK", - ], - dependencies: [], - env: { - TASK_ENV_VAR: "value", - }, - name: "task-a", - timeout: null, - }, - timeout: null, - }], - testConfigs: - [{ - name: "unit", - dependencies: [], - spec: - { - name: "unit", - args: ["echo", "OK"], - dependencies: [], - env: { - TEST_ENV_VAR: "value", - }, - timeout: null, - }, - timeout: null, - }], }) }) diff --git a/garden-service/test/unit/src/plugins/container/helpers.ts b/garden-service/test/unit/src/plugins/container/helpers.ts index b6b8d332c5..eefe80e33d 100644 --- a/garden-service/test/unit/src/plugins/container/helpers.ts +++ b/garden-service/test/unit/src/plugins/container/helpers.ts @@ -64,7 +64,7 @@ describe("containerHelpers", () => { garden = await makeTestGarden(projectRoot, { extraPlugins: [gardenPlugin] }) log = garden.log const provider = await garden.resolveProvider("container") - ctx = await garden.getPluginContext(provider) + ctx = garden.getPluginContext(provider) td.replace(garden.buildDir, "syncDependencyProducts", () => null) @@ -78,7 +78,7 @@ describe("containerHelpers", () => { async function getTestModule(moduleConfig: ContainerModuleConfig) { const parsed = await configure({ ctx, moduleConfig, log }) const graph = await garden.getConfigGraph() - return moduleFromConfig(garden, graph, parsed) + return moduleFromConfig(garden, graph, parsed.moduleConfig) } describe("getLocalImageId", () => { 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 c73721abee..fa60b6348c 100644 --- a/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts +++ b/garden-service/test/unit/src/plugins/kubernetes/container/ingress.ts @@ -377,10 +377,10 @@ describe("createIngressResources", () => { } const provider = await garden.resolveProvider("container") - const ctx = await garden.getPluginContext(provider) + const ctx = garden.getPluginContext(provider) const parsed = await configure({ ctx, moduleConfig, log: garden.log }) const graph = await garden.getConfigGraph() - const module = await moduleFromConfig(garden, graph, parsed) + const module = await moduleFromConfig(garden, graph, parsed.moduleConfig) return { name: spec.name, diff --git a/garden-service/test/unit/src/plugins/kubernetes/helm/common.ts b/garden-service/test/unit/src/plugins/kubernetes/helm/common.ts index 274d9c7c43..823621d53a 100644 --- a/garden-service/test/unit/src/plugins/kubernetes/helm/common.ts +++ b/garden-service/test/unit/src/plugins/kubernetes/helm/common.ts @@ -25,6 +25,19 @@ import { ConfigGraph } from "../../../../../../src/config-graph" import { Provider } from "../../../../../../src/config/provider" import { buildHelmModule } from "../../../../../../src/plugins/kubernetes/helm/build" +const containerProvider: Provider = { + name: "container", + config: { + name: "container", + }, + dependencies: [], + moduleConfigs: [], + status: { + ready: true, + outputs: {}, + }, +} + const helmProvider: Provider = { name: "local-kubernetes", config: { @@ -39,11 +52,13 @@ const helmProvider: Provider = { }, } +const resolvedProviders = [containerProvider, helmProvider] + export async function getHelmTestGarden() { const projectRoot = resolve(dataDir, "test-projects", "helm") const garden = await makeTestGarden(projectRoot) // Avoid having to resolve the provider - set(garden, "resolvedProviders", [helmProvider]) + set(garden, "resolvedProviders", resolvedProviders) return garden } @@ -55,10 +70,8 @@ describe("Helm common functions", () => { before(async () => { garden = await getHelmTestGarden() - // Avoid having to resolve the provider - set(garden, "resolvedProviders", [helmProvider]) graph = await garden.getConfigGraph() - ctx = await garden.getPluginContext(helmProvider) + ctx = garden.getPluginContext(helmProvider) log = garden.log await buildModules() }) diff --git a/garden-service/test/unit/src/plugins/kubernetes/helm/config.ts b/garden-service/test/unit/src/plugins/kubernetes/helm/config.ts index 5db223e5ab..130500388a 100644 --- a/garden-service/test/unit/src/plugins/kubernetes/helm/config.ts +++ b/garden-service/test/unit/src/plugins/kubernetes/helm/config.ts @@ -18,7 +18,7 @@ describe("validateHelmModule", () => { before(async () => { garden = await getHelmTestGarden() const provider = await garden.resolveProvider("local-kubernetes") - ctx = await garden.getPluginContext(provider) + ctx = garden.getPluginContext(provider) await garden.resolveModuleConfigs() moduleConfigs = cloneDeep((garden).moduleConfigs) }) diff --git a/garden-service/test/unit/src/tasks/resolve-provider.ts b/garden-service/test/unit/src/tasks/resolve-provider.ts new file mode 100644 index 0000000000..5a79d6bebc --- /dev/null +++ b/garden-service/test/unit/src/tasks/resolve-provider.ts @@ -0,0 +1,51 @@ +import { GardenPlugin, PluginMap } from "../../../../src/types/plugin/plugin" +import { getPluginBases } from "../../../../src/tasks/resolve-provider" +import { expect } from "chai" +import { sortBy } from "lodash" + +describe("getPluginBases", () => { + it("should return an empty list if plugin has no base", () => { + const plugin: GardenPlugin = { + name: "foo", + } + const plugins: PluginMap = { + foo: plugin, + } + expect(getPluginBases(plugin, plugins)).to.eql([]) + }) + + it("should return the base if there is a single base", () => { + const base: GardenPlugin = { + name: "base", + } + const plugin: GardenPlugin = { + name: "foo", + base: "base", + } + const plugins: PluginMap = { + foo: plugin, + base, + } + expect(getPluginBases(plugin, plugins)).to.eql([base]) + }) + + it("should recursively return all bases for a plugin", () => { + const baseA: GardenPlugin = { + name: "base-a", + } + const baseB: GardenPlugin = { + name: "base-b", + base: "base-a", + } + const plugin: GardenPlugin = { + name: "foo", + base: "base-b", + } + const plugins: PluginMap = { + "foo": plugin, + "base-a": baseA, + "base-b": baseB, + } + expect(sortBy(getPluginBases(plugin, plugins), "name")).to.eql([baseA, baseB]) + }) +}) diff --git a/garden-service/test/unit/src/util/validate-dependencies.ts b/garden-service/test/unit/src/util/validate-dependencies.ts index 8d4521f8bc..8c9c944c6e 100644 --- a/garden-service/test/unit/src/util/validate-dependencies.ts +++ b/garden-service/test/unit/src/util/validate-dependencies.ts @@ -77,19 +77,19 @@ describe("validate-dependencies", () => { describe("detectCycles", () => { it("should detect self-to-self cycles", () => { - const cycles = detectCycles({ - a: { a: { distance: 1, next: "a" } }, - }) + const cycles = detectCycles([ + { from: "a", to: "a" }, + ]) expect(cycles).to.deep.eq([["a"]]) }) it("should preserve dependency order when returning cycles", () => { - const cycles = detectCycles({ - foo: { bar: { distance: 1, next: "bar" } }, - bar: { baz: { distance: 1, next: "baz" } }, - baz: { foo: { distance: 1, next: "foo" } }, - }) + const cycles = detectCycles([ + { from: "foo", to: "bar" }, + { from: "bar", to: "baz" }, + { from: "baz", to: "foo" }, + ]) expect(cycles).to.deep.eq([["foo", "bar", "baz"]]) })