diff --git a/.nycrc b/.nycrc index 73ae10d5c2..c0129879ef 100644 --- a/.nycrc +++ b/.nycrc @@ -1,6 +1,10 @@ { "check-coverage": true, "per-file": true, + "lines": 0, + "statements": 0, + "functions": 0, + "branches": 0, "reporter": [ "html", "text" diff --git a/examples/hello-world/garden.yml b/examples/hello-world/garden.yml index 14ab3d477d..1c294e9d0c 100644 --- a/examples/hello-world/garden.yml +++ b/examples/hello-world/garden.yml @@ -1,19 +1,19 @@ project: name: hello-world + global: + providers: + container: {} + npm-package: {} + variables: + my-variable: hello-variable environments: local: providers: - docker: - type: kubernetes + kubernetes: context: docker-for-desktop - gcf: - type: local-google-cloud-functions + local-google-cloud-functions: {} dev: providers: - docker: - type: google-app-engine - gcf: - type: google-cloud-functions + google-app-engine: {} + google-cloud-functions: default-project: garden-hello-world - variables: - my-variable: hello-variable diff --git a/src/cli.ts b/src/cli.ts index 270de32ee2..8d56789799 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,7 +29,6 @@ import { BuildCommand } from "./commands/build" import { EnvironmentCommand } from "./commands/environment/index" import { DeployCommand } from "./commands/deploy" import { CallCommand } from "./commands/call" -import { defaultPlugins } from "./plugins" import { TestCommand } from "./commands/test" import { DevCommand } from "./commands/dev" import { LogsCommand } from "./commands/logs" @@ -282,7 +281,7 @@ export class GardenCli { ) } - const garden = await Garden.factory(root, { env, logger, plugins: defaultPlugins }) + const garden = await Garden.factory(root, { env, logger }) return command.action(garden.pluginContext, argsForAction, optsForAction) } diff --git a/src/garden.ts b/src/garden.ts index 0c5d02566e..9e81f6779e 100644 --- a/src/garden.ts +++ b/src/garden.ts @@ -6,17 +6,50 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { parse, relative, resolve } from "path" -import { values, fromPairs } from "lodash" +import { + parse, + relative, + resolve, + sep, +} from "path" +import { + isString, + values, + fromPairs, + merge, + pick, + isEmpty, +} from "lodash" import * as Joi from "joi" import { PluginContext, createPluginContext, } from "./plugin-context" -import { Module, ModuleConfigType } from "./types/module" -import { ProjectConfig } from "./types/project" -import { getIgnorer, scanDirectory } from "./util" -import { DEFAULT_NAMESPACE, MODULE_CONFIG_FILENAME } from "./constants" +import { + builtinPlugins, + fixedPlugins, +} from "./plugins" +import { + Module, + ModuleConfigType, +} from "./types/module" +import { + moduleActionNames, + pluginModuleSchema, + pluginSchema, + RegisterPluginParam, +} from "./types/plugin" +import { + EnvironmentConfig, +} from "./types/project" +import { + getIgnorer, + scanDirectory, +} from "./util" +import { + DEFAULT_NAMESPACE, + MODULE_CONFIG_FILENAME, +} from "./constants" import { ConfigurationError, ParameterError, @@ -25,52 +58,80 @@ import { import { VcsHandler } from "./vcs/base" import { GitHandler } from "./vcs/git" import { BuildDir } from "./build-dir" -import { Task, TaskGraph, TaskResults } from "./task-graph" -import { getLogger, RootLogNode } from "./logger" +import { + Task, + TaskGraph, + TaskResults, +} from "./task-graph" +import { + getLogger, + RootLogNode, +} from "./logger" import { pluginActionNames, PluginActions, PluginFactory, - Plugin, + GardenPlugin, + ModuleActions, } from "./types/plugin" -import { GenericModuleHandler } from "./plugins/generic" -import { Environment, joiIdentifier, validate } from "./types/common" +import { + Environment, + joiIdentifier, + validate, +} from "./types/common" import { Service } from "./types/service" -import { TemplateStringContext, getTemplateContext, resolveTemplateStrings } from "./template-string" +import { + TemplateStringContext, + getTemplateContext, + resolveTemplateStrings, +} from "./template-string" import { loadConfig } from "./types/config" -export interface ModuleMap { [key: string]: T } -export interface ServiceMap { [key: string]: Service } -export interface ActionHandlerMap> { [key: string]: PluginActions[T] } +export interface ModuleMap { + [key: string]: T +} + +export interface ServiceMap { + [key: string]: Service +} + +export interface ActionHandlerMap { + [actionName: string]: PluginActions[T] +} + +export interface ModuleActionHandlerMap> { + [actionName: string]: ModuleActions[T] +} export type PluginActionMap = { - [A in keyof PluginActions]: { - [pluginName: string]: PluginActions[A], + [A in keyof PluginActions]: { + [pluginName: string]: PluginActions[A], + } +} + +export type ModuleActionMap = { + [A in keyof ModuleActions]: { + [pluginName: string]: ModuleActions[A], } } export interface ContextOpts { env?: string, logger?: RootLogNode, - plugins?: PluginFactory[], + plugins?: RegisterPluginParam[], } -const builtinPlugins: PluginFactory[] = [ - () => new GenericModuleHandler(), -] - export class Garden { public buildDir: BuildDir public readonly log: RootLogNode public readonly actionHandlers: PluginActionMap - public readonly projectName: string - public readonly plugins: { [key: string]: Plugin } + public readonly moduleActionHandlers: ModuleActionMap public readonly pluginContext: PluginContext - private environment: string - private namespace: string + private readonly loadedPlugins: { [key: string]: GardenPlugin } private readonly modules: ModuleMap private modulesScanned: boolean + private readonly registeredPlugins: { [key: string]: PluginFactory } private readonly services: ServiceMap private taskGraph: TaskGraph private readonly configKeyNamespaces: string[] @@ -78,8 +139,13 @@ export class Garden { vcs: VcsHandler constructor( - public projectRoot: string, public projectConfig: ProjectConfig, - env?: string, logger?: RootLogNode, + public readonly projectRoot: string, + public readonly projectName: string, + private readonly environment: string, + private readonly namespace: string, + public readonly config: EnvironmentConfig, + plugins: RegisterPluginParam[], + logger?: RootLogNode, ) { this.modulesScanned = false this.log = logger || getLogger() @@ -89,73 +155,74 @@ export class Garden { this.modules = {} this.services = {} - this.plugins = {} + this.loadedPlugins = {} + this.registeredPlugins = {} this.actionHandlers = fromPairs(pluginActionNames.map(n => [n, {}])) + this.moduleActionHandlers = fromPairs(moduleActionNames.map(n => [n, {}])) this.buildDir.init() - this.projectConfig = projectConfig - this.projectName = this.projectConfig.name + this.config = config this.configKeyNamespaces = ["project"] - this.setEnvironment(env || this.projectConfig.defaultEnvironment) - this.pluginContext = createPluginContext(this) this.taskGraph = new TaskGraph(this.pluginContext) + + // Register plugins + for (const plugin of builtinPlugins.concat(plugins)) { + this.registerPlugin(plugin) + } + + for (const plugin of fixedPlugins) { + this.loadPlugin(plugin, {}) + } + + // Load configured plugins + // Validate configuration + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + this.loadPlugin(providerName, providerConfig) + } } static async factory(projectRoot: string, { env, logger, plugins = [] }: ContextOpts = {}) { // const localConfig = new LocalConfig(projectRoot) const templateContext = await getTemplateContext() const config = await resolveTemplateStrings(await loadConfig(projectRoot, projectRoot), templateContext) - const projectConfig = config.project - if (!projectConfig) { + if (!config.project) { throw new ConfigurationError(`Path ${projectRoot} does not contain a project configuration`, { projectRoot, config, }) } - const ctx = new Garden(projectRoot, projectConfig, env, logger) - - // Load configured plugins - plugins = builtinPlugins.concat(plugins) - - for (const plugin of plugins) { - ctx.registerPlugin(plugin) + if (!env) { + env = config.project.defaultEnvironment } - // validate the provider configuration - for (const envName in projectConfig.environments) { - const envConfig = projectConfig.environments[envName] + const projectName = config.project.name + const globalConfig = config.project.global || {} - for (const providerName in envConfig.providers) { - const providerConfig = envConfig.providers[providerName] - const providerType = providerConfig.type - - if (!ctx.plugins[providerType]) { - throw new ConfigurationError( - `Could not find plugin type ${providerType} (specified in environment ${envName})`, - { envName, providerType }, - ) - } - } - } + const parts = env.split(".") + const environment = parts[0] + const namespace = parts.slice(1).join(".") || DEFAULT_NAMESPACE - return ctx - } + const envConfig = config.project.environments[environment] - setEnvironment(environment: string) { - const parts = environment.split(".") - const name = parts[0] - const namespace = parts.slice(1).join(".") || DEFAULT_NAMESPACE + if (!envConfig) { + throw new ParameterError(`Project ${projectName} does not specify environment ${environment}`, { + projectName, + env, + definedEnvironments: Object.keys(config.project.environments), + }) + } - if (!this.projectConfig.environments[name]) { - throw new ParameterError(`Could not find environment ${environment}`, { - name, - namespace, + if (!envConfig.providers || isEmpty(envConfig.providers)) { + throw new ConfigurationError(`Environment '${environment}' does not specify any providers`, { + projectName, + env, + envConfig, }) } @@ -166,21 +233,17 @@ export class Garden { }) } - this.environment = name - this.namespace = namespace + // Resolve the project configuration based on selected environment + const projectConfig = merge({}, globalConfig, envConfig) - return { name, namespace } + return new Garden(projectRoot, projectName, environment, namespace, projectConfig, plugins, logger) } getEnvironment(): Environment { - if (!this.environment) { - throw new PluginError(`Environment has not been set`, {}) - } - return { name: this.environment, namespace: this.namespace, - config: this.projectConfig.environments[this.environment], + config: this.config, } } @@ -196,33 +259,160 @@ export class Garden { return this.taskGraph.processTasks() } - registerPlugin(pluginFactory: PluginFactory) { - const plugin = pluginFactory(this) - const pluginName = validate(plugin.name, joiIdentifier(), "plugin name") + private registerPlugin(moduleOrFactory: RegisterPluginParam) { + let factory: PluginFactory + let name: string + + if (typeof moduleOrFactory === "function") { + factory = moduleOrFactory + name = factory.pluginName || factory.name! + + } else if (isString(moduleOrFactory)) { + let moduleNameOrLocation = moduleOrFactory + const parsedLocation = parse(moduleNameOrLocation) + + // allow relative references to project root + if (parse(moduleNameOrLocation).dir !== "") { + moduleNameOrLocation = resolve(this.projectRoot, moduleNameOrLocation) + } + + let pluginModule + + try { + pluginModule = require(moduleNameOrLocation) + } catch (error) { + throw new ConfigurationError(`Unable to load plugin "${moduleNameOrLocation}" (could not load module)`, { + error, + moduleNameOrLocation, + }) + } + + try { + pluginModule = validate(pluginModule, pluginModuleSchema, `plugin module "${moduleNameOrLocation}"`) + + if (pluginModule.name) { + name = pluginModule.name + } else { + if (parsedLocation.name === "index") { + // use parent directory name + name = parsedLocation.dir.split(sep).slice(-1)[0] + } else { + name = parsedLocation.name + } + } + + validate(name, joiIdentifier(), `name of plugin "${moduleNameOrLocation}"`) + } catch (err) { + throw new PluginError(`Unable to load plugin: ${err}`, { + moduleNameOrLocation, + err, + }) + } + + factory = pluginModule.gardenPlugin + + } else { + throw new TypeError(`Expected plugin factory function, module name or module path`) + } - if (this.plugins[pluginName]) { - throw new ConfigurationError(`Plugin ${pluginName} declared more than once`, { - previous: this.plugins[pluginName], - adding: plugin, + this.registeredPlugins[name] = factory + } + + private loadPlugin(pluginName: string, config: object) { + const factory = this.registeredPlugins[pluginName] + + if (!factory) { + throw new ConfigurationError(`Configured plugin '${pluginName}' has not been registered`, { + name: pluginName, + availablePlugins: Object.keys(this.registeredPlugins), + }) + } + + let plugin + + try { + plugin = factory({ + garden: this, + config, + }) + } catch (error) { + throw new PluginError(`Unexpected error when loading plugin "${pluginName}": ${error}`, { + pluginName, + error, }) } - this.plugins[pluginName] = plugin + plugin = validate(plugin, pluginSchema, `plugin "${pluginName}"`) - for (const action of pluginActionNames) { - const actionHandler = plugin[action] + this.loadedPlugins[pluginName] = plugin - if (actionHandler) { - const wrapped = (...args) => { - return actionHandler.apply(plugin, args) - } - wrapped["actionType"] = action - wrapped["pluginName"] = pluginName - this.actionHandlers[action][pluginName] = wrapped + const actions = plugin.actions || {} + + for (const actionType of pluginActionNames) { + const handler = actions[actionType] + handler && this.addActionHandler(pluginName, actionType, handler) + } + + const moduleActions = plugin.moduleActions || {} + + for (const moduleType of Object.keys(moduleActions)) { + for (const actionType of moduleActionNames) { + const handler = moduleActions[moduleType][actionType] + handler && this.addModuleActionHandler(pluginName, actionType, moduleType, handler) } } } + private getPlugin(pluginName: string) { + const plugin = this.loadedPlugins[pluginName] + + if (!plugin) { + throw new PluginError(`Could not find plugin ${pluginName}`, { + pluginName, + availablePlugins: Object.keys(this.loadedPlugins), + }) + } + + return plugin + } + + private addActionHandler( + pluginName: string, actionType: T, handler: PluginActions[T], + ) { + const plugin = this.getPlugin(pluginName) + + const wrapped = (...args) => { + return handler.apply(plugin, args) + } + wrapped["actionType"] = actionType + wrapped["pluginName"] = pluginName + + this.actionHandlers[actionType][pluginName] = wrapped + } + + private addModuleActionHandler>( + pluginName: string, actionType: T, moduleType: string, handler: ModuleActions[T], + ) { + const plugin = this.getPlugin(pluginName) + + const wrapped = (...args) => { + return handler.apply(plugin, args) + } + wrapped["actionType"] = actionType + wrapped["pluginName"] = pluginName + wrapped["moduleType"] = moduleType + + if (!this.moduleActionHandlers[moduleType]) { + this.moduleActionHandlers[moduleType] = {} + } + + if (!this.moduleActionHandlers[moduleType][actionType]) { + this.moduleActionHandlers[moduleType][actionType] = {} + } + + this.moduleActionHandlers[moduleType][actionType][pluginName] = wrapped + } + /* Returns all modules that are registered in this context. Scans for modules in the project root if it hasn't already been done. @@ -409,7 +599,6 @@ export class Garden { } async getTemplateContext(extraContext: TemplateStringContext = {}): Promise { - const env = this.getEnvironment() const _this = this return { @@ -417,8 +606,8 @@ export class Garden { config: async (key: string[]) => { return _this.pluginContext.getConfig(key) }, - variables: this.projectConfig.variables, - environment: { name: env.name, config: env.config }, + variables: this.config.variables, + environment: { name: this.environment, config: this.config }, ...extraContext, } } @@ -428,59 +617,37 @@ export class Garden { //=========================================================================== /** - * Get a list of all available plugins (not specific to an environment). - * - * Optionally filter to only include plugins that support a specific module type. + * Get a list of names of all configured plugins for the currently set environment. + * Includes built-in module handlers (used for builds and such). */ - private getAllPlugins(moduleType?: string): Plugin[] { - const allPlugins = values(this.plugins) - - if (moduleType) { - return allPlugins.filter(p => p.supportedModuleTypes.includes(moduleType)) - } else { - return allPlugins - } + private getEnvPlugins() { + return Object.keys(this.loadedPlugins) } /** - * Get a list of all configured plugins for the currently set environment. - * Includes built-in module handlers (used for builds and such). - * - * Optionally filter to only include plugins that support a specific module type. + * Get a handler for the specified action. */ - private getEnvPlugins(moduleType?: string) { - const env = this.getEnvironment() - const allPlugins = this.getAllPlugins(moduleType) - const envProviderTypes = values(env.config.providers).map(p => p.type) - - return allPlugins.filter(p => envProviderTypes.includes(p.name)) + public getActionHandlers(actionType: T): ActionHandlerMap { + return pick(this.actionHandlers[actionType], this.getEnvPlugins()) } /** - * Get a handler for the specified action (and optionally module type). + * Get a handler for the specified module action. */ - public getActionHandlers - >(type: T, moduleType?: string): ActionHandlerMap { - - const handlers: ActionHandlerMap = {} - - this.getAllPlugins(moduleType) - .filter(p => !!p[type]) - .map(p => { - handlers[p.name] = this.actionHandlers[type][p.name] - }) - - return handlers + public getModuleActionHandlers>( + actionType: T, moduleType: string, + ): ModuleActionHandlerMap { + return pick((this.moduleActionHandlers[moduleType] || {})[actionType], this.getEnvPlugins()) } /** * Get the last configured handler for the specified action (and optionally module type). */ - public getActionHandler>( - type: T, moduleType?: string, defaultHandler?: PluginActions[T], - ): PluginActions[T] { + public getActionHandler( + type: T, defaultHandler?: PluginActions[T], + ): PluginActions[T] { - const handlers = values(this.getActionHandlers(type, moduleType)) + const handlers = values(this.getActionHandlers(type)) if (handlers.length) { return handlers[handlers.length - 1] @@ -489,45 +656,24 @@ export class Garden { } // TODO: Make these error messages nicer - let msg = `No handler for ${type} configured` - - if (moduleType) { - msg += ` for module type ${moduleType}` - } - - throw new ParameterError(msg, { - requestedHandlerType: type, - requestedModuleType: moduleType, - }) - } - - /** - * Get all handlers for the specified action for the currently set environment - * (and optionally module type). - */ - public getEnvActionHandlers - >(type: T, moduleType?: string): ActionHandlerMap { - - const handlers: ActionHandlerMap = {} - - this.getEnvPlugins(moduleType) - .filter(p => !!p[type]) - .map(p => { - handlers[p.name] = this.actionHandlers[type][p.name] - }) - - return handlers + throw new ParameterError( + `No '${type}' handler configured in environment '${this.environment}'. ` + + `Are you missing a provider configuration?`, + { + requestedHandlerType: type, + environment: this.environment, + }, + ) } /** - * Get last configured handler for the specified action for the currently set environment - * (and optionally module type). + * Get the last configured handler for the specified action. */ - public getEnvActionHandler>( - type: T, moduleType?: string, defaultHandler?: PluginActions[T], - ): PluginActions[T] { + public getModuleActionHandler>( + type: T, moduleType: string, defaultHandler?: ModuleActions[T], + ): ModuleActions[T] { - const handlers = values(this.getEnvActionHandlers(type, moduleType)) + const handlers = values(this.getModuleActionHandlers(type, moduleType)) if (handlers.length) { return handlers[handlers.length - 1] @@ -535,18 +681,16 @@ export class Garden { return defaultHandler } - const env = this.getEnvironment() - let msg = `No handler for ${type} configured for environment ${env.name}` - - if (moduleType) { - msg += ` and module type ${moduleType}` - } - - throw new ParameterError(msg, { - requestedHandlerType: type, - requestedModuleType: moduleType, - environment: env.name, - }) + // TODO: Make these error messages nicer + throw new ParameterError( + `No '${type}' handler configured for module type '${moduleType}' in environment '${this.environment}'. ` + + `Are you missing a provider configuration?`, + { + requestedHandlerType: type, + requestedModuleType: moduleType, + environment: this.environment, + }, + ) } /** diff --git a/src/plugin-context.ts b/src/plugin-context.ts index 9b90ccd78d..72a901ad96 100644 --- a/src/plugin-context.ts +++ b/src/plugin-context.ts @@ -30,12 +30,13 @@ import { EnvironmentStatusMap, ExecInServiceResult, GetServiceLogsParams, + ModuleActionParams, PluginActionParams, - PluginActions, PushModuleParams, PushResult, ServiceLogEntry, TestResult, + ModuleActions, } from "./types/plugin" import { Service, @@ -51,14 +52,14 @@ import { import { TreeVersion } from "./vcs/base" export type PluginContextGuard = { - readonly [P in keyof PluginActionParams]: (...args: any[]) => Promise + readonly [P in keyof (PluginActionParams | ModuleActionParams)]: (...args: any[]) => Promise } export type WrappedFromGarden = Pick export interface PluginContext extends PluginContextGuard, WrappedFromGarden { - parseModule: (config: T["_ConfigType"]) => Promise + parseModule: (moduleConfig: T["_ConfigType"]) => Promise getModuleBuildPath: (module: T) => Promise getModuleBuildStatus: (module: T, logEntry?: LogEntry) => Promise buildModule: (module: T, logEntry?: LogEntry) => Promise @@ -100,11 +101,13 @@ export function createPluginContext(garden: Garden): PluginContext { return f.bind(garden) } + const config = { ...garden.config } + const ctx: PluginContext = { projectName: garden.projectName, projectRoot: garden.projectRoot, log: garden.log, - projectConfig: { ...garden.projectConfig }, + config, vcs: garden.vcs, // TODO: maybe we should move some of these here @@ -122,9 +125,9 @@ export function createPluginContext(garden: Garden): PluginContext { return module ? module : null }, - parseModule: async (config: T["_ConfigType"]) => { - const handler = garden.getActionHandler("parseModule", config.type) - return handler({ ctx, config }) + parseModule: async (moduleConfig: T["_ConfigType"]) => { + const handler = garden.getModuleActionHandler("parseModule", moduleConfig.type) + return handler({ ctx, config, moduleConfig }) }, getModuleBuildPath: async (module: T) => { @@ -132,44 +135,44 @@ export function createPluginContext(garden: Garden): PluginContext { }, getModuleBuildStatus: async (module: T, logEntry?: LogEntry) => { - const defaultHandler = garden.actionHandlers["getModuleBuildStatus"]["generic"] - const handler = garden.getActionHandler("getModuleBuildStatus", module.type, defaultHandler) - return handler({ ctx, module, logEntry }) + const defaultHandler = garden.getModuleActionHandler("getModuleBuildStatus", "generic") + const handler = garden.getModuleActionHandler("getModuleBuildStatus", module.type, defaultHandler) + return handler({ ctx, config, module, logEntry }) }, buildModule: async (module: T, logEntry?: LogEntry) => { await garden.buildDir.syncDependencyProducts(module) - const defaultHandler = garden.actionHandlers["buildModule"]["generic"] - const handler = garden.getActionHandler("buildModule", module.type, defaultHandler) - return handler({ ctx, module, logEntry }) + const defaultHandler = garden.getModuleActionHandler("buildModule", "generic") + const handler = garden.getModuleActionHandler("buildModule", module.type, defaultHandler) + return handler({ ctx, config, module, logEntry }) }, pushModule: async (module: T, logEntry?: LogEntry) => { - const handler = garden.getActionHandler("pushModule", module.type, dummyPushHandler) - return handler({ ctx, module, logEntry }) + const handler = garden.getModuleActionHandler("pushModule", module.type, dummyPushHandler) + return handler({ ctx, config, module, logEntry }) }, testModule: async (module: T, testSpec: TestSpec, logEntry?: LogEntry) => { - const defaultHandler = garden.actionHandlers["testModule"]["generic"] - const handler = garden.getEnvActionHandler("testModule", module.type, defaultHandler) + const defaultHandler = garden.getModuleActionHandler("testModule", "generic") + const handler = garden.getModuleActionHandler("testModule", module.type, defaultHandler) const env = garden.getEnvironment() - return handler({ ctx, module, testSpec, env, logEntry }) + return handler({ ctx, config, module, testSpec, env, logEntry }) }, getTestResult: async (module: T, version: TreeVersion, logEntry?: LogEntry) => { - const handler = garden.getEnvActionHandler("getTestResult", module.type, async () => null) + const handler = garden.getModuleActionHandler("getTestResult", module.type, async () => null) const env = garden.getEnvironment() - return handler({ ctx, module, version, env, logEntry }) + return handler({ ctx, config, module, version, env, logEntry }) }, getEnvironmentStatus: async () => { - const handlers = garden.getEnvActionHandlers("getEnvironmentStatus") + const handlers = garden.getActionHandlers("getEnvironmentStatus") const env = garden.getEnvironment() - return Bluebird.props(mapValues(handlers, h => h({ ctx, env }))) + return Bluebird.props(mapValues(handlers, h => h({ ctx, config, env }))) }, configureEnvironment: async () => { - const handlers = garden.getEnvActionHandlers("configureEnvironment") + const handlers = garden.getActionHandlers("configureEnvironment") const env = garden.getEnvironment() await Bluebird.each(toPairs(handlers), async ([name, handler]) => { @@ -179,7 +182,7 @@ export function createPluginContext(garden: Garden): PluginContext { msg: "Configuring...", }) - await handler({ ctx, env, logEntry }) + await handler({ ctx, config, env, logEntry }) logEntry.setSuccess("Configured") }) @@ -187,55 +190,55 @@ export function createPluginContext(garden: Garden): PluginContext { }, destroyEnvironment: async () => { - const handlers = garden.getEnvActionHandlers("destroyEnvironment") + const handlers = garden.getActionHandlers("destroyEnvironment") const env = garden.getEnvironment() - await Bluebird.each(values(handlers), h => h({ ctx, env })) + await Bluebird.each(values(handlers), h => h({ ctx, config, env })) return ctx.getEnvironmentStatus() }, getServiceStatus: async (service: Service) => { - const handler = garden.getEnvActionHandler("getServiceStatus", service.module.type) - return handler({ ctx, service, env: garden.getEnvironment() }) + const handler = garden.getModuleActionHandler("getServiceStatus", service.module.type) + return handler({ ctx, config, service, env: garden.getEnvironment() }) }, deployService: async ( service: Service, serviceContext?: ServiceContext, logEntry?: LogEntry, ) => { - const handler = garden.getEnvActionHandler("deployService", service.module.type) + const handler = garden.getModuleActionHandler("deployService", service.module.type) if (!serviceContext) { serviceContext = { envVars: {}, dependencies: {} } } - return handler({ ctx, service, serviceContext, env: garden.getEnvironment(), logEntry }) + return handler({ ctx, config, service, serviceContext, env: garden.getEnvironment(), logEntry }) }, getServiceOutputs: async (service: Service) => { // TODO: We might want to generally allow for "default handlers" - let handler: PluginActions["getServiceOutputs"] + let handler: ModuleActions["getServiceOutputs"] try { - handler = garden.getEnvActionHandler("getServiceOutputs", service.module.type) + handler = garden.getModuleActionHandler("getServiceOutputs", service.module.type) } catch (err) { return {} } - return handler({ ctx, service, env: garden.getEnvironment() }) + return handler({ ctx, config, service, env: garden.getEnvironment() }) }, execInService: async (service: Service, command: string[]) => { - const handler = garden.getEnvActionHandler("execInService", service.module.type) - return handler({ ctx, service, command, env: garden.getEnvironment() }) + const handler = garden.getModuleActionHandler("execInService", service.module.type) + return handler({ ctx, config, service, command, env: garden.getEnvironment() }) }, getServiceLogs: async (service: Service, stream: Stream, tail?: boolean) => { - const handler = garden.getEnvActionHandler("getServiceLogs", service.module.type, dummyLogStreamer) - return handler({ ctx, service, stream, tail, env: garden.getEnvironment() }) + const handler = garden.getModuleActionHandler("getServiceLogs", service.module.type, dummyLogStreamer) + return handler({ ctx, config, service, stream, tail, env: garden.getEnvironment() }) }, getConfig: async (key: string[]) => { garden.validateConfigKey(key) // TODO: allow specifying which provider to use for configs - const handler = garden.getEnvActionHandler("getConfig") - const value = await handler({ ctx, key, env: garden.getEnvironment() }) + const handler = garden.getActionHandler("getConfig") + const value = await handler({ ctx, config, key, env: garden.getEnvironment() }) if (value === null) { throw new NotFoundError(`Could not find config key ${key}`, { key }) @@ -246,14 +249,14 @@ export function createPluginContext(garden: Garden): PluginContext { setConfig: async (key: string[], value: string) => { garden.validateConfigKey(key) - const handler = garden.getEnvActionHandler("setConfig") - return handler({ ctx, key, value, env: garden.getEnvironment() }) + const handler = garden.getActionHandler("setConfig") + return handler({ ctx, config, key, value, env: garden.getEnvironment() }) }, deleteConfig: async (key: string[]) => { garden.validateConfigKey(key) - const handler = garden.getEnvActionHandler("deleteConfig") - const res = await handler({ ctx, key, env: garden.getEnvironment() }) + const handler = garden.getActionHandler("deleteConfig") + const res = await handler({ ctx, config, key, env: garden.getEnvironment() }) if (!res.found) { throw new NotFoundError(`Could not find config key ${key}`, { key }) diff --git a/src/plugins/container.ts b/src/plugins/container.ts index 3d4e2b0fe0..51e51ec6db 100644 --- a/src/plugins/container.ts +++ b/src/plugins/container.ts @@ -15,7 +15,13 @@ import { identifierRegex, validate } from "../types/common" import { existsSync } from "fs" import { join } from "path" import { ConfigurationError } from "../exceptions" -import { BuildModuleParams, GetModuleBuildStatusParams, Plugin, PushModuleParams } from "../types/plugin" +import { + BuildModuleParams, + GetModuleBuildStatusParams, + GardenPlugin, + PushModuleParams, + ParseModuleParams, +} from "../types/plugin" import { Service } from "../types/service" import { DEFAULT_PORT_PROTOCOL } from "../constants" import { splitFirst } from "../util" @@ -178,120 +184,121 @@ export class ContainerModule { - name = "docker-module" - supportedModuleTypes = ["container"] +// TODO: rename this plugin to docker +export const gardenPlugin = (): GardenPlugin => ({ + moduleActions: { + container: { + async parseModule({ ctx, moduleConfig }: ParseModuleParams) { + moduleConfig = validate(moduleConfig, containerSchema, `module ${moduleConfig.name}`) - async parseModule({ ctx, config }: { ctx: PluginContext, config: ContainerModuleConfig }) { - config = validate(config, containerSchema, `module ${config.name}`) + const module = new ContainerModule(ctx, moduleConfig) - const module = new ContainerModule(ctx, config) - - // make sure we can build the thing - if (!module.image && !module.hasDockerfile()) { - throw new ConfigurationError( - `Module ${config.name} neither specifies image nor provides Dockerfile`, - {}, - ) - } - - // validate services - for (const [name, service] of Object.entries(module.services)) { - // make sure ports are correctly configured - const definedPorts = Object.keys(service.ports) - - for (const endpoint of service.endpoints) { - const endpointPort = endpoint.port - - if (!service.ports[endpointPort]) { - throw new ConfigurationError( - `Service ${name} does not define port ${endpointPort} defined in endpoint`, - { definedPorts, endpointPort }, - ) - } - } - - if (service.healthCheck && service.healthCheck.httpGet) { - const healthCheckHttpPort = service.healthCheck.httpGet.port - - if (!service.ports[healthCheckHttpPort]) { + // make sure we can build the thing + if (!module.image && !module.hasDockerfile()) { throw new ConfigurationError( - `Service ${name} does not define port ${healthCheckHttpPort} defined in httpGet health check`, - { definedPorts, healthCheckHttpPort }, + `Module ${moduleConfig.name} neither specifies image nor provides Dockerfile`, + {}, ) } - } - if (service.healthCheck && service.healthCheck.tcpPort) { - const healthCheckTcpPort = service.healthCheck.tcpPort - - if (!service.ports[healthCheckTcpPort]) { - throw new ConfigurationError( - `Service ${name} does not define port ${healthCheckTcpPort} defined in tcpPort health check`, - { definedPorts, healthCheckTcpPort }, - ) + // validate services + for (const [name, service] of Object.entries(module.services)) { + // make sure ports are correctly configured + const definedPorts = Object.keys(service.ports) + + for (const endpoint of service.endpoints) { + const endpointPort = endpoint.port + + if (!service.ports[endpointPort]) { + throw new ConfigurationError( + `Service ${name} does not define port ${endpointPort} defined in endpoint`, + { definedPorts, endpointPort }, + ) + } + } + + if (service.healthCheck && service.healthCheck.httpGet) { + const healthCheckHttpPort = service.healthCheck.httpGet.port + + if (!service.ports[healthCheckHttpPort]) { + throw new ConfigurationError( + `Service ${name} does not define port ${healthCheckHttpPort} defined in httpGet health check`, + { definedPorts, healthCheckHttpPort }, + ) + } + } + + if (service.healthCheck && service.healthCheck.tcpPort) { + const healthCheckTcpPort = service.healthCheck.tcpPort + + if (!service.ports[healthCheckTcpPort]) { + throw new ConfigurationError( + `Service ${name} does not define port ${healthCheckTcpPort} defined in tcpPort health check`, + { definedPorts, healthCheckTcpPort }, + ) + } + } } - } - } - return module - } + return module + }, - async getModuleBuildStatus({ module, logEntry }: GetModuleBuildStatusParams) { - const identifier = await module.imageExistsLocally() + async getModuleBuildStatus({ module, logEntry }: GetModuleBuildStatusParams) { + const identifier = await module.imageExistsLocally() - if (identifier) { - logEntry && logEntry.debug({ - section: module.name, - msg: `Image ${identifier} already exists`, - symbol: LogSymbolType.info, - }) - } + if (identifier) { + logEntry && logEntry.debug({ + section: module.name, + msg: `Image ${identifier} already exists`, + symbol: LogSymbolType.info, + }) + } - return { ready: !!identifier } - } + return { ready: !!identifier } + }, - async buildModule({ ctx, module, logEntry }: BuildModuleParams) { - if (!!module.image && !module.hasDockerfile()) { - logEntry && logEntry.setState(`Pulling image ${module.image}...`) - await module.pullImage(ctx) - return { fetched: true } - } + async buildModule({ ctx, module, logEntry }: BuildModuleParams) { + if (!!module.image && !module.hasDockerfile()) { + logEntry && logEntry.setState(`Pulling image ${module.image}...`) + await module.pullImage(ctx) + return { fetched: true } + } - const identifier = await module.getLocalImageId() + const identifier = await module.getLocalImageId() - // build doesn't exist, so we create it - logEntry && logEntry.setState(`Building ${identifier}...`) + // build doesn't exist, so we create it + logEntry && logEntry.setState(`Building ${identifier}...`) - // TODO: log error if it occurs - // TODO: stream output to log if at debug log level - await module.dockerCli(`build -t ${identifier} ${await module.getBuildPath()}`) + // TODO: log error if it occurs + // TODO: stream output to log if at debug log level + await module.dockerCli(`build -t ${identifier} ${await module.getBuildPath()}`) - return { fresh: true } - } + return { fresh: true } + }, - async pushModule({ module, logEntry }: PushModuleParams) { - if (!module.hasDockerfile()) { - logEntry && logEntry.setState({ msg: `Nothing to push` }) - return { pushed: false } - } + async pushModule({ module, logEntry }: PushModuleParams) { + if (!module.hasDockerfile()) { + logEntry && logEntry.setState({ msg: `Nothing to push` }) + return { pushed: false } + } - const localId = await module.getLocalImageId() - const remoteId = await module.getRemoteImageId() + const localId = await module.getLocalImageId() + const remoteId = await module.getRemoteImageId() - // build doesn't exist, so we create it - logEntry && logEntry.setState({ msg: `Pushing image ${remoteId}...` }) + // build doesn't exist, so we create it + logEntry && logEntry.setState({ msg: `Pushing image ${remoteId}...` }) - if (localId !== remoteId) { - await module.dockerCli(`tag ${localId} ${remoteId}`) - } + if (localId !== remoteId) { + await module.dockerCli(`tag ${localId} ${remoteId}`) + } - // TODO: log error if it occurs - // TODO: stream output to log if at debug log level - // TODO: check if module already exists remotely? - await module.dockerCli(`push ${remoteId}`) + // TODO: log error if it occurs + // TODO: stream output to log if at debug log level + // TODO: check if module already exists remotely? + await module.dockerCli(`push ${remoteId}`) - return { pushed: true } - } -} + return { pushed: true } + }, + }, + }, +}) diff --git a/src/plugins/generic.ts b/src/plugins/generic.ts index 0aa6c3f2d9..cdd03e300b 100644 --- a/src/plugins/generic.ts +++ b/src/plugins/generic.ts @@ -8,57 +8,75 @@ import { exec } from "child-process-promise" import { + BuildModuleParams, BuildResult, BuildStatus, + GetModuleBuildStatusParams, ParseModuleParams, - Plugin, TestModuleParams, TestResult, } from "../types/plugin" -import { Module } from "../types/module" +import { + Module, + ModuleConfig, +} from "../types/module" +import { + ServiceConfig, +} from "../types/service" import { spawn } from "../util" -export class GenericModuleHandler implements Plugin { - name = "generic" - supportedModuleTypes = ["generic"] +export const name = "generic" - async parseModule({ ctx, config }: ParseModuleParams) { - return new Module(ctx, config) - } +// TODO: find a different way to solve type export issues +let _serviceConfig: ServiceConfig - async getModuleBuildStatus({ module }: { module: T }): Promise { - // Each module handler should keep track of this for now. Defaults to return false if a build command is specified. - return { ready: !(await module.getConfig()).build.command } - } +export const genericPlugin = { + moduleActions: { + generic: { + async parseModule({ ctx, moduleConfig }: ParseModuleParams): Promise { + return new Module(ctx, moduleConfig) + }, - async buildModule({ module }: { module: T }): Promise { - // By default we run the specified build command in the module root, if any. - // TODO: Keep track of which version has been built (needs local data store/cache). - const config = await module.getConfig() + async getModuleBuildStatus({ module }: GetModuleBuildStatusParams): Promise { + // Each module handler should keep track of this for now. + // Defaults to return false if a build command is specified. + return { ready: !(await module.getConfig()).build.command } + }, - if (config.build.command) { - const buildPath = await module.getBuildPath() - const result = await exec(config.build.command, { cwd: buildPath }) + async buildModule({ module }: BuildModuleParams): Promise { + // By default we run the specified build command in the module root, if any. + // TODO: Keep track of which version has been built (needs local data store/cache). + const config: ModuleConfig = await module.getConfig() - return { - fresh: true, - buildLog: result.stdout, - } - } else { - return {} - } - } + if (config.build.command) { + const buildPath = await module.getBuildPath() + const result = await exec(config.build.command, { cwd: buildPath }) - async testModule({ module, testSpec }: TestModuleParams): Promise { - const startedAt = new Date() - const result = await spawn(testSpec.command[0], testSpec.command.slice(1), { cwd: module.path, ignoreError: true }) + return { + fresh: true, + buildLog: result.stdout, + } + } else { + return {} + } + }, - return { - version: await module.getVersion(), - success: result.code === 0, - startedAt, - completedAt: new Date(), - output: result.output, - } - } + async testModule({ module, testSpec }: TestModuleParams): Promise { + const startedAt = new Date() + const result = await spawn( + testSpec.command[0], testSpec.command.slice(1), { cwd: module.path, ignoreError: true }, + ) + + return { + version: await module.getVersion(), + success: result.code === 0, + startedAt, + completedAt: new Date(), + output: result.output, + } + }, + }, + }, } + +export const gardenPlugin = () => genericPlugin diff --git a/src/plugins/google/base.ts b/src/plugins/google/base.ts deleted file mode 100644 index 638bb49626..0000000000 --- a/src/plugins/google/base.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2018 Garden Technologies, Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Environment } from "../../types/common" -import { Module, ModuleConfig } from "../../types/module" -import { Service, ServiceConfig } from "../../types/service" -import { ConfigurationError } from "../../exceptions" -import { Memoize } from "typescript-memoize" -import { GCloud } from "./gcloud" -import { values } from "lodash" -import { ConfigureEnvironmentParams, Plugin } from "../../types/plugin" - -export const GOOGLE_CLOUD_DEFAULT_REGION = "us-central1" - -export interface GoogleCloudServiceConfig extends ServiceConfig { - project?: string -} - -export interface GoogleCloudModuleConfig extends ModuleConfig { } - -export abstract class GoogleCloudModule extends Module { } - -export abstract class GoogleCloudProviderBase implements Plugin { - abstract name: string - abstract supportedModuleTypes: string[] - - async getEnvironmentStatus() { - let sdkInfo - - const output = { - configured: true, - detail: { - sdkInstalled: true, - sdkInitialized: true, - betaComponentsInstalled: true, - sdkInfo: {}, - }, - } - - try { - sdkInfo = output.detail.sdkInfo = await this.gcloud().json(["info"]) - } catch (err) { - output.configured = false - output.detail.sdkInstalled = false - } - - if (!sdkInfo.config.account) { - output.configured = false - output.detail.sdkInitialized = false - } - - if (!sdkInfo.installation.components.beta) { - output.configured = false - output.detail.betaComponentsInstalled = false - } - - return output - } - - async configureEnvironment({ ctx }: ConfigureEnvironmentParams) { - const status = await this.getEnvironmentStatus() - - if (!status.detail.sdkInstalled) { - throw new ConfigurationError( - "Google Cloud SDK is not installed. " + - "Please visit https://cloud.google.com/sdk/downloads for installation instructions.", - {}, - ) - } - - if (!status.detail.betaComponentsInstalled) { - ctx.log.info({ - section: "google-cloud-functions", - msg: `Installing gcloud SDK beta components...`, - }) - await this.gcloud().call(["components update"]) - await this.gcloud().call(["components install beta"]) - } - - if (!status.detail.sdkInitialized) { - ctx.log.info({ - section: "google-cloud-functions", - msg: `Initializing SDK...`, - }) - await this.gcloud().tty(["init"], { silent: false }) - } - } - - @Memoize() - protected gcloud(project?: string, account?: string) { - return new GCloud({ project, account }) - } - - protected getProject(service: Service, env: Environment) { - // TODO: this is very contrived - we should rethink this a bit and pass - // provider configuration when calling the plugin - const providerConfig = values(env.config.providers).filter(p => p.type === this.name)[0] - return service.config.project || providerConfig["default-project"] || null - } -} diff --git a/src/plugins/google/common.ts b/src/plugins/google/common.ts new file mode 100644 index 0000000000..864196e451 --- /dev/null +++ b/src/plugins/google/common.ts @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Environment } from "../../types/common" +import { Module, ModuleConfig } from "../../types/module" +import { Service, ServiceConfig } from "../../types/service" +import { ConfigurationError } from "../../exceptions" +import { GCloud } from "./gcloud" +import { ConfigureEnvironmentParams } from "../../types/plugin" + +export const GOOGLE_CLOUD_DEFAULT_REGION = "us-central1" + +export interface GoogleCloudServiceConfig extends ServiceConfig { + project?: string +} + +export interface GoogleCloudModuleConfig extends ModuleConfig { } + +export abstract class GoogleCloudModule extends Module { } + +export async function getEnvironmentStatus() { + let sdkInfo + + const output = { + configured: true, + detail: { + sdkInstalled: true, + sdkInitialized: true, + betaComponentsInstalled: true, + sdkInfo: {}, + }, + } + + try { + sdkInfo = output.detail.sdkInfo = await gcloud().json(["info"]) + } catch (err) { + output.configured = false + output.detail.sdkInstalled = false + } + + if (!sdkInfo.config.account) { + output.configured = false + output.detail.sdkInitialized = false + } + + if (!sdkInfo.installation.components.beta) { + output.configured = false + output.detail.betaComponentsInstalled = false + } + + return output +} + +export async function configureEnvironment({ ctx }: ConfigureEnvironmentParams) { + const status = await getEnvironmentStatus() + + if (!status.detail.sdkInstalled) { + throw new ConfigurationError( + "Google Cloud SDK is not installed. " + + "Please visit https://cloud.google.com/sdk/downloads for installation instructions.", + {}, + ) + } + + if (!status.detail.betaComponentsInstalled) { + ctx.log.info({ + section: "google-cloud-functions", + msg: `Installing gcloud SDK beta components...`, + }) + await gcloud().call(["components update"]) + await gcloud().call(["components install beta"]) + } + + if (!status.detail.sdkInitialized) { + ctx.log.info({ + section: "google-cloud-functions", + msg: `Initializing SDK...`, + }) + await gcloud().tty(["init"], { silent: false }) + } +} + +export function gcloud(project?: string, account?: string) { + return new GCloud({ project, account }) +} + +export function getProject(providerName: string, service: Service, env: Environment) { + // TODO: this is very contrived - we should rethink this a bit and pass + // provider configuration when calling the plugin + const providerConfig = env.config.providers[providerName] + return service.config.project || providerConfig["default-project"] || null +} diff --git a/src/plugins/google/google-app-engine.ts b/src/plugins/google/google-app-engine.ts index ab9e0e476a..785f1caf72 100644 --- a/src/plugins/google/google-app-engine.ts +++ b/src/plugins/google/google-app-engine.ts @@ -8,10 +8,22 @@ import { ServiceStatus } from "../../types/service" import { join } from "path" -import { GOOGLE_CLOUD_DEFAULT_REGION, GoogleCloudProviderBase } from "./base" +import { + gcloud, + getProject, +} from "./common" +import { + getEnvironmentStatus, + GOOGLE_CLOUD_DEFAULT_REGION, + configureEnvironment, +} from "./common" import { ContainerModule, ContainerModuleConfig, ContainerServiceConfig } from "../container" import { dumpYaml } from "../../util" -import { DeployServiceParams, PluginActionParams } from "../../types/plugin" +import { + DeployServiceParams, + GardenPlugin, + GetServiceOutputsParams, +} from "../../types/plugin" export interface GoogleAppEngineServiceConfig extends ContainerServiceConfig { project: string @@ -21,70 +33,74 @@ export interface GoogleAppEngineModuleConfig extends ContainerModuleConfig { } -// TODO: support built-in GAE types (not just custom/flex containers) -export class GoogleAppEngineProvider extends GoogleCloudProviderBase { - name = "google-app-engine" - supportedModuleTypes = ["container"] - - async getServiceStatus(): Promise { - // TODO - // const project = this.getProject(service, env) - // - // const appStatus = await this.gcloud(project).json(["app", "describe"]) - // const services = await this.gcloud(project).json(["app", "services", "list"]) - // const instances: any[] = await this.gcloud(project).json(["app", "instances", "list"]) - - return {} - } - - async deployService({ ctx, service, serviceContext, env }: DeployServiceParams) { - ctx.log.info({ - section: service.name, - msg: `Deploying app...`, - }) - - const config = service.config - - // prepare app.yaml - const appYaml: any = { - runtime: "custom", - env: "flex", - env_variables: serviceContext.envVars, - } - - if (config.healthCheck) { - if (config.healthCheck.tcpPort || config.healthCheck.command) { - ctx.log.warn({ +export const gardenPlugin = (): GardenPlugin => ({ + actions: { + getEnvironmentStatus, + configureEnvironment, + }, + moduleActions: { + container: { + async getServiceStatus(): Promise { + // TODO + // const project = this.getProject(service, env) + // + // const appStatus = await this.gcloud(project).json(["app", "describe"]) + // const services = await this.gcloud(project).json(["app", "services", "list"]) + // const instances: any[] = await this.gcloud(project).json(["app", "instances", "list"]) + + return {} + }, + + async deployService({ ctx, service, serviceContext, env }: DeployServiceParams) { + ctx.log.info({ section: service.name, - msg: "GAE only supports httpGet health checks", + msg: `Deploying app...`, }) - } - if (config.healthCheck.httpGet) { - appYaml.liveness_check = { path: config.healthCheck.httpGet.path } - appYaml.readiness_check = { path: config.healthCheck.httpGet.path } - } - } - - // write app.yaml to build context - const appYamlPath = join(service.module.path, "app.yaml") - dumpYaml(appYamlPath, appYaml) - - // deploy to GAE - const project = this.getProject(service, env) - - await this.gcloud(project).call([ - "app", "deploy", "--quiet", - ], { cwd: service.module.path }) - - ctx.log.info({ section: service.name, msg: `App deployed` }) - } - - async getServiceOutputs({ service, env }: PluginActionParams["getServiceOutputs"]) { - // TODO: we may want to pull this from the service status instead, along with other outputs - const project = this.getProject(service, env) - - return { - endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${service.name}`, - } - } -} + + const config = service.config + + // prepare app.yaml + const appYaml: any = { + runtime: "custom", + env: "flex", + env_variables: serviceContext.envVars, + } + + if (config.healthCheck) { + if (config.healthCheck.tcpPort || config.healthCheck.command) { + ctx.log.warn({ + section: service.name, + msg: "GAE only supports httpGet health checks", + }) + } + if (config.healthCheck.httpGet) { + appYaml.liveness_check = { path: config.healthCheck.httpGet.path } + appYaml.readiness_check = { path: config.healthCheck.httpGet.path } + } + } + + // write app.yaml to build context + const appYamlPath = join(service.module.path, "app.yaml") + dumpYaml(appYamlPath, appYaml) + + // deploy to GAE + const project = getProject("google-app-engine", service, env) + + await gcloud(project).call([ + "app", "deploy", "--quiet", + ], { cwd: service.module.path }) + + ctx.log.info({ section: service.name, msg: `App deployed` }) + }, + + async getServiceOutputs({ service, env }: GetServiceOutputsParams) { + // TODO: we may want to pull this from the service status instead, along with other outputs + const project = getProject("google-app-engine", service, env) + + return { + endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${service.name}`, + } + }, + }, + }, +}) diff --git a/src/plugins/google/google-cloud-functions.ts b/src/plugins/google/google-cloud-functions.ts index 6dd9244e2b..a82e8f51cd 100644 --- a/src/plugins/google/google-cloud-functions.ts +++ b/src/plugins/google/google-cloud-functions.ts @@ -6,15 +6,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { PluginContext } from "../../plugin-context" import { identifierRegex, validate } from "../../types/common" import { baseServiceSchema, Module, ModuleConfig } from "../../types/module" import { ServiceConfig, ServiceState, ServiceStatus } from "../../types/service" -import { resolve } from "path" +import { + resolve, +} from "path" import * as Joi from "joi" import { GARDEN_ANNOTATION_KEYS_VERSION } from "../../constants" -import { GOOGLE_CLOUD_DEFAULT_REGION, GoogleCloudProviderBase } from "./base" -import { PluginActionParams } from "../../types/plugin" +import { + configureEnvironment, + gcloud, + getEnvironmentStatus, + getProject, + GOOGLE_CLOUD_DEFAULT_REGION, +} from "./common" +import { + DeployServiceParams, + GardenPlugin, + GetServiceOutputsParams, + GetServiceStatusParams, + ParseModuleParams, +} from "../../types/plugin" export interface GoogleCloudFunctionsServiceConfig extends ServiceConfig { function: string, @@ -35,73 +48,82 @@ export const gcfServicesSchema = Joi.object() export class GoogleCloudFunctionsModule extends Module { } -export class GoogleCloudFunctionsProvider extends GoogleCloudProviderBase { - name = "google-cloud-functions" - supportedModuleTypes = ["google-cloud-function"] - - async parseModule({ ctx, config }: { ctx: PluginContext, config: GoogleCloudFunctionsModuleConfig }) { - const module = new GoogleCloudFunctionsModule(ctx, config) - - // TODO: check that each function exists at the specified path - - module.services = validate(config.services, gcfServicesSchema, `services in module ${config.name}`) - - return module - } - - async getServiceStatus( - { service, env }: PluginActionParams["getServiceStatus"], - ): Promise { - const project = this.getProject(service, env) - const functions: any[] = await this.gcloud(project).json(["beta", "functions", "list"]) - const providerId = `projects/${project}/locations/${GOOGLE_CLOUD_DEFAULT_REGION}/functions/${service.name}` - - const status = functions.filter(f => f.name === providerId)[0] - - if (!status) { - // not deployed yet - return {} - } - - // TODO: map states properly - const state: ServiceState = status.status === "ACTIVE" ? "ready" : "unhealthy" - - return { - providerId, - providerVersion: status.versionId, - version: status.labels[GARDEN_ANNOTATION_KEYS_VERSION], - state, - updatedAt: status.updateTime, - detail: status, - } - } - - async deployService( - { ctx, service, env }: PluginActionParams["deployService"], - ) { - // TODO: provide env vars somehow to function - const project = this.getProject(service, env) - const functionPath = resolve(service.module.path, service.config.path) - const entrypoint = service.config.entrypoint || service.name - - await this.gcloud(project).call([ - "beta", "functions", - "deploy", service.name, - `--source=${functionPath}`, - `--entry-point=${entrypoint}`, - // TODO: support other trigger types - "--trigger-http", - ]) - - return this.getServiceStatus({ ctx, service, env }) +const pluginName = "google-cloud-functions" + +export const gardenPlugin = (): GardenPlugin => ({ + actions: { + getEnvironmentStatus, + configureEnvironment, + }, + moduleActions: { + "google-cloud-function": { + async parseModule({ ctx, moduleConfig }: ParseModuleParams) { + const module = new GoogleCloudFunctionsModule(ctx, moduleConfig) + + // TODO: check that each function exists at the specified path + + module.services = validate( + moduleConfig.services, gcfServicesSchema, `services in module ${moduleConfig.name}`, + ) + + return module + }, + + async deployService( + { ctx, config, service, env }: DeployServiceParams, + ) { + // TODO: provide env vars somehow to function + const project = getProject(pluginName, service, env) + const functionPath = resolve(service.module.path, service.config.path) + const entrypoint = service.config.entrypoint || service.name + + await gcloud(project).call([ + "beta", "functions", + "deploy", service.name, + `--source=${functionPath}`, + `--entry-point=${entrypoint}`, + // TODO: support other trigger types + "--trigger-http", + ]) + + return getServiceStatus({ ctx, config, service, env }) + }, + + async getServiceOutputs({ service, env }: GetServiceOutputsParams) { + // TODO: we may want to pull this from the service status instead, along with other outputs + const project = getProject(pluginName, service, env) + + return { + endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${service.name}`, + } + }, + }, + }, +}) + +export async function getServiceStatus( + { service, env }: GetServiceStatusParams, +): Promise { + const project = getProject(pluginName, service, env) + const functions: any[] = await gcloud(project).json(["beta", "functions", "list"]) + const providerId = `projects/${project}/locations/${GOOGLE_CLOUD_DEFAULT_REGION}/functions/${service.name}` + + const status = functions.filter(f => f.name === providerId)[0] + + if (!status) { + // not deployed yet + return {} } - async getServiceOutputs({ service, env }: PluginActionParams["getServiceOutputs"]) { - // TODO: we may want to pull this from the service status instead, along with other outputs - const project = this.getProject(service, env) + // TODO: map states properly + const state: ServiceState = status.status === "ACTIVE" ? "ready" : "unhealthy" - return { - endpoint: `https://${GOOGLE_CLOUD_DEFAULT_REGION}-${project}.cloudfunctions.net/${service.name}`, - } + return { + providerId, + providerVersion: status.versionId, + version: status.labels[GARDEN_ANNOTATION_KEYS_VERSION], + state, + updatedAt: status.updateTime, + detail: status, } } diff --git a/src/plugins/index.ts b/src/plugins/index.ts index fdcaf8b7b8..90854d4a85 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -6,20 +6,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DockerModuleHandler } from "./container" -import { GoogleCloudFunctionsProvider } from "./google/google-cloud-functions" -import { LocalGoogleCloudFunctionsProvider } from "./local/local-google-cloud-functions" -import { KubernetesProvider } from "./kubernetes" -import { NpmPackageModuleHandler } from "./npm-package" -import { GoogleAppEngineProvider } from "./google/google-app-engine" -import { PluginFactory } from "../types/plugin" - +import { resolve } from "path" // TODO: these should be configured, either explicitly or as dependencies of other plugins -export const defaultPlugins: PluginFactory[] = [ - DockerModuleHandler, - NpmPackageModuleHandler, - KubernetesProvider, - GoogleAppEngineProvider, - GoogleCloudFunctionsProvider, - LocalGoogleCloudFunctionsProvider, -].map(pluginClass => (_ctx) => new pluginClass()) +import { RegisterPluginParam } from "../types/plugin" + +// These plugins are always registered +export const builtinPlugins: RegisterPluginParam[] = [ + "./generic", + "./container", + "./google/google-cloud-functions", + "./local/local-google-cloud-functions", + "./kubernetes", + "./npm-package", + "./google/google-app-engine", +].map(p => resolve(__dirname, p)) + +// These plugins are always loaded +export const fixedPlugins = [ + "generic", +] diff --git a/src/plugins/kubernetes/actions.ts b/src/plugins/kubernetes/actions.ts new file mode 100644 index 0000000000..be80316a97 --- /dev/null +++ b/src/plugins/kubernetes/actions.ts @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { DeploymentError, NotFoundError } from "../../exceptions" +import { + ConfigureEnvironmentParams, DeleteConfigParams, + DestroyEnvironmentParams, + ExecInServiceParams, GetConfigParams, + GetEnvironmentStatusParams, + GetServiceLogsParams, + GetServiceOutputsParams, + GetServiceStatusParams, GetTestResultParams, + SetConfigParams, + TestModuleParams, TestResult, +} from "../../types/plugin" +import { TreeVersion } from "../../vcs/base" +import { + ContainerModule, +} from "../container" +import { values, every, map, extend } from "lodash" +import { deserializeKeys, serializeKeys, splitFirst } from "../../util" +import { ServiceStatus } from "../../types/service" +import { + apiGetOrNull, + apiPostOrPut, + coreApi, +} from "./api" +import { + createNamespace, + getAppNamespace, + getMetadataNamespace, +} from "./namespace" +import { + kubectl, +} from "./kubectl" +import { DEFAULT_TEST_TIMEOUT } from "../../constants" +import * as split from "split" +import moment = require("moment") +import { EntryStyle } from "../../logger/types" +import { + checkDeploymentStatus, +} from "./status" +import { + configureGlobalSystem, + getGlobalSystemStatus, +} from "./system-global" + +export async function getEnvironmentStatus({ ctx, env }: GetEnvironmentStatusParams) { + try { + // TODO: use API instead of kubectl (I just couldn't find which API call to make) + await kubectl().call(["version"]) + } catch (err) { + // TODO: catch error properly + if (err.output) { + throw new DeploymentError(err.output, { output: err.output }) + } + throw err + } + + const globalSystemStatus = await getGlobalSystemStatus(ctx, env) + + const statusDetail = { + systemNamespaceReady: false, + namespaceReady: false, + metadataNamespaceReady: false, + ...globalSystemStatus, + } + + const metadataNamespace = getMetadataNamespace(ctx) + const namespacesStatus = await coreApi().namespaces().get() + + for (const n of namespacesStatus.items) { + if (n.metadata.name === getAppNamespace(ctx, env) && n.status.phase === "Active") { + statusDetail.namespaceReady = true + } + + if (n.metadata.name === metadataNamespace && n.status.phase === "Active") { + statusDetail.metadataNamespaceReady = true + } + } + + let configured = every(values(statusDetail)) + + return { + configured, + detail: statusDetail, + } +} + +export async function configureEnvironment(params: ConfigureEnvironmentParams) { + // TODO: use Helm 3 when it's released instead of this custom/manual stuff + const { ctx, config, env, logEntry } = params + const status = await getEnvironmentStatus({ ctx, config, env }) + + if (status.configured) { + return + } + + await configureGlobalSystem(params, status) + + if (!status.detail.namespaceReady) { + const ns = getAppNamespace(ctx, env) + logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating namespace ${ns}` }) + await createNamespace(ns) + } + + if (!status.detail.metadataNamespaceReady) { + const ns = getMetadataNamespace(ctx) + logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating namespace ${ns}` }) + await createNamespace(ns) + } +} + +export async function getServiceStatus( + { ctx, service }: GetServiceStatusParams, +): Promise { + // TODO: hash and compare all the configuration files (otherwise internal changes don't get deployed) + return await checkDeploymentStatus({ ctx, service }) +} + +export async function destroyEnvironment({ ctx, env }: DestroyEnvironmentParams) { + const namespace = getAppNamespace(ctx, env) + const entry = ctx.log.info({ + section: "kubernetes", + msg: `Deleting namespace ${namespace}`, + entryStyle: EntryStyle.activity, + }) + try { + await coreApi().namespace(namespace).delete(namespace) + entry.setSuccess("Finished") + } catch (err) { + entry.setError(err.message) + throw new NotFoundError(err, { namespace }) + } +} + +export async function getServiceOutputs({ service }: GetServiceOutputsParams) { + return { + host: service.name, + } +} + +export async function execInService({ ctx, config, service, env, command }: ExecInServiceParams) { + const status = await getServiceStatus({ ctx, config, service, env }) + const namespace = getAppNamespace(ctx, env) + + // TODO: this check should probably live outside of the plugin + if (!status.state || status.state !== "ready") { + throw new DeploymentError(`Service ${service.name} is not running`, { + name: service.name, + state: status.state, + }) + } + + // get a running pod + let res = await coreApi(namespace).namespaces.pods.get({ + qs: { + labelSelector: `service=${service.name}`, + }, + }) + const pod = res.items[0] + + if (!pod) { + // This should not happen because of the prior status check, but checking to be sure + throw new DeploymentError(`Could not find running pod for ${service.name}`, { + serviceName: service.name, + }) + } + + // exec in the pod via kubectl + res = await kubectl(namespace).tty(["exec", "-it", pod.metadata.name, "--", ...command]) + + return { code: res.code, output: res.output } +} + +export async function testModule({ ctx, module, testSpec }: TestModuleParams): Promise { + // TODO: include a service context here + const baseEnv = {} + const envVars: {} = extend({}, baseEnv, testSpec.variables) + const envArgs = map(envVars, (v: string, k: string) => `--env=${k}=${v}`) + + // TODO: use the runModule() method + const testCommandStr = testSpec.command.join(" ") + const image = await module.getLocalImageId() + const version = await module.getVersion() + + const kubecmd = [ + "run", `run-${module.name}-${Math.round(new Date().getTime())}`, + `--image=${image}`, + "--restart=Never", + "--command", + "-i", + "--tty", + "--rm", + ...envArgs, + "--", + "/bin/sh", + "-c", + testCommandStr, + ] + + const startedAt = new Date() + + const timeout = testSpec.timeout || DEFAULT_TEST_TIMEOUT + const res = await kubectl(getAppNamespace(ctx)).tty(kubecmd, { ignoreError: true, timeout }) + + const testResult: TestResult = { + version, + success: res.code === 0, + startedAt, + completedAt: new Date(), + output: res.output, + } + + const ns = getMetadataNamespace(ctx) + const resultKey = `test-result--${module.name}--${version.versionString}` + const body = { + body: { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: resultKey, + annotations: { + "garden.io/generated": "true", + }, + }, + type: "generic", + data: serializeKeys(testResult), + }, + } + + await apiPostOrPut(coreApi(ns).namespaces.configmaps, resultKey, body) + + return testResult +} + +export async function getTestResult({ ctx, module, version }: GetTestResultParams) { + const ns = getMetadataNamespace(ctx) + const resultKey = getTestResultKey(module, version) + const res = await apiGetOrNull(coreApi(ns).namespaces.configmaps, resultKey) + return res && deserializeKeys(res.data) +} + +export async function getServiceLogs({ ctx, service, stream, tail }: GetServiceLogsParams) { + const resourceType = service.config.daemon ? "daemonset" : "deployment" + + const kubectlArgs = ["logs", `${resourceType}/${service.name}`, "--timestamps=true"] + + if (tail) { + kubectlArgs.push("--follow") + } + + const proc = kubectl(getAppNamespace(ctx)).spawn(kubectlArgs) + + proc.stdout + .pipe(split()) + .on("data", (s) => { + if (!s) { + return + } + const [timestampStr, msg] = splitFirst(s, " ") + const timestamp = moment(timestampStr) + stream.write({ serviceName: service.name, timestamp, msg }) + }) + + proc.stderr.pipe(process.stderr) + + return new Promise((resolve, reject) => { + proc.on("error", reject) + + proc.on("exit", () => { + resolve() + }) + }) +} + +export async function getConfig({ ctx, key }: GetConfigParams) { + const ns = getMetadataNamespace(ctx) + const res = await apiGetOrNull(coreApi(ns).namespaces.secrets, key.join(".")) + return res && Buffer.from(res.data.value, "base64").toString() +} + +export async function setConfig({ ctx, key, value }: SetConfigParams) { + // we store configuration in a separate metadata namespace, so that configs aren't cleared when wiping the namespace + const ns = getMetadataNamespace(ctx) + const body = { + body: { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: key, + annotations: { + "garden.io/generated": "true", + }, + }, + type: "generic", + stringData: { value }, + }, + } + + await apiPostOrPut(coreApi(ns).namespaces.secrets, key.join("."), body) +} + +export async function deleteConfig({ ctx, key }: DeleteConfigParams) { + const ns = getMetadataNamespace(ctx) + try { + await coreApi(ns).namespaces.secrets(key.join(".")).delete() + } catch (err) { + if (err.code === 404) { + return { found: false } + } else { + throw err + } + } + return { found: true } +} + +function getTestResultKey(module: ContainerModule, version: TreeVersion) { + return `test-result--${module.name}--${version.versionString}` +} diff --git a/src/plugins/kubernetes/index.ts b/src/plugins/kubernetes/index.ts index edfde08ef6..162e37447c 100644 --- a/src/plugins/kubernetes/index.ts +++ b/src/plugins/kubernetes/index.ts @@ -6,338 +6,46 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DeploymentError, NotFoundError } from "../../exceptions" -import { - ConfigureEnvironmentParams, DeleteConfigParams, - DeployServiceParams, DestroyEnvironmentParams, - ExecInServiceParams, GetConfigParams, - GetEnvironmentStatusParams, - GetServiceLogsParams, - GetServiceOutputsParams, - GetServiceStatusParams, GetTestResultParams, Plugin, - SetConfigParams, - TestModuleParams, TestResult, -} from "../../types/plugin" -import { TreeVersion } from "../../vcs/base" -import { - ContainerModule, -} from "../container" -import { values, every, map, extend } from "lodash" -import { deserializeKeys, serializeKeys, splitFirst } from "../../util" -import { ServiceStatus } from "../../types/service" -import { - apiGetOrNull, - apiPostOrPut, - coreApi, -} from "./api" -import { - createNamespace, - getAppNamespace, - getMetadataNamespace, -} from "./namespace" -import { - deployService, -} from "./deployment" -import { - kubectl, -} from "./kubectl" -import { DEFAULT_TEST_TIMEOUT } from "../../constants" -import * as split from "split" -import moment = require("moment") -import { EntryStyle } from "../../logger/types" -import { - checkDeploymentStatus, -} from "./status" -import { - configureGlobalSystem, - getGlobalSystemStatus, -} from "./system-global" - -export class KubernetesProvider implements Plugin { - name = "kubernetes" - supportedModuleTypes = ["container"] - - // TODO: validate provider config - - //=========================================================================== - //region Plugin actions - //=========================================================================== - - async getEnvironmentStatus({ ctx, env }: GetEnvironmentStatusParams) { - try { - // TODO: use API instead of kubectl (I just couldn't find which API call to make) - await kubectl().call(["version"]) - } catch (err) { - // TODO: catch error properly - if (err.output) { - throw new DeploymentError(err.output, { output: err.output }) - } - throw err - } - - const globalSystemStatus = await getGlobalSystemStatus(ctx, env) - - const statusDetail = { - systemNamespaceReady: false, - namespaceReady: false, - metadataNamespaceReady: false, - ...globalSystemStatus, - } - - const metadataNamespace = getMetadataNamespace(ctx) - const namespacesStatus = await coreApi().namespaces().get() - - for (const n of namespacesStatus.items) { - if (n.metadata.name === getAppNamespace(ctx, env) && n.status.phase === "Active") { - statusDetail.namespaceReady = true - } - - if (n.metadata.name === metadataNamespace && n.status.phase === "Active") { - statusDetail.metadataNamespaceReady = true - } - } - - let configured = every(values(statusDetail)) - - return { - configured, - detail: statusDetail, - } - } - - async configureEnvironment(params: ConfigureEnvironmentParams) { - // TODO: use Helm 3 when it's released instead of this custom/manual stuff - const { ctx, env, logEntry } = params - const status = await this.getEnvironmentStatus({ ctx, env }) - - if (status.configured) { - return - } - - await configureGlobalSystem(params, status) - - if (!status.detail.namespaceReady) { - const ns = getAppNamespace(ctx, env) - logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating namespace ${ns}` }) - await createNamespace(ns) - } - - if (!status.detail.metadataNamespaceReady) { - const ns = getMetadataNamespace(ctx) - logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating namespace ${ns}` }) - await createNamespace(ns) - } - } - - async getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { - // TODO: hash and compare all the configuration files (otherwise internal changes don't get deployed) - return await checkDeploymentStatus({ ctx, service }) - } - - async destroyEnvironment({ ctx, env }: DestroyEnvironmentParams) { - const namespace = getAppNamespace(ctx, env) - const entry = ctx.log.info({ - section: "kubernetes", - msg: `Deleting namespace ${namespace}`, - entryStyle: EntryStyle.activity, - }) - try { - await coreApi().namespace(namespace).delete(namespace) - entry.setSuccess("Finished") - } catch (err) { - entry.setError(err.message) - throw new NotFoundError(err, { namespace }) - } - } - - async deployService(params: DeployServiceParams) { - return deployService(params) - } - - async getServiceOutputs({ service }: GetServiceOutputsParams) { - return { - host: service.name, - } - } - - async execInService({ ctx, service, env, command }: ExecInServiceParams) { - const status = await this.getServiceStatus({ ctx, service, env }) - const namespace = getAppNamespace(ctx, env) - - // TODO: this check should probably live outside of the plugin - if (!status.state || status.state !== "ready") { - throw new DeploymentError(`Service ${service.name} is not running`, { - name: service.name, - state: status.state, - }) - } - - // get a running pod - let res = await coreApi(namespace).namespaces.pods.get({ - qs: { - labelSelector: `service=${service.name}`, +import { GardenPlugin } from "../../types/plugin" + +import { + configureEnvironment, + deleteConfig, + destroyEnvironment, + execInService, + getConfig, + getEnvironmentStatus, + getServiceLogs, + getServiceOutputs, + getServiceStatus, + getTestResult, + setConfig, + testModule, +} from "./actions" +import { deployService } from "./deployment" + +export const name = "kubernetes" + +export function gardenPlugin(): GardenPlugin { + return { + actions: { + getEnvironmentStatus, + configureEnvironment, + destroyEnvironment, + getConfig, + setConfig, + deleteConfig, + }, + moduleActions: { + container: { + getServiceStatus, + deployService, + getServiceOutputs, + execInService, + testModule, + getTestResult, + getServiceLogs, }, - }) - const pod = res.items[0] - - if (!pod) { - // This should not happen because of the prior status check, but checking to be sure - throw new DeploymentError(`Could not find running pod for ${service.name}`, { - serviceName: service.name, - }) - } - - // exec in the pod via kubectl - res = await kubectl(namespace).tty(["exec", "-it", pod.metadata.name, "--", ...command]) - - return { code: res.code, output: res.output } - } - - async testModule({ ctx, module, testSpec }: TestModuleParams): Promise { - // TODO: include a service context here - const baseEnv = {} - const envVars: {} = extend({}, baseEnv, testSpec.variables) - const envArgs = map(envVars, (v: string, k: string) => `--env=${k}=${v}`) - - // TODO: use the runModule() method - const testCommandStr = testSpec.command.join(" ") - const image = await module.getLocalImageId() - const version = await module.getVersion() - - const kubecmd = [ - "run", `run-${module.name}-${Math.round(new Date().getTime())}`, - `--image=${image}`, - "--restart=Never", - "--command", - "-i", - "--tty", - "--rm", - ...envArgs, - "--", - "/bin/sh", - "-c", - testCommandStr, - ] - - const startedAt = new Date() - - const timeout = testSpec.timeout || DEFAULT_TEST_TIMEOUT - const res = await kubectl(getAppNamespace(ctx)).tty(kubecmd, { ignoreError: true, timeout }) - - const testResult: TestResult = { - version, - success: res.code === 0, - startedAt, - completedAt: new Date(), - output: res.output, - } - - const ns = getMetadataNamespace(ctx) - const resultKey = `test-result--${module.name}--${version.versionString}` - const body = { - body: { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name: resultKey, - annotations: { - "garden.io/generated": "true", - }, - }, - type: "generic", - data: serializeKeys(testResult), - }, - } - - await apiPostOrPut(coreApi(ns).namespaces.configmaps, resultKey, body) - - return testResult - } - - async getTestResult({ ctx, module, version }: GetTestResultParams) { - const ns = getMetadataNamespace(ctx) - const resultKey = getTestResultKey(module, version) - const res = await apiGetOrNull(coreApi(ns).namespaces.configmaps, resultKey) - return res && deserializeKeys(res.data) - } - - async getServiceLogs({ ctx, service, stream, tail }: GetServiceLogsParams) { - const resourceType = service.config.daemon ? "daemonset" : "deployment" - - const kubectlArgs = ["logs", `${resourceType}/${service.name}`, "--timestamps=true"] - - if (tail) { - kubectlArgs.push("--follow") - } - - const proc = kubectl(getAppNamespace(ctx)).spawn(kubectlArgs) - - proc.stdout - .pipe(split()) - .on("data", (s) => { - if (!s) { - return - } - const [timestampStr, msg] = splitFirst(s, " ") - const timestamp = moment(timestampStr) - stream.write({ serviceName: service.name, timestamp, msg }) - }) - - proc.stderr.pipe(process.stderr) - - return new Promise((resolve, reject) => { - proc.on("error", reject) - - proc.on("exit", () => { - resolve() - }) - }) - } - - async getConfig({ ctx, key }: GetConfigParams) { - const ns = getMetadataNamespace(ctx) - const res = await apiGetOrNull(coreApi(ns).namespaces.secrets, key.join(".")) - return res && Buffer.from(res.data.value, "base64").toString() + }, } - - async setConfig({ ctx, key, value }: SetConfigParams) { - // we store configuration in a separate metadata namespace, so that configs aren't cleared when wiping the namespace - const ns = getMetadataNamespace(ctx) - const body = { - body: { - apiVersion: "v1", - kind: "Secret", - metadata: { - name: key, - annotations: { - "garden.io/generated": "true", - }, - }, - type: "generic", - stringData: { value }, - }, - } - - await apiPostOrPut(coreApi(ns).namespaces.secrets, key.join("."), body) - } - - async deleteConfig({ ctx, key }: DeleteConfigParams) { - const ns = getMetadataNamespace(ctx) - try { - await coreApi(ns).namespaces.secrets(key.join(".")).delete() - } catch (err) { - if (err.code === 404) { - return { found: false } - } else { - throw err - } - } - return { found: true } - } - - //endregion -} - -function getTestResultKey(module: ContainerModule, version: TreeVersion) { - return `test-result--${module.name}--${version.versionString}` } diff --git a/src/plugins/kubernetes/system-global.ts b/src/plugins/kubernetes/system-global.ts index 8cadf21658..156a5a7175 100644 --- a/src/plugins/kubernetes/system-global.ts +++ b/src/plugins/kubernetes/system-global.ts @@ -80,7 +80,7 @@ export async function getGlobalSystemStatus(ctx: PluginContext, env: Environment } export async function configureGlobalSystem( - { ctx, env, logEntry }: ConfigureEnvironmentParams, status: EnvironmentStatus, + { ctx, config, env, logEntry }: ConfigureEnvironmentParams, status: EnvironmentStatus, ) { if (!status.detail.systemNamespaceReady) { logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating garden system namespace` }) @@ -99,6 +99,7 @@ export async function configureGlobalSystem( await deployService({ ctx, + config, service: await getDefaultBackendService(ctx), serviceContext: { envVars: {}, dependencies: {} }, env: gardenEnv, @@ -106,6 +107,7 @@ export async function configureGlobalSystem( }) await deployService({ ctx, + config, service: await getIngressControllerService(ctx), serviceContext: { envVars: {}, dependencies: {} }, env: gardenEnv, @@ -116,7 +118,7 @@ export async function configureGlobalSystem( } function getSystemEnv(env: Environment): Environment { - return { name: env.name, namespace: GARDEN_GLOBAL_SYSTEM_NAMESPACE, config: { providers: {} } } + return { name: env.name, namespace: GARDEN_GLOBAL_SYSTEM_NAMESPACE, config: { providers: {}, variables: {} } } } async function getIngressControllerService(ctx: PluginContext) { diff --git a/src/plugins/local/local-docker-swarm.ts b/src/plugins/local/local-docker-swarm.ts index 2aecdd2e1f..f2aea0fc65 100644 --- a/src/plugins/local/local-docker-swarm.ts +++ b/src/plugins/local/local-docker-swarm.ts @@ -8,287 +8,267 @@ import * as Docker from "dockerode" import { exec } from "child-process-promise" -import { Memoize } from "typescript-memoize" import { DeploymentError } from "../../exceptions" import { PluginContext } from "../../plugin-context" import { DeployServiceParams, ExecInServiceParams, GetServiceOutputsParams, GetServiceStatusParams, - Plugin, + GardenPlugin, } from "../../types/plugin" import { ContainerModule } from "../container" -import { sortBy, map } from "lodash" +import { + map, + sortBy, +} from "lodash" import { sleep } from "../../util" import { ServiceState, ServiceStatus } from "../../types/service" // should this be configurable and/or global across providers? const DEPLOY_TIMEOUT = 30 -// TODO: Support namespacing -export class LocalDockerSwarmProvider implements Plugin { - name = "local-docker-swarm" - supportedModuleTypes = ["container"] - - @Memoize() - protected getDocker() { - return new Docker() - } +const pluginName = "local-docker-swarm" + +export const gardenPlugin = (): GardenPlugin => ({ + actions: { + getEnvironmentStatus, + configureEnvironment, + }, + moduleActions: { + container: { + getServiceStatus, + + async deployService({ ctx, config, service, serviceContext, env }: DeployServiceParams) { + // TODO: split this method up and test + const { versionString } = await service.module.getVersion() + + ctx.log.info({ section: service.name, msg: `Deploying version ${versionString}` }) + + const identifier = await service.module.getLocalImageId() + const ports = Object.values(service.config.ports).map(p => { + const port: any = { + Protocol: p.protocol ? p.protocol.toLowerCase() : "tcp", + TargetPort: p.containerPort, + } + + if (p.hostPort) { + port.PublishedPort = p.hostPort + } + }) - async getEnvironmentStatus() { - const docker = this.getDocker() + const envVars = map(serviceContext.envVars, (v, k) => `${k}=${v}`) + + const volumeMounts = service.config.volumes.map(v => { + // TODO-LOW: Support named volumes + if (v.hostPath) { + return { + Type: "bind", + Source: v.hostPath, + Target: v.containerPath, + } + } else { + return { + Type: "tmpfs", + Target: v.containerPath, + } + } + }) - try { - await docker.swarmInspect() + const opts: any = { + Name: getSwarmServiceName(ctx, service.name), + Labels: { + environment: env.name, + namespace: env.namespace, + provider: pluginName, + }, + TaskTemplate: { + ContainerSpec: { + Image: identifier, + Command: service.config.command, + Env: envVars, + Mounts: volumeMounts, + }, + Resources: { + Limits: {}, + Reservations: {}, + }, + RestartPolicy: {}, + Placement: {}, + }, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + UpdateConfig: { + Parallelism: 1, + }, + EndpointSpec: { + Ports: ports, + }, + } - return { - configured: true, - } - } catch (err) { - if (err.statusCode === 503) { - // swarm has not been initialized - return { - configured: false, - services: [], + const docker = getDocker() + const serviceStatus = await getServiceStatus({ ctx, config, service, env }) + let swarmServiceStatus + let serviceId + + if (serviceStatus.providerId) { + const swarmService = await docker.getService(serviceStatus.providerId) + swarmServiceStatus = await swarmService.inspect() + opts.version = parseInt(swarmServiceStatus.Version.Index, 10) + ctx.log.verbose({ + section: service.name, + msg: `Updating existing Swarm service (version ${opts.version})`, + }) + await swarmService.update(opts) + serviceId = serviceStatus.providerId + } else { + ctx.log.verbose({ + section: service.name, + msg: `Creating new Swarm service`, + }) + const swarmService = await docker.createService(opts) + serviceId = swarmService.ID } - } else { - throw err - } - } - } - async getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { - const docker = this.getDocker() - const swarmServiceName = this.getSwarmServiceName(ctx, service.name) - const swarmService = docker.getService(swarmServiceName) - - let swarmServiceStatus - - try { - swarmServiceStatus = await swarmService.inspect() - } catch (err) { - if (err.statusCode === 404) { - // service does not exist - return {} - } else { - throw err - } - } + // Wait for service to be ready + const start = new Date().getTime() - const image = swarmServiceStatus.Spec.TaskTemplate.ContainerSpec.Image - const version = image.split(":")[1] + while (true) { + await sleep(1000) - const { lastState, lastError } = await this.getServiceState(swarmServiceStatus.ID) + const { lastState, lastError } = await getServiceState(serviceId) - return { - providerId: swarmServiceStatus.ID, - version, - runningReplicas: swarmServiceStatus.Spec.Mode.Replicated.Replicas, - state: mapContainerState(lastState), - lastError: lastError || undefined, - createdAt: swarmServiceStatus.CreatedAt, - updatedAt: swarmServiceStatus.UpdatedAt, - } - } + if (lastError) { + throw new DeploymentError(`Service ${service.name} ${lastState}: ${lastError}`, { + service, + state: lastState, + error: lastError, + }) + } - async configureEnvironment() { - const status = await this.getEnvironmentStatus() + if (mapContainerState(lastState) === "ready") { + break + } - if (!status.configured) { - await this.getDocker().swarmInit({}) - } - } - - async deployService({ ctx, service, serviceContext, env }: DeployServiceParams) { - // TODO: split this method up and test - const { versionString } = await service.module.getVersion() - - ctx.log.info({ section: service.name, msg: `Deploying version ${versionString}` }) - - const identifier = await service.module.getLocalImageId() - const ports = Object.values(service.config.ports).map(p => { - const port: any = { - Protocol: p.protocol ? p.protocol.toLowerCase() : "tcp", - TargetPort: p.containerPort, - } + if (new Date().getTime() - start > DEPLOY_TIMEOUT * 1000) { + throw new DeploymentError(`Timed out deploying ${service.name} (status: ${lastState}`, { + service, + state: lastState, + }) + } + } - if (p.hostPort) { - port.PublishedPort = p.hostPort - } - }) + ctx.log.info({ + section: service.name, + msg: `Ready`, + }) - const envVars = map(serviceContext.envVars, (v, k) => `${k}=${v}`) + return getServiceStatus({ ctx, config, service, env }) + }, - const volumeMounts = service.config.volumes.map(v => { - // TODO-LOW: Support named volumes - if (v.hostPath) { - return { - Type: "bind", - Source: v.hostPath, - Target: v.containerPath, - } - } else { + async getServiceOutputs({ ctx, service }: GetServiceOutputsParams) { return { - Type: "tmpfs", - Target: v.containerPath, + host: getSwarmServiceName(ctx, service.name), } - } - }) - - const opts: any = { - Name: this.getSwarmServiceName(ctx, service.name), - Labels: { - environment: env.name, - namespace: env.namespace, - provider: this.name, - }, - TaskTemplate: { - ContainerSpec: { - Image: identifier, - Command: service.config.command, - Env: envVars, - Mounts: volumeMounts, - }, - Resources: { - Limits: {}, - Reservations: {}, - }, - RestartPolicy: {}, - Placement: {}, - }, - Mode: { - Replicated: { - Replicas: 1, - }, - }, - UpdateConfig: { - Parallelism: 1, }, - EndpointSpec: { - Ports: ports, - }, - } - - const docker = this.getDocker() - const serviceStatus = await this.getServiceStatus({ ctx, service, env }) - let swarmServiceStatus - let serviceId - - if (serviceStatus.providerId) { - const swarmService = await docker.getService(serviceStatus.providerId) - swarmServiceStatus = await swarmService.inspect() - opts.version = parseInt(swarmServiceStatus.Version.Index, 10) - ctx.log.verbose({ - section: service.name, - msg: `Updating existing Swarm service (version ${opts.version})`, - }) - await swarmService.update(opts) - serviceId = serviceStatus.providerId - } else { - ctx.log.verbose({ - section: service.name, - msg: `Creating new Swarm service`, - }) - const swarmService = await docker.createService(opts) - serviceId = swarmService.ID - } - // Wait for service to be ready - const start = new Date().getTime() + async execInService({ ctx, config, env, service, command }: ExecInServiceParams) { + const status = await getServiceStatus({ ctx, config, service, env }) - while (true) { - await sleep(1000) + if (!status.state || status.state !== "ready") { + throw new DeploymentError(`Service ${service.name} is not running`, { + name: service.name, + state: status.state, + }) + } - const { lastState, lastError } = await this.getServiceState(serviceId) + // This is ugly, but dockerode doesn't have this, or at least it's too cumbersome to implement. + const swarmServiceName = getSwarmServiceName(ctx, service.name) + const servicePsCommand = [ + "docker", "service", "ps", + "-f", `'name=${swarmServiceName}.1'`, + "-f", `'desired-state=running'`, + swarmServiceName, + "-q", + ] + let res = await exec(servicePsCommand.join(" ")) + const serviceContainerId = `${swarmServiceName}.1.${res.stdout.trim()}` + + const execCommand = ["docker", "exec", serviceContainerId, ...command] + res = await exec(execCommand.join(" ")) + + return { code: 0, output: "", stdout: res.stdout, stderr: res.stderr } + }, + }, + }, +}) - if (lastError) { - throw new DeploymentError(`Service ${service.name} ${lastState}: ${lastError}`, { - service, - state: lastState, - error: lastError, - }) - } +async function getEnvironmentStatus() { + const docker = getDocker() - if (mapContainerState(lastState) === "ready") { - break - } + try { + await docker.swarmInspect() - if (new Date().getTime() - start > DEPLOY_TIMEOUT * 1000) { - throw new DeploymentError(`Timed out deploying ${service.name} (status: ${lastState}`, { - service, - state: lastState, - }) + return { + configured: true, + } + } catch (err) { + if (err.statusCode === 503) { + // swarm has not been initialized + return { + configured: false, + services: [], } + } else { + throw err } + } +} - ctx.log.info({ - section: service.name, - msg: `Ready`, - }) +async function configureEnvironment() { + const status = await getEnvironmentStatus() - return this.getServiceStatus({ ctx, service, env }) + if (!status.configured) { + await getDocker().swarmInit({}) } +} - async getServiceOutputs({ ctx, service }: GetServiceOutputsParams) { - return { - host: this.getSwarmServiceName(ctx, service.name), - } - } +async function getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { + const docker = getDocker() + const swarmServiceName = getSwarmServiceName(ctx, service.name) + const swarmService = docker.getService(swarmServiceName) - async execInService({ ctx, env, service, command }: ExecInServiceParams) { - const status = await this.getServiceStatus({ ctx, service, env }) + let swarmServiceStatus - if (!status.state || status.state !== "ready") { - throw new DeploymentError(`Service ${service.name} is not running`, { - name: service.name, - state: status.state, - }) + try { + swarmServiceStatus = await swarmService.inspect() + } catch (err) { + if (err.statusCode === 404) { + // service does not exist + return {} + } else { + throw err } - - // This is ugly, but dockerode doesn't have this, or at least it's too cumbersome to implement. - const swarmServiceName = this.getSwarmServiceName(ctx, service.name) - const servicePsCommand = [ - "docker", "service", "ps", - "-f", `'name=${swarmServiceName}.1'`, - "-f", `'desired-state=running'`, - swarmServiceName, - "-q", - ] - let res = await exec(servicePsCommand.join(" ")) - const serviceContainerId = `${swarmServiceName}.1.${res.stdout.trim()}` - - const execCommand = ["docker", "exec", serviceContainerId, ...command] - res = await exec(execCommand.join(" ")) - - return { code: 0, output: "", stdout: res.stdout, stderr: res.stderr } } - private getSwarmServiceName(ctx: PluginContext, serviceName: string) { - return `${ctx.projectName}--${serviceName}` - } + const image = swarmServiceStatus.Spec.TaskTemplate.ContainerSpec.Image + const version = image.split(":")[1] - private async getServiceTask(serviceId: string) { - let tasks = await this.getDocker().listTasks({ - // Service: this.getSwarmServiceName(service.name), - }) - // For whatever (presumably totally reasonable) reason, the filter option above does not work. - tasks = tasks.filter(t => t.ServiceID === serviceId) - tasks = sortBy(tasks, ["CreatedAt"]).reverse() + const { lastState, lastError } = await getServiceState(swarmServiceStatus.ID) - return tasks[0] + return { + providerId: swarmServiceStatus.ID, + version, + runningReplicas: swarmServiceStatus.Spec.Mode.Replicated.Replicas, + state: mapContainerState(lastState), + lastError: lastError || undefined, + createdAt: swarmServiceStatus.CreatedAt, + updatedAt: swarmServiceStatus.UpdatedAt, } +} - private async getServiceState(serviceId: string) { - const task = await this.getServiceTask(serviceId) - - let lastState - let lastError - - if (task) { - lastState = task.Status.State - lastError = task.Status.Err || null - } - - return { lastState, lastError } - } +function getDocker() { + return new Docker() } // see schema in https://docs.docker.com/engine/api/v1.35/#operation/TaskList @@ -311,3 +291,32 @@ const taskStateMap: { [key: string]: ServiceState } = { function mapContainerState(lastState: string | undefined): ServiceState | undefined { return lastState ? taskStateMap[lastState] : undefined } + +function getSwarmServiceName(ctx: PluginContext, serviceName: string) { + return `${ctx.projectName}--${serviceName}` +} + +async function getServiceTask(serviceId: string) { + let tasks = await getDocker().listTasks({ + // Service: this.getSwarmServiceName(service.name), + }) + // For whatever (presumably totally reasonable) reason, the filter option above does not work. + tasks = tasks.filter(t => t.ServiceID === serviceId) + tasks = sortBy(tasks, ["CreatedAt"]).reverse() + + return tasks[0] +} + +async function getServiceState(serviceId: string) { + const task = await getServiceTask(serviceId) + + let lastState + let lastError + + if (task) { + lastState = task.Status.State + lastError = task.Status.Err || null + } + + return { lastState, lastError } +} diff --git a/src/plugins/local/local-google-cloud-functions.ts b/src/plugins/local/local-google-cloud-functions.ts index c8ed47456d..1fbf3685a5 100644 --- a/src/plugins/local/local-google-cloud-functions.ts +++ b/src/plugins/local/local-google-cloud-functions.ts @@ -18,7 +18,7 @@ import { ConfigureEnvironmentParams, DeployServiceParams, GetEnvironmentStatusParams, GetServiceLogsParams, GetServiceOutputsParams, GetServiceStatusParams, ParseModuleParams, - Plugin, + GardenPlugin, } from "../../types/plugin" import { STATIC_DIR } from "../../constants" import { ContainerModule, ContainerService } from "../container" @@ -28,124 +28,135 @@ const emulatorModulePath = join(STATIC_DIR, "local-gcf-container") const emulatorPort = 8010 const emulatorServiceName = "google-cloud-functions" -export class LocalGoogleCloudFunctionsProvider implements Plugin { - name = "local-google-cloud-functions" - supportedModuleTypes = ["google-cloud-function"] - - async parseModule({ ctx, config }: ParseModuleParams) { - const module = new GoogleCloudFunctionsModule(ctx, config) - - // TODO: check that each function exists at the specified path - - module.services = validate(config.services, gcfServicesSchema, `services in module ${config.name}`) - - return module - } +export const gardenPlugin = (): GardenPlugin => ({ + actions: { + getEnvironmentStatus, + configureEnvironment, + }, + moduleActions: { + "google-cloud-function": { + async parseModule({ ctx, moduleConfig }: ParseModuleParams) { + const module = new GoogleCloudFunctionsModule(ctx, moduleConfig) + + // TODO: check that each function exists at the specified path + + module.services = validate( + moduleConfig.services, gcfServicesSchema, `services in module ${moduleConfig.name}`, + ) + + return module + }, + + getServiceStatus, + + async deployService({ ctx, config, service, env }: DeployServiceParams) { + const containerFunctionPath = resolve( + "/functions", + relative(ctx.projectRoot, service.module.path), + service.config.path, + ) + + const emulator = await getEmulatorService(ctx) + const result = await ctx.execInService( + emulator, + [ + "functions-emulator", "deploy", + "--trigger-http", + "--project", "local", + "--region", "local", + "--local-path", containerFunctionPath, + "--entry-point", service.config.entrypoint || service.name, + service.config.function, + ], + ) + + if (result.code !== 0) { + throw new DeploymentError(`Deploying function ${service.name} failed: ${result.output}`, { + serviceName: service.name, + error: result.stderr, + }) + } + + return getServiceStatus({ ctx, config, service, env }) + }, + + async getServiceOutputs({ ctx, service }: GetServiceOutputsParams) { + const emulator = await getEmulatorService(ctx) + + return { + endpoint: `http://${emulator.name}:${emulatorPort}/local/local/${service.config.function}`, + } + }, + + async getServiceLogs({ ctx, stream, tail }: GetServiceLogsParams) { + const emulator = await getEmulatorService(ctx) + // TODO: filter to only relevant function logs + return ctx.getServiceLogs(emulator, stream, tail) + }, + }, + }, +}) + +async function getEnvironmentStatus({ ctx }: GetEnvironmentStatusParams) { + // Check if functions emulator container is running + const status = await ctx.getServiceStatus(await getEmulatorService(ctx)) + + return { configured: status.state === "ready" } +} - async getEnvironmentStatus({ ctx }: GetEnvironmentStatusParams) { - // Check if functions emulator container is running - const status = await ctx.getServiceStatus(await this.getEmulatorService(ctx)) +async function configureEnvironment({ ctx, config, env, logEntry }: ConfigureEnvironmentParams) { + const status = await getEnvironmentStatus({ ctx, config, env }) - return { configured: status.state === "ready" } + // TODO: This check should happen ahead of calling this handler + if (status.configured) { + return } - async configureEnvironment({ ctx, env, logEntry }: ConfigureEnvironmentParams) { - const status = await this.getEnvironmentStatus({ ctx, env }) + const service = await getEmulatorService(ctx) - // TODO: This check should happen ahead of calling this handler - if (status.configured) { - return - } + // We mount the project root into the container, so we can exec deploy any function in there later. + service.config.volumes = [{ + name: "functions", + containerPath: "/functions", + hostPath: ctx.projectRoot, + }] - const service = await this.getEmulatorService(ctx) + // TODO: Publish this container separately from the project instead of building it here + await ctx.buildModule(service.module) + await ctx.deployService(service, undefined, logEntry) +} - // We mount the project root into the container, so we can exec deploy any function in there later. - service.config.volumes = [{ - name: "functions", - containerPath: "/functions", - hostPath: ctx.projectRoot, - }] +async function getServiceStatus( + { ctx, service }: GetServiceStatusParams, +): Promise { + const emulator = await getEmulatorService(ctx) + const emulatorStatus = await ctx.getServiceStatus(emulator) - // TODO: Publish this container separately from the project instead of building it here - await ctx.buildModule(service.module) - await ctx.deployService(service, undefined, logEntry) + if (emulatorStatus !== "ready") { + return { state: "stopped" } } - async getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { - const emulator = await this.getEmulatorService(ctx) - const emulatorStatus = await ctx.getServiceStatus(emulator) - - if (emulatorStatus !== "ready") { - return { state: "stopped" } - } - - const result = await ctx.execInService(emulator, ["functions-emulator", "list"]) - - // Regex fun. Yay. - // TODO: Submit issue/PR to @google-cloud/functions-emulator to get machine-readable output - if (result.output.match(new RegExp(`READY\\s+│\\s+${escapeStringRegexp(service.name)}\\s+│`, "g"))) { - // For now we don't have a way to track which version is developed. - // We most likely need to keep track of that on our side. - return { state: "ready" } - } else { - return {} - } - } + const result = await ctx.execInService(emulator, ["functions-emulator", "list"]) - async deployService({ ctx, service, env }: DeployServiceParams) { - const containerFunctionPath = resolve( - "/functions", - relative(ctx.projectRoot, service.module.path), - service.config.path, - ) - - const emulator = await this.getEmulatorService(ctx) - const result = await ctx.execInService( - emulator, - [ - "functions-emulator", "deploy", - "--trigger-http", - "--project", "local", - "--region", "local", - "--local-path", containerFunctionPath, - "--entry-point", service.config.entrypoint || service.name, - service.config.function, - ], - ) - - if (result.code !== 0) { - throw new DeploymentError(`Deploying function ${service.name} failed: ${result.output}`, { - serviceName: service.name, - error: result.stderr, - }) - } - - return this.getServiceStatus({ ctx, service, env }) + // Regex fun. Yay. + // TODO: Submit issue/PR to @google-cloud/functions-emulator to get machine-readable output + if (result.output.match(new RegExp(`READY\\s+│\\s+${escapeStringRegexp(service.name)}\\s+│`, "g"))) { + // For now we don't have a way to track which version is developed. + // We most likely need to keep track of that on our side. + return { state: "ready" } + } else { + return {} } +} - async getServiceOutputs({ ctx, service }: GetServiceOutputsParams) { - const emulator = await this.getEmulatorService(ctx) - - return { - endpoint: `http://${emulator.name}:${emulatorPort}/local/local/${service.config.function}`, - } - } +async function getEmulatorService(ctx: PluginContext) { + const module = await ctx.resolveModule(emulatorModulePath) - async getServiceLogs({ ctx, stream, tail }: GetServiceLogsParams) { - const emulator = await this.getEmulatorService(ctx) - // TODO: filter to only relevant function logs - return ctx.getServiceLogs(emulator, stream, tail) + if (!module) { + throw new PluginError(`Could not find Google Cloud Function emulator module`, { + emulatorModulePath, + }) } - private async getEmulatorService(ctx: PluginContext) { - const module = await ctx.resolveModule(emulatorModulePath) - - if (!module) { - throw new PluginError(`Could not find Google Cloud Function emulator module`, { - emulatorModulePath, - }) - } - - return ContainerService.factory(ctx, module, emulatorServiceName) - } + return ContainerService.factory(ctx, module, emulatorServiceName) } diff --git a/src/plugins/npm-package.ts b/src/plugins/npm-package.ts index 6ec794502f..27f11665c2 100644 --- a/src/plugins/npm-package.ts +++ b/src/plugins/npm-package.ts @@ -6,13 +6,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GenericModuleHandler } from "./generic" +import { ModuleConfig } from "../types/module" +import { GardenPlugin } from "../types/plugin" +import { ServiceConfig } from "../types/service" +import { + genericPlugin, +} from "./generic" -export class NpmPackageModuleHandler extends GenericModuleHandler { - name = "npm-package-module" - supportedModuleTypes = ["npm-package"] +let _moduleConfig: ModuleConfig +let _serviceConfig: ServiceConfig - // TODO: check package.json - // parseModule(module: Module) { - // } -} +export const gardenPlugin = (): GardenPlugin => ({ + moduleActions: { + "npm-package": genericPlugin.moduleActions.generic, + }, +}) diff --git a/src/types/common.ts b/src/types/common.ts index 922e03b024..6c898902f8 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { JoiObject } from "joi" import * as Joi from "joi" import * as uuid from "uuid" import { EnvironmentConfig } from "./project" @@ -28,6 +29,8 @@ export const joiIdentifier = () => Joi "cannot contain consecutive dashes and cannot end with a dash", ) +export const joiIdentifierMap = (valueSchema: JoiObject) => Joi.object().pattern(identifierRegex, valueSchema) + export const joiVariables = () => Joi .object().pattern(/[\w\d]+/i, joiPrimitive()) .default(() => ({}), "{}") diff --git a/src/types/config.ts b/src/types/config.ts index 16ea62d716..68fcbe38ca 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -18,7 +18,7 @@ import { extend } from "lodash" const CONFIG_FILENAME = "garden.yml" -export interface Config { +export interface GardenConfig { version: string dirname: string path: string @@ -33,10 +33,9 @@ export const configSchema = Joi.object() project: projectSchema, }) .optionalKeys(["module", "project"]) - .options({ allowUnknown: true }) .required() -export async function loadConfig(projectRoot: string, path: string): Promise { +export async function loadConfig(projectRoot: string, path: string): Promise { // TODO: nicer error messages when load/validation fails const absPath = join(path, CONFIG_FILENAME) let fileData @@ -49,7 +48,7 @@ export async function loadConfig(projectRoot: string, path: string): Promiseyaml.safeLoad(fileData) || {} + config = yaml.safeLoad(fileData) || {} } catch (err) { throw new ConfigurationError(`Could not parse ${CONFIG_FILENAME} in directory ${path} as valid YAML`, err) } @@ -68,7 +67,7 @@ export async function loadConfig(projectRoot: string, path: string): Promisevalidate(config, configSchema, relative(projectRoot, absPath)) + const parsed = validate(config, configSchema, relative(projectRoot, absPath)) const project = parsed.project const module = parsed.module diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 9b2da32181..a358a56418 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -6,24 +6,69 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Garden } from "../garden" +import * as Joi from "joi" import { PluginContext } from "../plugin-context" import { Module, TestSpec } from "./module" -import { Environment, Primitive, PrimitiveMap } from "./common" -import { Nullable } from "../util" +import { + Environment, + joiIdentifier, + joiIdentifierMap, + Primitive, + PrimitiveMap, +} from "./common" import { Service, ServiceContext, ServiceStatus } from "./service" import { LogEntry } from "../logger" import { Stream } from "ts-stream" import { Moment } from "moment" import { TreeVersion } from "../vcs/base" +import { mapValues } from "lodash" export interface PluginActionParamsBase { ctx: PluginContext + config: object logEntry?: LogEntry } export interface ParseModuleParams extends PluginActionParamsBase { - config: T["_ConfigType"] + moduleConfig: T["_ConfigType"] +} + +export interface GetEnvironmentStatusParams extends PluginActionParamsBase { + env: Environment, +} + +export interface ConfigureEnvironmentParams extends PluginActionParamsBase { + env: Environment, +} + +export interface DestroyEnvironmentParams extends PluginActionParamsBase { + env: Environment, +} + +export interface GetConfigParams extends PluginActionParamsBase { + env: Environment, + key: string[] +} + +export interface SetConfigParams extends PluginActionParamsBase { + env: Environment, + key: string[] + value: Primitive +} + +export interface DeleteConfigParams extends PluginActionParamsBase { + env: Environment, + key: string[] +} + +export interface PluginActionParams { + getEnvironmentStatus: GetEnvironmentStatusParams + configureEnvironment: ConfigureEnvironmentParams + destroyEnvironment: DestroyEnvironmentParams + + getConfig: GetConfigParams + setConfig: SetConfigParams + deleteConfig: DeleteConfigParams } export interface GetModuleBuildStatusParams extends PluginActionParamsBase { @@ -50,18 +95,6 @@ export interface GetTestResultParams extends PluginAc env: Environment, } -export interface GetEnvironmentStatusParams extends PluginActionParamsBase { - env: Environment, -} - -export interface ConfigureEnvironmentParams extends PluginActionParamsBase { - env: Environment, -} - -export interface DestroyEnvironmentParams extends PluginActionParamsBase { - env: Environment, -} - export interface GetServiceStatusParams extends PluginActionParamsBase { service: Service, env: Environment, @@ -93,23 +126,7 @@ export interface GetServiceLogsParams extends PluginA startTime?: Date, } -export interface GetConfigParams extends PluginActionParamsBase { - env: Environment, - key: string[] -} - -export interface SetConfigParams extends PluginActionParamsBase { - env: Environment, - key: string[] - value: Primitive -} - -export interface DeleteConfigParams extends PluginActionParamsBase { - env: Environment, - key: string[] -} - -export interface PluginActionParams { +export interface ModuleActionParams { parseModule: ParseModuleParams getModuleBuildStatus: GetModuleBuildStatusParams buildModule: BuildModuleParams @@ -117,19 +134,11 @@ export interface PluginActionParams { testModule: TestModuleParams getTestResult: GetTestResultParams - getEnvironmentStatus: GetEnvironmentStatusParams - configureEnvironment: ConfigureEnvironmentParams - destroyEnvironment: DestroyEnvironmentParams - getServiceStatus: GetServiceStatusParams deployService: DeployServiceParams getServiceOutputs: GetServiceOutputsParams execInService: ExecInServiceParams getServiceLogs: GetServiceLogsParams - - getConfig: GetConfigParams - setConfig: SetConfigParams - deleteConfig: DeleteConfigParams } export interface BuildResult { @@ -182,7 +191,17 @@ export interface DeleteConfigResult { found: boolean } -export interface PluginActionOutputs { +export interface PluginActionOutputs { + getEnvironmentStatus: Promise + configureEnvironment: Promise + destroyEnvironment: Promise + + getConfig: Promise + setConfig: Promise + deleteConfig: Promise +} + +export interface ModuleActionOutputs { parseModule: Promise getModuleBuildStatus: Promise buildModule: Promise @@ -190,61 +209,77 @@ export interface PluginActionOutputs { testModule: Promise getTestResult: Promise - getEnvironmentStatus: Promise - configureEnvironment: Promise - destroyEnvironment: Promise - getServiceStatus: Promise deployService: Promise // TODO: specify getServiceOutputs: Promise execInService: Promise getServiceLogs: Promise +} - getConfig: Promise - setConfig: Promise - deleteConfig: Promise +export type PluginActions = { + [P in keyof PluginActionParams]: (params: PluginActionParams[P]) => PluginActionOutputs[P] } -export type PluginActions = { - [P in keyof PluginActionParams]: (params: PluginActionParams[P]) => PluginActionOutputs[P] +export type ModuleActions = { + [P in keyof ModuleActionParams]: (params: ModuleActionParams[P]) => ModuleActionOutputs[P] } -export type PluginActionName = keyof PluginActions +export type PluginActionName = keyof PluginActions +export type ModuleActionName = keyof ModuleActions -// A little convoluted, but serves the purpose of making sure we don't forget to include actions -// in the `pluginActionNames` array -class _PluginActionKeys implements Nullable> { - parseModule = null - getModuleBuildStatus = null - buildModule = null - pushModule = null - testModule = null - getTestResult = null +interface PluginActionDescription { + description?: string +} - getEnvironmentStatus = null - configureEnvironment = null - destroyEnvironment = null - getServiceStatus = null - deployService = null - getServiceOutputs = null - execInService = null - getServiceLogs = null +const pluginActionDescriptions: {[P in PluginActionName]: PluginActionDescription } = { + getEnvironmentStatus: {}, + configureEnvironment: {}, + destroyEnvironment: {}, - getConfig = null - setConfig = null - deleteConfig = null + getConfig: {}, + setConfig: {}, + deleteConfig: {}, } -export const pluginActionNames: PluginActionName[] = - Object.keys(new _PluginActionKeys()) +const moduleActionDescriptions: {[P in ModuleActionName]: PluginActionDescription } = { + parseModule: {}, + getModuleBuildStatus: {}, + buildModule: {}, + pushModule: {}, + testModule: {}, + getTestResult: {}, -export interface Plugin extends Partial> { - name: string + getServiceStatus: {}, + deployService: {}, + getServiceOutputs: {}, + execInService: {}, + getServiceLogs: {}, +} - // Specify which module types are applicable to the module actions - supportedModuleTypes: string[] +export const pluginActionNames: PluginActionName[] = Object.keys(pluginActionDescriptions) +export const moduleActionNames: ModuleActionName[] = Object.keys(moduleActionDescriptions) +export interface GardenPlugin { configKeys?: string[] + + actions?: Partial + moduleActions?: { [moduleType: string]: Partial> } } -export type PluginFactory = (garden: Garden) => Plugin +export interface PluginFactory { + ({ garden: Garden, config: object }): GardenPlugin + pluginName?: string +} +export type RegisterPluginParam = string | PluginFactory + +export const pluginSchema = Joi.object().keys({ + actions: Joi.object().keys(mapValues(pluginActionDescriptions, () => Joi.func())), + moduleActions: joiIdentifierMap( + Joi.object().keys(mapValues(moduleActionDescriptions, () => Joi.func())), + ), +}) + +export const pluginModuleSchema = Joi.object().keys({ + name: joiIdentifier(), + gardenPlugin: Joi.func().required(), +}).unknown(true) diff --git a/src/types/project.ts b/src/types/project.ts index bd6e7762d9..2645ab3a68 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -6,53 +6,63 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { extend } from "lodash" import * as Joi from "joi" -import { identifierRegex, joiIdentifier, joiPrimitive, Primitive } from "./common" +import { + identifierRegex, + joiIdentifier, + joiVariables, + Primitive, +} from "./common" + +export const defaultProviders = { + container: {}, +} export const defaultEnvironments = { local: { providers: { - generic: { - type: "generic", - }, - containers: { - type: "kubernetes", + kubernetes: { context: "docker-for-desktop", }, }, }, } -export interface ProviderConfig { - type: string - name?: string -} +export interface ProviderConfig { } export interface EnvironmentConfig { configurationHandler?: string providers: { [key: string]: ProviderConfig } + variables: { [key: string]: Primitive } } export interface ProjectConfig { version: string name: string defaultEnvironment: string + global: EnvironmentConfig environments: { [key: string]: EnvironmentConfig } - variables: { [key: string]: Primitive } } -export const providerConfigBase = Joi.object().keys({ - type: Joi.string().required(), -}).unknown(true) +export const providerConfigBase = Joi.object().unknown(true) + +export const environmentSchema = Joi.object().keys({ + configurationHandler: joiIdentifier(), + providers: Joi.object().pattern(identifierRegex, providerConfigBase), + variables: joiVariables(), +}) + +const defaultGlobal = { + providers: defaultProviders, + variables: {}, +} export const projectSchema = Joi.object().keys({ version: Joi.string().default("0").only("0"), name: joiIdentifier().required(), defaultEnvironment: Joi.string().default("", ""), - environments: Joi.object().pattern(identifierRegex, Joi.object().keys({ - configurationHandler: joiIdentifier(), - providers: Joi.object().pattern(identifierRegex, providerConfigBase), - })).default(() => extend({}, defaultEnvironments), JSON.stringify(defaultEnvironments)), - variables: Joi.object().pattern(/[\w\d]+/i, joiPrimitive()).default(() => ({}), "{}"), + global: environmentSchema.default(() => defaultGlobal, JSON.stringify(defaultGlobal)), + environments: Joi.object() + .pattern(identifierRegex, environmentSchema) + .default(() => ({ ...defaultEnvironments }), JSON.stringify(defaultEnvironments)), }).required() diff --git a/src/types/service.ts b/src/types/service.ts index 5af9557b42..7c8eab1ddb 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -113,8 +113,8 @@ export class Service { } const dependencies = {} - for (const key in this.ctx.projectConfig.variables) { - envVars[key] = this.ctx.projectConfig.variables[key] + for (const key in this.ctx.config.variables) { + envVars[key] = this.ctx.config.variables[key] } for (const dep of await this.getDependencies()) { diff --git a/test/data/plugins/invalid-exported-name.ts b/test/data/plugins/invalid-exported-name.ts new file mode 100644 index 0000000000..2365c2ca17 --- /dev/null +++ b/test/data/plugins/invalid-exported-name.ts @@ -0,0 +1,5 @@ +export const name = "BAt1%!2f" + +export const gardenPlugin = () => ({ + actions: {}, +}) diff --git a/test/data/plugins/invalidModuleName.ts b/test/data/plugins/invalidModuleName.ts new file mode 100644 index 0000000000..e87d9b0f7c --- /dev/null +++ b/test/data/plugins/invalidModuleName.ts @@ -0,0 +1,3 @@ +export const gardenPlugin = () => ({ + actions: {}, +}) diff --git a/test/data/plugins/missing-factory.ts b/test/data/plugins/missing-factory.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/data/test-project-a/garden.yml b/test/data/test-project-a/garden.yml index acf0ba947f..bbeaab2dea 100644 --- a/test/data/test-project-a/garden.yml +++ b/test/data/test-project-a/garden.yml @@ -1,12 +1,11 @@ project: - name: build-test-project + name: test-project-a + global: + variables: + some: variable environments: local: providers: - test: - type: test-plugin - test-b: - type: test-plugin-b + test-plugin: {} + test-plugin-b: {} other: {} - variables: - some: variable diff --git a/test/data/test-project-b/garden.yml b/test/data/test-project-b/garden.yml index 768ec7f3d7..153a3a3832 100644 --- a/test/data/test-project-b/garden.yml +++ b/test/data/test-project-b/garden.yml @@ -3,5 +3,4 @@ project: environments: local: providers: - test: - type: test-plugin + test-plugin: {} diff --git a/test/data/test-project-container/garden.yml b/test/data/test-project-container/garden.yml index 4477525209..dd3d2e9f6e 100644 --- a/test/data/test-project-container/garden.yml +++ b/test/data/test-project-container/garden.yml @@ -3,7 +3,5 @@ project: environments: local: providers: - test: - type: test-plugin - container: - type: docker-module + test-plugin: {} + container: {} diff --git a/test/data/test-project-missing-provider/garden.yml b/test/data/test-project-missing-provider/garden.yml index f082083806..4b3c5930c5 100644 --- a/test/data/test-project-missing-provider/garden.yml +++ b/test/data/test-project-missing-provider/garden.yml @@ -3,7 +3,5 @@ project: environments: test: providers: - test: - type: test-plugin - test-b: - type: test-plugin-b + test-plugin: {} + test-plugin-b: {} diff --git a/test/data/test-project-templated/garden.yml b/test/data/test-project-templated/garden.yml index b503bb0e6b..e0647f955c 100644 --- a/test/data/test-project-templated/garden.yml +++ b/test/data/test-project-templated/garden.yml @@ -1,10 +1,10 @@ project: name: test-project-templated + global: + variables: + some: ${local.env.TEST_VARIABLE} + service-a-build-command: echo OK environments: local: providers: - test: - type: ${local.env.TEST_PROVIDER_TYPE} - variables: - some: ${local.env.TEST_VARIABLE} - service-a-build-command: echo OK + test-plugin: {} diff --git a/test/data/test-project-templated/module-a/garden.yml b/test/data/test-project-templated/module-a/garden.yml index 018124f799..97919223f4 100644 --- a/test/data/test-project-templated/module-a/garden.yml +++ b/test/data/test-project-templated/module-a/garden.yml @@ -1,6 +1,6 @@ module: name: module-a - type: test + type: generic services: service-a: command: echo ${local.env.TEST_VARIABLE} diff --git a/test/data/test-project-templated/module-b/garden.yml b/test/data/test-project-templated/module-b/garden.yml index bb982473f4..9c7672a3b3 100644 --- a/test/data/test-project-templated/module-b/garden.yml +++ b/test/data/test-project-templated/module-b/garden.yml @@ -1,6 +1,6 @@ module: name: module-b - type: test + type: generic services: service-b: command: echo ${dependencies.service-a.version} diff --git a/test/helpers.ts b/test/helpers.ts index 35af03d3a5..9d81412603 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,10 +1,14 @@ import * as td from "testdouble" import { resolve } from "path" -import { PluginContext } from "../src/plugin-context" import { DeleteConfigParams, - GetConfigParams, ParseModuleParams, Plugin, PluginActions, PluginFactory, + GetConfigParams, + ParseModuleParams, + GardenPlugin, + PluginActions, + PluginFactory, SetConfigParams, + ModuleActions, } from "../src/types/plugin" import { Garden } from "../src/garden" import { Module } from "../src/types/module" @@ -22,47 +26,53 @@ class TestModule extends Module { type = "test" } -export class TestPlugin implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic"] - - private _config: object - - constructor() { - this._config = {} - } - - async parseModule({ ctx, config }: ParseModuleParams) { - return new Module(ctx, config) - } - - async configureEnvironment() { } - async getServiceStatus() { return {} } - async deployService() { return {} } - - async setConfig({ key, value }: SetConfigParams) { - this._config[key.join(".")] = value - } - - async getConfig({ key }: GetConfigParams) { - return this._config[key.join(".")] || null - } - - async deleteConfig({ key }: DeleteConfigParams) { - const k = key.join(".") - if (this._config[k]) { - delete this._config[k] - return { found: true } - } else { - return { found: false } - } +export const testPlugin: PluginFactory = (): GardenPlugin => { + const _config = {} + + return { + actions: { + async configureEnvironment() { }, + + async setConfig({ key, value }: SetConfigParams) { + _config[key.join(".")] = value + }, + + async getConfig({ key }: GetConfigParams) { + return _config[key.join(".")] || null + }, + + async deleteConfig({ key }: DeleteConfigParams) { + const k = key.join(".") + if (_config[k]) { + delete _config[k] + return { found: true } + } else { + return { found: false } + } + }, + }, + moduleActions: { + generic: { + async parseModule({ ctx, moduleConfig }: ParseModuleParams) { + return new Module(ctx, moduleConfig) + }, + + async getServiceStatus() { return {} }, + async deployService() { return {} }, + }, + }, } } +testPlugin.pluginName = "test-plugin" -class TestPluginB extends TestPlugin { - name = "test-plugin-b" - supportedModuleTypes = ["test"] +export const testPluginB: PluginFactory = (params) => { + const plugin = testPlugin(params) + plugin.moduleActions = { + test: plugin.moduleActions!.generic, + } + return plugin } +testPluginB.pluginName = "test-plugin-b" export const makeTestModule = (ctx, name = "test") => { return new TestModule(ctx, { @@ -81,8 +91,8 @@ export const makeTestModule = (ctx, name = "test") => { export const makeTestGarden = async (projectRoot: string, extraPlugins: PluginFactory[] = []) => { const testPlugins: PluginFactory[] = [ - (_ctx) => new TestPlugin(), - (_ctx) => new TestPluginB(), + testPlugin, + testPluginB, ] const plugins: PluginFactory[] = testPlugins.concat(extraPlugins) @@ -103,12 +113,18 @@ export const makeTestContextA = async (extraPlugins: PluginFactory[] = []) => { return garden.pluginContext } -export function stubPluginAction> ( - garden: Garden, pluginName: string, type: T, handler?: PluginActions[T], +export function stubAction ( + garden: Garden, pluginName: string, type: T, handler?: PluginActions[T], ) { return td.replace(garden["actionHandlers"][type], pluginName, handler) } +export function stubModuleAction> ( + garden: Garden, moduleType: string, pluginName: string, type: T, handler?: ModuleActions[T], +) { + return td.replace(garden["moduleActionHandlers"][moduleType][type], pluginName, handler) +} + export async function expectError(fn: Function, typeOrCallback: string | ((err: any) => void)) { try { await fn() @@ -119,14 +135,16 @@ export async function expectError(fn: Function, typeOrCallback: string | ((err: if (!err.type) { throw new Error(`Expected GardenError with type ${typeOrCallback}, got: ${err}`) } - expect(err.type).to.equal(typeOrCallback) + if (err.type !== typeOrCallback) { + throw new Error(`Expected ${typeOrCallback} error, got: ${err}`) + } } return } if (typeof typeOrCallback === "string") { - throw new Error(`Expected ${typeOrCallback} error`) + throw new Error(`Expected ${typeOrCallback} error (got no error)`) } else { - throw new Error(`Expected error`) + throw new Error(`Expected error (got no error)`) } } diff --git a/test/src/build-dir.ts b/test/src/build-dir.ts index 1c2c508bf8..1879cb33a4 100644 --- a/test/src/build-dir.ts +++ b/test/src/build-dir.ts @@ -1,9 +1,8 @@ +const nodetree = require("nodetree") import { join } from "path" import { pathExists, readdir } from "fs-extra" import { expect } from "chai" -const nodetree = require("nodetree") import { values } from "lodash" -import { defaultPlugins } from "../../src/plugins" import { BuildTask } from "../../src/tasks/build" import { makeTestGarden, @@ -22,7 +21,7 @@ import { const projectRoot = join(__dirname, "..", "data", "test-project-build-products") const makeGarden = async () => { - return await makeTestGarden(projectRoot, defaultPlugins) + return await makeTestGarden(projectRoot) } describe("BuildDir", () => { diff --git a/test/src/commands/call.ts b/test/src/commands/call.ts index afad84fd0a..0dd9d99d2d 100644 --- a/test/src/commands/call.ts +++ b/test/src/commands/call.ts @@ -2,16 +2,12 @@ import { join } from "path" import { Garden } from "../../../src/garden" import { CallCommand } from "../../../src/commands/call" import { expect } from "chai" -import { GetServiceStatusParams, Plugin } from "../../../src/types/plugin" -import { Module } from "../../../src/types/module" +import { GetServiceStatusParams, PluginFactory } from "../../../src/types/plugin" import { ServiceStatus } from "../../../src/types/service" import nock = require("nock") -class TestProvider implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic", "container"] - - testStatuses: { [key: string]: ServiceStatus } = { +const testProvider: PluginFactory = () => { + const testStatuses: { [key: string]: ServiceStatus } = { "service-a": { state: "ready", endpoints: [{ @@ -26,11 +22,20 @@ class TestProvider implements Plugin { }, } - async getServiceStatus({ service }: GetServiceStatusParams): Promise { - return this.testStatuses[service.name] || {} + const getServiceStatus = async ({ service }: GetServiceStatusParams): Promise => { + return testStatuses[service.name] || {} + } + + return { + moduleActions: { + generic: { getServiceStatus }, + container: { getServiceStatus }, + }, } } +testProvider.pluginName = "test-plugin" + describe("commands.call", () => { const projectRootB = join(__dirname, "..", "..", "data", "test-project-b") @@ -44,7 +49,7 @@ describe("commands.call", () => { }) it("should find the endpoint for a service and call it with the specified path", async () => { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProvider()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new CallCommand() @@ -68,7 +73,7 @@ describe("commands.call", () => { }) it("should error if service isn't running", async () => { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProvider()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new CallCommand() @@ -88,7 +93,7 @@ describe("commands.call", () => { }) it("should error if service has no endpoints", async () => { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProvider()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new CallCommand() @@ -108,7 +113,7 @@ describe("commands.call", () => { }) it("should error if service has no matching endpoints", async () => { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProvider()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new CallCommand() diff --git a/test/src/commands/deploy.ts b/test/src/commands/deploy.ts index 4a91f1d356..c6145f74a2 100644 --- a/test/src/commands/deploy.ts +++ b/test/src/commands/deploy.ts @@ -2,40 +2,61 @@ import { join } from "path" import { Garden } from "../../../src/garden" import { DeployCommand } from "../../../src/commands/deploy" import { expect } from "chai" -import { DeployServiceParams, GetServiceStatusParams, Plugin } from "../../../src/types/plugin" -import { Module } from "../../../src/types/module" +import { + DeployServiceParams, + GetServiceStatusParams, + PluginFactory, +} from "../../../src/types/plugin" import { ServiceState, ServiceStatus } from "../../../src/types/service" -import { defaultPlugins } from "../../../src/plugins" -class TestProvider implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic", "container"] - - testStatuses: { [key: string]: ServiceStatus } = {} +const testProvider: PluginFactory = () => { + const testStatuses: { [key: string]: ServiceStatus } = { + "service-a": { + state: "ready", + endpoints: [{ + protocol: "http", + hostname: "service-a.test-project-b.local.app.garden", + paths: ["/path-a"], + url: "http://service-a.test-project-b.local.app.garden:32000", + }], + }, + "service-c": { + state: "ready", + }, + } - async getServiceStatus({ service }: GetServiceStatusParams): Promise { - return this.testStatuses[service.name] || {} + const getServiceStatus = async ({ service }: GetServiceStatusParams): Promise => { + return testStatuses[service.name] || {} } - async deployService({ service }: DeployServiceParams) { + const deployService = async ({ service }: DeployServiceParams) => { const newStatus = { version: "1", state: "ready", } - this.testStatuses[service.name] = newStatus + testStatuses[service.name] = newStatus return newStatus } + + return { + moduleActions: { + generic: { deployService, getServiceStatus }, + container: { deployService, getServiceStatus }, + }, + } } +testProvider.pluginName = "test-plugin" + describe("commands.deploy", () => { const projectRootB = join(__dirname, "..", "..", "data", "test-project-b") // TODO: Verify that services don't get redeployed when same version is already deployed. it("should build and deploy all modules in a project", async () => { - const garden = await Garden.factory(projectRootB, { plugins: defaultPlugins.concat([() => new TestProvider()]) }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new DeployCommand() @@ -60,7 +81,7 @@ describe("commands.deploy", () => { }) it("should optionally build and deploy single service and its dependencies", async () => { - const garden = await Garden.factory(projectRootB, { plugins: defaultPlugins.concat([() => new TestProvider()]) }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const ctx = garden.pluginContext const command = new DeployCommand() diff --git a/test/src/commands/environment/destroy.ts b/test/src/commands/environment/destroy.ts index 75e9ae3002..501898bd0b 100644 --- a/test/src/commands/environment/destroy.ts +++ b/test/src/commands/environment/destroy.ts @@ -2,38 +2,36 @@ import { expect } from "chai" import { join } from "path" import * as td from "testdouble" -import { defaultPlugins } from "../../../../src/plugins" import { - DestroyEnvironmentParams, EnvironmentStatus, - GetEnvironmentStatusParams, - Plugin, + PluginFactory, } from "../../../../src/types/plugin" import { EnvironmentDestroyCommand } from "../../../../src/commands/environment/destroy" import { Garden } from "../../../../src/garden" -import { Module } from "../../../../src/types/module" -class TestProvider implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic", "container"] +const testProvider: PluginFactory = () => { + const name = "test-plugin" - testEnvStatuses: { [key: string]: EnvironmentStatus } = {} + const testEnvStatuses: { [key: string]: EnvironmentStatus } = {} - async destroyEnvironment({ ctx, env }: DestroyEnvironmentParams): Promise { - this.testEnvStatuses[this.name] = {configured: false} + const destroyEnvironment = async () => { + testEnvStatuses[name] = { configured: false } } - async getEnvironmentStatus({ env }: GetEnvironmentStatusParams): Promise { - return this.testEnvStatuses[this.name] + const getEnvironmentStatus = async () => { + return testEnvStatuses[name] } -} -class TestProviderSlow extends TestProvider { - async destroyEnvironment({ ctx, env }: DestroyEnvironmentParams): Promise { - this.testEnvStatuses[this.name] = {configured: true} + return { + actions: { + destroyEnvironment, + getEnvironmentStatus, + }, } } +testProvider.pluginName = "test-plugin" + describe("EnvironmentDestroyCommand", () => { afterEach(() => { td.reset() @@ -43,9 +41,7 @@ describe("EnvironmentDestroyCommand", () => { const command = new EnvironmentDestroyCommand() it("should destroy environment", async () => { - const garden = await Garden.factory(projectRootB, { - plugins: defaultPlugins.concat([() => new TestProvider()]), - }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) const result = await command.action(garden.pluginContext) @@ -53,9 +49,7 @@ describe("EnvironmentDestroyCommand", () => { }) it("should wait until each provider is no longer configured", async () => { - const garden = await Garden.factory(projectRootB, { - plugins: defaultPlugins.concat([() => new TestProviderSlow()]), - }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) td.replace( command, diff --git a/test/src/commands/push.ts b/test/src/commands/push.ts index 4978784e9c..c3b1564f11 100644 --- a/test/src/commands/push.ts +++ b/test/src/commands/push.ts @@ -4,10 +4,7 @@ import { expect } from "chai" import * as td from "testdouble" import { Garden } from "../../../src/garden" import { - BuildModuleParams, BuildResult, - GetModuleBuildStatusParams, - Plugin, - PushModuleParams, + PluginFactory, } from "../../../src/types/plugin" import { Module } from "../../../src/types/module" import { PushCommand } from "../../../src/commands/push" @@ -18,38 +15,60 @@ import { const projectRootB = join(__dirname, "..", "..", "data", "test-project-b") -class TestProvider implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic"] +const getModuleBuildStatus = async () => { + return { ready: true } +} - async getModuleBuildStatus({ module }: GetModuleBuildStatusParams) { - return { ready: true } - } +const buildModule = async () => { + return { fresh: true } +} - async buildModule({ module }: BuildModuleParams): Promise { - return { fresh: true } - } +const pushModule = async () => { + return { pushed: true } +} - async pushModule({ module }: PushModuleParams) { - return { pushed: true } +const testProvider: PluginFactory = () => { + return { + moduleActions: { + generic: { + getModuleBuildStatus, + buildModule, + pushModule, + }, + }, } } -class TestProviderB implements Plugin { - name = "test-plugin" - supportedModuleTypes = ["generic"] +testProvider.pluginName = "test-plugin" - async getModuleBuildStatus({ module }: GetModuleBuildStatusParams) { - return { ready: true } +const testProviderB: PluginFactory = () => { + return { + moduleActions: { + generic: { + getModuleBuildStatus, + buildModule, + }, + }, } +} + +testProviderB.pluginName = "test-plugin-b" - async buildModule({ module }: BuildModuleParams): Promise { - return { fresh: true } +const testProviderNoPush: PluginFactory = () => { + return { + moduleActions: { + generic: { + getModuleBuildStatus, + buildModule, + }, + }, } } +testProviderNoPush.pluginName = "test-plugin" + async function getTestContext() { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProvider()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProvider] }) return garden.pluginContext } @@ -159,7 +178,7 @@ describe("PushCommand", () => { }) it("should fail gracefully if module does not have a provider for push", async () => { - const garden = await Garden.factory(projectRootB, { plugins: [() => new TestProviderB()] }) + const garden = await Garden.factory(projectRootB, { plugins: [testProviderNoPush, testProviderB] }) const ctx = garden.pluginContext const command = new PushCommand() diff --git a/test/src/commands/validate.ts b/test/src/commands/validate.ts index 5f6c913e71..0e0ccd79e8 100644 --- a/test/src/commands/validate.ts +++ b/test/src/commands/validate.ts @@ -1,13 +1,12 @@ import { join } from "path" import { Garden } from "../../../src/garden" import { ValidateCommand } from "../../../src/commands/validate" -import { defaultPlugins } from "../../../src/plugins" import { expectError } from "../../helpers" describe("commands.validate", () => { it("should successfully validate the hello-world project", async () => { const root = join(__dirname, "..", "..", "..", "examples", "hello-world") - const garden = await Garden.factory(root, { plugins: defaultPlugins }) + const garden = await Garden.factory(root) const command = new ValidateCommand() await command.action(garden.pluginContext) @@ -21,7 +20,7 @@ describe("commands.validate", () => { it("should fail validating the bad-module project", async () => { const root = join(__dirname, "data", "validate", "bad-module") - const garden = await Garden.factory(root, { plugins: defaultPlugins }) + const garden = await Garden.factory(root) const command = new ValidateCommand() await expectError(async () => await command.action(garden.pluginContext), "configuration") diff --git a/test/src/garden.ts b/test/src/garden.ts index a74d1867c5..2f7a82e881 100644 --- a/test/src/garden.ts +++ b/test/src/garden.ts @@ -2,156 +2,127 @@ import { join } from "path" import { Garden } from "../../src/garden" import { expect } from "chai" import { + dataDir, expectError, makeTestGarden, makeTestGardenA, makeTestModule, projectRootA, - TestPlugin, + testPlugin, + testPluginB, } from "../helpers" describe("Garden", () => { - it("should throw when initializing with missing plugins", async () => { - await expectError(async () => await Garden.factory(projectRootA), "configuration") - }) + describe("factory", () => { + it("should throw when initializing with missing plugins", async () => { + await expectError(async () => await Garden.factory(projectRootA), "configuration") + }) - it("should initialize add the action handlers for a plugin", async () => { - const ctx = await makeTestGardenA() + it("should initialize and add the action handlers for a plugin", async () => { + const ctx = await makeTestGardenA() - expect(ctx.plugins["test-plugin"]).to.be.ok - expect(ctx.actionHandlers.configureEnvironment["test-plugin"]).to.be.ok - expect(ctx.plugins["test-plugin-b"]).to.be.ok - expect(ctx.actionHandlers.configureEnvironment["test-plugin-b"]).to.be.ok - }) + expect(ctx.actionHandlers.configureEnvironment["test-plugin"]).to.be.ok + expect(ctx.actionHandlers.configureEnvironment["test-plugin-b"]).to.be.ok + }) - it("should throw if registering same plugin twice", async () => { - try { - await Garden.factory(projectRootA, { - plugins: [ - (_ctx) => new TestPlugin(), - (_ctx) => new TestPlugin(), - ], - }) - } catch (err) { - expect(err.type).to.equal("configuration") - return - } + it("should throw if registering same plugin twice", async () => { + try { + await Garden.factory(projectRootA, { + plugins: ["test-plugin", "test-plugin"], + }) + } catch (err) { + expect(err.type).to.equal("configuration") + return + } - throw new Error("Expected error") - }) + throw new Error("Expected error") + }) - it("should parse the config from the project root", async () => { - const ctx = await makeTestGardenA() - const config = ctx.projectConfig + it("should parse and resolve the config from the project root", async () => { + const ctx = await makeTestGardenA() - expect(config).to.eql({ - defaultEnvironment: "local", - environments: { - local: { - providers: { - test: { - type: "test-plugin", - }, - "test-b": { - type: "test-plugin-b", - }, - }, + expect(ctx.projectName).to.equal("test-project-a") + expect(ctx.config).to.eql({ + providers: { + "test-plugin": {}, + "test-plugin-b": {}, }, - other: {}, - }, - name: "build-test-project", - version: "0", - variables: { - some: "variable", - }, + variables: { + some: "variable", + }, + }) }) - }) - it("should resolve templated env variables in project config", async () => { - process.env.TEST_PROVIDER_TYPE = "test-plugin" - process.env.TEST_VARIABLE = "banana" + it("should resolve templated env variables in project config", async () => { + process.env.TEST_PROVIDER_TYPE = "test-plugin" + process.env.TEST_VARIABLE = "banana" - const projectRoot = join(__dirname, "..", "data", "test-project-templated") + const projectRoot = join(__dirname, "..", "data", "test-project-templated") - const ctx = await makeTestGarden(projectRoot) - const config = ctx.projectConfig + const ctx = await makeTestGarden(projectRoot) - delete process.env.TEST_PROVIDER_TYPE - delete process.env.TEST_VARIABLE + delete process.env.TEST_PROVIDER_TYPE + delete process.env.TEST_VARIABLE - expect(config).to.eql({ - defaultEnvironment: "local", - environments: { - local: { - providers: { - test: { - type: "test-plugin", - }, - }, + expect(ctx.config).to.eql({ + providers: { + "test-plugin": {}, }, - }, - name: "test-project-templated", - version: "0", - variables: { - some: "banana", - "service-a-build-command": "echo OK", - }, - }) - }) - - describe("setEnvironment", () => { - it("should set the active environment for the context", async () => { - const ctx = await makeTestGardenA() - - const { name, namespace } = ctx.setEnvironment("local") - expect(name).to.equal("local") - expect(namespace).to.equal("default") - - const env = ctx.getEnvironment() - expect(env.name).to.equal("local") - expect(env.namespace).to.equal("default") + variables: { + some: "banana", + "service-a-build-command": "echo OK", + }, + }) }) it("should optionally set a namespace with the dot separator", async () => { - const ctx = await makeTestGardenA() + const garden = await Garden.factory( + projectRootA, { env: "local.mynamespace", plugins: [testPlugin, testPluginB] }, + ) - const { name, namespace } = ctx.setEnvironment("local.mynamespace") + const { name, namespace } = garden.getEnvironment() expect(name).to.equal("local") expect(namespace).to.equal("mynamespace") }) it("should split environment and namespace on the first dot", async () => { - const ctx = await makeTestGardenA() + const garden = await Garden.factory( + projectRootA, { env: "local.mynamespace.2", plugins: [testPlugin, testPluginB] }, + ) - const { name, namespace } = ctx.setEnvironment("local.mynamespace.2") + const { name, namespace } = garden.getEnvironment() expect(name).to.equal("local") expect(namespace).to.equal("mynamespace.2") }) it("should throw if the specified environment isn't configured", async () => { - const ctx = await makeTestGardenA() + await expectError(async () => Garden.factory(projectRootA, { env: "bla" }), "parameter") + }) - try { - ctx.setEnvironment("bla") - } catch (err) { - expect(err.type).to.equal("parameter") - return - } + it("should throw if namespace starts with 'garden-'", async () => { + await expectError(async () => Garden.factory(projectRootA, { env: "garden-bla" }), "parameter") + }) - throw new Error("Expected error") + it("should throw if no provider is configured for the environment", async () => { + await expectError(async () => Garden.factory(projectRootA, { env: "other" }), "configuration") }) - it("should throw if namespace starts with 'garden-'", async () => { - const ctx = await makeTestGardenA() + it("should throw if plugin module exports invalid name", async () => { + const pluginPath = join(dataDir, "plugins", "invalid-exported-name.ts") + const projectRoot = join(dataDir, "test-project-empty") + await expectError(async () => Garden.factory(projectRoot, { plugins: [pluginPath] }), "plugin") + }) - try { - ctx.setEnvironment("local.garden-bla") - } catch (err) { - expect(err.type).to.equal("parameter") - return - } + it("should throw if plugin module name is not a valid identifier", async () => { + const pluginPath = join(dataDir, "plugins", "invalidModuleName.ts") + const projectRoot = join(dataDir, "test-project-empty") + await expectError(async () => Garden.factory(projectRoot, { plugins: [pluginPath] }), "plugin") + }) - throw new Error("Expected error") + it("should throw if plugin module doesn't contain factory function", async () => { + const pluginPath = join(dataDir, "plugins", "missing-factory.ts") + const projectRoot = join(dataDir, "test-project-empty") + await expectError(async () => Garden.factory(projectRoot, { plugins: [pluginPath] }), "plugin") }) }) @@ -159,18 +130,6 @@ describe("Garden", () => { it("should get the active environment for the context", async () => { const ctx = await makeTestGardenA() - const { name, namespace } = ctx.setEnvironment("other") - expect(name).to.equal("other") - expect(namespace).to.equal("default") - - const env = ctx.getEnvironment() - expect(env.name).to.equal("other") - expect(env.namespace).to.equal("default") - }) - - it("should return default environment if none has been explicitly set", async () => { - const ctx = await makeTestGardenA() - const { name, namespace } = ctx.getEnvironment() expect(name).to.equal("local") expect(namespace).to.equal("default") @@ -392,8 +351,11 @@ describe("Garden", () => { name: "local", config: { providers: { - test: { type: "test-plugin" }, - "test-b": { type: "test-plugin-b" }, + "test-plugin": {}, + "test-plugin-b": {}, + }, + variables: { + some: "variable", }, }, }) @@ -412,8 +374,11 @@ describe("Garden", () => { name: "local", config: { providers: { - test: { type: "test-plugin" }, - "test-b": { type: "test-plugin-b" }, + "test-plugin": {}, + "test-plugin-b": {}, + }, + variables: { + some: "variable", }, }, }) @@ -425,23 +390,23 @@ describe("Garden", () => { it("should return all handlers for a type", async () => { const ctx = await makeTestGardenA() - const handlers = ctx.getActionHandlers("parseModule") + const handlers = ctx.getActionHandlers("configureEnvironment") expect(Object.keys(handlers)).to.eql([ - "generic", "test-plugin", "test-plugin-b", ]) }) + }) - it("should optionally limit to handlers for specific module type", async () => { + describe("getModuleActionHandlers", () => { + it("should return all handlers for a type", async () => { const ctx = await makeTestGardenA() - const handlers = ctx.getActionHandlers("parseModule", "generic") + const handlers = ctx.getModuleActionHandlers("buildModule", "generic") expect(Object.keys(handlers)).to.eql([ "generic", - "test-plugin", ]) }) }) @@ -450,79 +415,41 @@ describe("Garden", () => { it("should return last configured handler for specified action type", async () => { const ctx = await makeTestGardenA() - const handler = ctx.getActionHandler("parseModule") + const handler = ctx.getActionHandler("configureEnvironment") - expect(handler["actionType"]).to.equal("parseModule") + expect(handler["actionType"]).to.equal("configureEnvironment") expect(handler["pluginName"]).to.equal("test-plugin-b") }) it("should optionally filter to only handlers for the specified module type", async () => { const ctx = await makeTestGardenA() - const handler = ctx.getActionHandler("parseModule", "test") + const handler = ctx.getActionHandler("configureEnvironment") - expect(handler["actionType"]).to.equal("parseModule") + expect(handler["actionType"]).to.equal("configureEnvironment") expect(handler["pluginName"]).to.equal("test-plugin-b") }) it("should throw if no handler is available", async () => { const ctx = await makeTestGardenA() - - try { - ctx.getActionHandler("deployService", "container") - } catch (err) { - expect(err.type).to.equal("parameter") - return - } - - throw new Error("Expected error") - }) - }) - - describe("getEnvActionHandlers", () => { - it("should return all handlers for a type that are configured for the set environment", async () => { - const ctx = await makeTestGardenA() - ctx.setEnvironment("local") - - const handlers = ctx.getEnvActionHandlers("configureEnvironment") - expect(Object.keys(handlers)).to.eql(["test-plugin", "test-plugin-b"]) - }) - - it("should optionally limit to handlers that support a specific module type", async () => { - const ctx = await makeTestGardenA() - ctx.setEnvironment("local") - - const handlers = ctx.getEnvActionHandlers("configureEnvironment", "test") - expect(Object.keys(handlers)).to.eql(["test-plugin-b"]) - }) - - it("should throw if environment has not been set", async () => { - const ctx = await makeTestGardenA() - - try { - ctx.getEnvActionHandlers("configureEnvironment", "container") - } catch (err) { - expect(err.type).to.equal("plugin") - } + await expectError(() => ctx.getActionHandler("destroyEnvironment"), "parameter") }) }) - describe("getEnvActionHandler", () => { - it("should return last configured handler for specified action type", async () => { + describe("getModuleActionHandler", () => { + it("should return last configured handler for specified module action type", async () => { const ctx = await makeTestGardenA() - ctx.setEnvironment("local") - const handler = ctx.getEnvActionHandler("configureEnvironment") + const handler = ctx.getModuleActionHandler("deployService", "test") - expect(handler["actionType"]).to.equal("configureEnvironment") + expect(handler["actionType"]).to.equal("deployService") expect(handler["pluginName"]).to.equal("test-plugin-b") }) - it("should optionally filter to only handlers for the specified module type", async () => { + it("should filter to only handlers for the specified module type", async () => { const ctx = await makeTestGardenA() - ctx.setEnvironment("local") - const handler = ctx.getEnvActionHandler("deployService", "test") + const handler = ctx.getModuleActionHandler("deployService", "test") expect(handler["actionType"]).to.equal("deployService") expect(handler["pluginName"]).to.equal("test-plugin-b") @@ -530,16 +457,7 @@ describe("Garden", () => { it("should throw if no handler is available", async () => { const ctx = await makeTestGardenA() - ctx.setEnvironment("local") - - try { - ctx.getEnvActionHandler("deployService", "container") - } catch (err) { - expect(err.type).to.equal("parameter") - return - } - - throw new Error("Expected error") + await expectError(() => ctx.getModuleActionHandler("execInService", "container"), "parameter") }) }) }) diff --git a/test/plugin-context.ts b/test/src/plugin-context.ts similarity index 99% rename from test/plugin-context.ts rename to test/src/plugin-context.ts index fe31bc5081..35429d1337 100644 --- a/test/plugin-context.ts +++ b/test/src/plugin-context.ts @@ -2,7 +2,7 @@ import { expect } from "chai" import { expectError, makeTestContextA, -} from "./helpers" +} from "../helpers" describe("PluginContext", () => { describe("setConfig", () => { diff --git a/test/src/plugins/container.ts b/test/src/plugins/container.ts index 89c5ca1e44..ac032d80d4 100644 --- a/test/src/plugins/container.ts +++ b/test/src/plugins/container.ts @@ -5,7 +5,7 @@ import { Garden } from "../../../src/garden" import { PluginContext } from "../../../src/plugin-context" import { ContainerModuleConfig, - DockerModuleHandler, + gardenPlugin, } from "../../../src/plugins/container" import { dataDir, @@ -17,13 +17,14 @@ describe("container", () => { const projectRoot = resolve(dataDir, "test-project-container") const modulePath = resolve(dataDir, "test-project-container", "module-a") - const handler = new DockerModuleHandler() + const handler = gardenPlugin() + const parseModule = handler.moduleActions!.container!.parseModule! let garden: Garden let ctx: PluginContext beforeEach(async () => { - garden = await makeTestGarden(projectRoot, [() => new DockerModuleHandler()]) + garden = await makeTestGarden(projectRoot, [gardenPlugin]) ctx = garden.pluginContext td.replace(garden.buildDir, "syncDependencyProducts", () => null) @@ -31,8 +32,8 @@ describe("container", () => { afterEach(() => td.reset()) - async function getTestModule(config: ContainerModuleConfig) { - return handler.parseModule({ ctx, config }) + async function getTestModule(moduleConfig: ContainerModuleConfig) { + return parseModule!({ ctx, config: {}, moduleConfig }) } describe("ContainerModule", () => { @@ -138,7 +139,7 @@ describe("container", () => { describe("DockerModuleHandler", () => { describe("parseModule", () => { it("should validate a container module", async () => { - const config: ContainerModuleConfig = { + const moduleConfig: ContainerModuleConfig = { allowPush: false, build: { command: "echo OK", @@ -183,11 +184,11 @@ describe("container", () => { variables: {}, } - await handler.parseModule({ ctx, config }) + await parseModule({ ctx, config: {}, moduleConfig }) }) it("should fail with invalid port in endpoint spec", async () => { - const config: ContainerModuleConfig = { + const moduleConfig: ContainerModuleConfig = { allowPush: false, build: { command: "echo OK", @@ -221,11 +222,14 @@ describe("container", () => { variables: {}, } - await expectError(() => handler.parseModule({ ctx, config }), "configuration") + await expectError( + () => parseModule({ ctx, config: {}, moduleConfig }), + "configuration", + ) }) it("should fail with invalid port in httpGet healthcheck spec", async () => { - const config: ContainerModuleConfig = { + const moduleConfig: ContainerModuleConfig = { allowPush: false, build: { command: "echo OK", @@ -260,11 +264,14 @@ describe("container", () => { variables: {}, } - await expectError(() => handler.parseModule({ ctx, config }), "configuration") + await expectError( + () => parseModule({ ctx, config: {}, moduleConfig }), + "configuration", + ) }) it("should fail with invalid port in tcpPort healthcheck spec", async () => { - const config: ContainerModuleConfig = { + const moduleConfig: ContainerModuleConfig = { allowPush: false, build: { command: "echo OK", @@ -296,7 +303,10 @@ describe("container", () => { variables: {}, } - await expectError(() => handler.parseModule({ ctx, config }), "configuration") + await expectError( + () => parseModule({ ctx, config: {}, moduleConfig }), + "configuration", + ) }) }) diff --git a/test/src/task-graph.ts b/test/src/task-graph.ts index ca498ec9e3..e7eaf95e49 100644 --- a/test/src/task-graph.ts +++ b/test/src/task-graph.ts @@ -2,7 +2,8 @@ import { join } from "path" import { expect } from "chai" import { Task, TaskGraph, TaskResults } from "../../src/task-graph" import { Garden } from "../../src/garden" -import { defaultPlugins } from "../../src/plugins" + +const projectRoot = join(__dirname, "..", "data", "test-project-empty") describe("task-graph", () => { class TestTask extends Task { @@ -29,12 +30,14 @@ describe("task-graph", () => { } } - describe("TaskGraph", async () => { - const projectRoot = join(__dirname, "..", "data", "test-project-empty") - const garden = await Garden.factory(projectRoot, { plugins: defaultPlugins }) - const ctx = garden.pluginContext + describe("TaskGraph", () => { + async function getContext() { + const garden = await Garden.factory(projectRoot) + return garden.pluginContext + } it("should successfully process a single task without dependencies", async () => { + const ctx = await getContext() const graph = new TaskGraph(ctx) const task = new TestTask("a") @@ -47,6 +50,7 @@ describe("task-graph", () => { }) it("should process multiple tasks in dependency order", async () => { + const ctx = await getContext() const graph = new TaskGraph(ctx) const callbackResults = {} diff --git a/test/src/tasks/deploy.ts b/test/src/tasks/deploy.ts index ac6791bc66..9ef4d22da7 100644 --- a/test/src/tasks/deploy.ts +++ b/test/src/tasks/deploy.ts @@ -4,7 +4,7 @@ import * as td from "testdouble" import { dataDir, makeTestGarden, - stubPluginAction, + stubModuleAction, } from "../../helpers" import { DeployTask } from "../../../src/tasks/deploy" @@ -15,7 +15,6 @@ describe("DeployTask", () => { it("should fully resolve templated strings on the service before deploying", async () => { process.env.TEST_VARIABLE = "banana" - process.env.TEST_PROVIDER_TYPE = "test-plugin-b" const garden = await makeTestGarden(resolve(dataDir, "test-project-templated")) const ctx = garden.pluginContext @@ -27,13 +26,13 @@ describe("DeployTask", () => { const task = new DeployTask(ctx, serviceB, false, false) let actionParams: any = {} - stubPluginAction( - garden, "test-plugin-b", "getServiceStatus", + stubModuleAction( + garden, "generic", "test-plugin", "getServiceStatus", async () => ({}), ) - stubPluginAction( - garden, "test-plugin-b", "deployService", + stubModuleAction( + garden, "generic", "test-plugin", "deployService", async (params) => { actionParams = params }, ) diff --git a/test/src/types/config.ts b/test/src/types/config.ts index 4c773dc675..667fab10bc 100644 --- a/test/src/types/config.ts +++ b/test/src/types/config.ts @@ -12,23 +12,22 @@ describe("loadConfig", () => { const parsed = await loadConfig(projectPathA, projectPathA) expect(parsed.project).to.eql({ - name: "build-test-project", + version: "0", + name: "test-project-a", + defaultEnvironment: "local", + global: { + variables: { some: "variable" }, + }, environments: { local: { providers: { - test: { - type: "test-plugin", - }, - "test-b": { - type: "test-plugin-b", - }, + "test-plugin": {}, + "test-plugin-b": {}, }, + variables: {}, }, - other: {}, + other: { variables: {} }, }, - variables: { some: "variable" }, - version: "0", - defaultEnvironment: "local", }) }) diff --git a/test/src/types/module.ts b/test/src/types/module.ts index d6a87497af..678fef7e4b 100644 --- a/test/src/types/module.ts +++ b/test/src/types/module.ts @@ -20,7 +20,7 @@ describe("Module", () => { it("should resolve template strings", async () => { process.env.TEST_VARIABLE = "banana" - process.env.TEST_PROVIDER_TYPE = "test-plugin" + // process.env.TEST_PROVIDER_TYPE = "test-plugin" const ctx = await makeTestContext(resolve(dataDir, "test-project-templated")) const modulePath = resolve(ctx.projectRoot, "module-a") @@ -37,7 +37,7 @@ describe("Module", () => { services: { "service-a": { command: "echo \${local.env.TEST_VARIABLE}", dependencies: [] } }, test: { unit: { command: ["echo", "OK"], dependencies: [], variables: {} } }, - type: "test", + type: "generic", variables: {}, }) })