From 242d0aadaf073ba22d3786b653d9dd4cfccecc1c Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Tue, 22 May 2018 10:56:08 +0200 Subject: [PATCH] refactor: major hardening of internal plugin APIs This is a hefty refactor that makes internal plugin APIs more consistent and better isolated. Plugins can no longer directly manipulate Module or Service objects except within their own scope of execution. Plugin handler outputs are also validated now, and type-safety has been improved across the board. One of the more important implications is that plugins can no longer add or modify methods on the Module or Service class globally, and several modifications were needed to accommodate that change. --- .../services/hello-container/garden.yml | 2 +- .../services/hello-function/garden.yml | 4 +- src/build-dir.ts | 19 +- src/commands/call.ts | 2 +- src/commands/config/delete.ts | 5 +- src/commands/config/get.ts | 12 +- src/commands/config/set.ts | 3 +- src/commands/deploy.ts | 2 +- src/commands/dev.ts | 2 +- src/commands/environment/configure.ts | 4 +- src/commands/environment/destroy.ts | 4 +- src/commands/login.ts | 4 +- src/commands/logout.ts | 4 +- src/commands/logs.ts | 5 +- src/commands/run/module.ts | 14 +- src/commands/run/service.ts | 12 +- src/commands/run/test.ts | 15 +- src/commands/scan.ts | 2 +- src/commands/test.ts | 2 +- src/garden.ts | 83 ++-- src/plugin-context.ts | 397 ++++++++++-------- src/plugins/container.ts | 279 ++++++------ src/plugins/generic.ts | 158 ++++--- src/plugins/google/common.ts | 29 +- src/plugins/google/google-app-engine.ts | 24 +- src/plugins/google/google-cloud-functions.ts | 84 ++-- src/plugins/kubernetes/actions.ts | 46 +- src/plugins/kubernetes/deployment.ts | 36 +- src/plugins/kubernetes/ingress.ts | 6 +- src/plugins/kubernetes/local.ts | 10 +- src/plugins/kubernetes/service.ts | 2 +- src/plugins/kubernetes/specs-module.ts | 66 +-- src/plugins/kubernetes/status.ts | 6 +- src/plugins/local/local-docker-swarm.ts | 30 +- .../local/local-google-cloud-functions.ts | 197 ++++----- src/plugins/npm-package.ts | 2 +- src/tasks/build.ts | 17 +- src/tasks/deploy.ts | 16 +- src/tasks/push.ts | 10 +- src/tasks/test.ts | 36 +- src/template-string.ts | 21 +- src/types/common.ts | 8 +- src/types/config.ts | 47 ++- src/types/module.ts | 191 +++++---- src/types/plugin.ts | 338 --------------- src/types/plugin/index.ts | 186 ++++++++ src/types/plugin/outputs.ts | 222 ++++++++++ src/types/plugin/params.ts | 164 ++++++++ src/types/service.ts | 77 +++- src/types/test.ts | 39 ++ src/util/detectCycles.ts | 22 +- src/util/index.ts | 1 + .../system/kubernetes-dashboard/garden.yml | 272 ++++++------ static/local-gcf-container/child/Dockerfile | 2 + static/local-gcf-container/start.sh | 4 +- test/data/test-project-a/module-a/garden.yml | 4 +- test/data/test-project-a/module-b/garden.yml | 4 +- test/data/test-project-a/module-c/garden.yml | 4 +- test/data/test-project-b/garden.yml | 2 + test/data/test-project-b/module-a/garden.yml | 6 +- test/data/test-project-b/module-b/garden.yml | 6 +- test/data/test-project-b/module-c/garden.yml | 6 +- .../test-project-build-products/garden.yml | 3 + .../module-a/garden.yml | 2 +- .../module-b/garden.yml | 2 +- .../module-c/garden.yml | 2 +- .../module-d/garden.yml | 2 +- .../module-e/garden.yml | 2 +- .../module-a/garden.yml | 6 +- .../module-b/garden.yml | 6 +- test/helpers.ts | 117 ++++-- test/src/commands/call.ts | 11 +- test/src/commands/config/delete.ts | 11 +- test/src/commands/config/get.ts | 2 +- test/src/commands/config/set.ts | 2 +- test/src/commands/deploy.ts | 10 +- test/src/commands/environment/destroy.ts | 3 +- test/src/commands/push.ts | 12 +- test/src/commands/run/module.ts | 2 +- test/src/commands/run/service.ts | 2 +- test/src/garden.ts | 9 - test/src/logger.ts | 2 +- test/src/plugin-context.ts | 34 +- test/src/plugins/container.ts | 382 ++++++++++------- test/src/tasks/deploy.ts | 28 +- test/src/types/config.ts | 18 +- test/src/types/module.ts | 89 +++- test/src/types/service.ts | 32 +- 88 files changed, 2472 insertions(+), 1596 deletions(-) delete mode 100644 src/types/plugin.ts create mode 100644 src/types/plugin/index.ts create mode 100644 src/types/plugin/outputs.ts create mode 100644 src/types/plugin/params.ts create mode 100644 src/types/test.ts diff --git a/examples/hello-world/services/hello-container/garden.yml b/examples/hello-world/services/hello-container/garden.yml index 4995b649ba..d8d8ded610 100644 --- a/examples/hello-world/services/hello-container/garden.yml +++ b/examples/hello-world/services/hello-container/garden.yml @@ -19,7 +19,7 @@ module: build: dependencies: - hello-npm-package - test: + tests: - name: unit command: [npm, test] - name: integ diff --git a/examples/hello-world/services/hello-function/garden.yml b/examples/hello-world/services/hello-function/garden.yml index 00a094f0d2..30d25d774b 100644 --- a/examples/hello-world/services/hello-function/garden.yml +++ b/examples/hello-world/services/hello-function/garden.yml @@ -2,10 +2,10 @@ module: description: Hello world serverless function name: hello-function type: google-cloud-function - services: + functions: - name: hello-function entrypoint: helloFunction - test: + tests: - name: unit command: [npm, test] build: diff --git a/src/build-dir.ts b/src/build-dir.ts index 54249ef38c..326087d8a0 100644 --- a/src/build-dir.ts +++ b/src/build-dir.ts @@ -21,12 +21,12 @@ import { import * as Rsync from "rsync" import { GARDEN_DIR_NAME } from "./constants" import { ConfigurationError } from "./exceptions" -import { PluginContext } from "./plugin-context" import { execRsyncCmd } from "./util" import { BuildCopySpec, Module, } from "./types/module" +import { zip } from "lodash" // Lazily construct a directory of modules inside which all build steps are performed. @@ -44,27 +44,28 @@ export class BuildDir { ensureDirSync(this.buildDirPath) } - async syncFromSrc(module: T) { + async syncFromSrc(module: Module) { await this.sync( resolve(this.projectRoot, module.path, "*"), await this.buildPath(module), ) } - async syncDependencyProducts(ctx: PluginContext, module: T) { + async syncDependencyProducts(module: Module) { await this.syncFromSrc(module) const buildPath = await this.buildPath(module) + const buildDependencies = await module.getBuildDependencies() + const dependencyConfigs = module.config.build.dependencies || [] - await bluebirdMap(module.config.build.dependencies || [], async (depConfig) => { - if (!depConfig.copy) { - return [] + await bluebirdMap(zip(buildDependencies, dependencyConfigs), async ([sourceModule, depConfig]) => { + if (!sourceModule || !depConfig || !depConfig.copy) { + return } - const sourceModule = await ctx.getModule(depConfig.name) const sourceBuildPath = await this.buildPath(sourceModule) // Sync to the module's top-level dir by default. - return bluebirdMap(depConfig.copy, (copy: BuildCopySpec) => { + await bluebirdMap(depConfig.copy, (copy: BuildCopySpec) => { if (isAbsolute(copy.source)) { throw new ConfigurationError(`Source path in build dependency copy spec must be a relative path`, { copySpec: copy, @@ -88,7 +89,7 @@ export class BuildDir { await emptyDir(this.buildDirPath) } - async buildPath(module: T): Promise { + async buildPath(module: Module): Promise { const path = resolve(this.buildDirPath, module.name) await ensureDir(path) return path diff --git a/src/commands/call.ts b/src/commands/call.ts index 260f50dea1..fab79d8a10 100644 --- a/src/commands/call.ts +++ b/src/commands/call.ts @@ -38,7 +38,7 @@ export class CallCommand extends Command { // TODO: better error when service doesn't exist const service = await ctx.getService(serviceName) - const status = await ctx.getServiceStatus(service) + const status = await ctx.getServiceStatus({ serviceName }) if (status.state !== "ready") { throw new RuntimeError(`Service ${service.name} is not running`, { diff --git a/src/commands/config/delete.ts b/src/commands/config/delete.ts index 8817558eb4..6d2444662e 100644 --- a/src/commands/config/delete.ts +++ b/src/commands/config/delete.ts @@ -29,12 +29,13 @@ export class ConfigDeleteCommand extends Command { arguments = configDeleteArgs async action(ctx: PluginContext, args: DeleteArgs) { - const res = await ctx.deleteConfig(args.key.split(".")) + const key = args.key.split(".") + const res = await ctx.deleteConfig({ key }) if (res.found) { ctx.log.info(`Deleted config key ${args.key}`) } else { - throw new NotFoundError(`Could not find config key ${args.key}`, { key: args.key }) + throw new NotFoundError(`Could not find config key ${args.key}`, { key }) } return { ok: true } diff --git a/src/commands/config/get.ts b/src/commands/config/get.ts index 3add628325..65e1a840ac 100644 --- a/src/commands/config/get.ts +++ b/src/commands/config/get.ts @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { NotFoundError } from "../../exceptions" import { PluginContext } from "../../plugin-context" import { Command, ParameterValues, StringParameter } from "../base" @@ -27,10 +28,15 @@ export class ConfigGetCommand extends Command { arguments = configGetArgs async action(ctx: PluginContext, args: GetArgs) { - const res = await ctx.getConfig(args.key.split(".")) + const key = args.key.split(".") + const { value } = await ctx.getConfig({ key }) - ctx.log.info(res) + if (value === null || value === undefined) { + throw new NotFoundError(`Could not find config key ${args.key}`, { key }) + } - return { [args.key]: res } + ctx.log.info(value) + + return { [args.key]: value } } } diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index f9f13dd493..33c3813065 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -31,7 +31,8 @@ export class ConfigSetCommand extends Command { arguments = configSetArgs async action(ctx: PluginContext, args: SetArgs) { - await ctx.setConfig(args.key.split("."), args.value) + const key = args.key.split(".") + await ctx.setConfig({ key, value: args.value }) ctx.log.info(`Set config key ${args.key}`) return { ok: true } } diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index f07dd56997..f31694bcc0 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -46,7 +46,7 @@ export class DeployCommand extends Command ctx.log.header({ emoji: "rocket", command: "Deploy" }) // TODO: make this a task - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const watch = opts.watch const force = opts.force diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 5f101ee850..b5788adfad 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -35,7 +35,7 @@ export class DevCommand extends Command { ctx.log.info(chalk.gray.italic(`\nGood ${getGreetingTime()}! Let's get your environment wired up...\n`)) - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const modules = await ctx.getModules() diff --git a/src/commands/environment/configure.ts b/src/commands/environment/configure.ts index 5a389fb2f9..cfd408ae8a 100644 --- a/src/commands/environment/configure.ts +++ b/src/commands/environment/configure.ts @@ -7,7 +7,7 @@ */ import { PluginContext } from "../../plugin-context" -import { EnvironmentStatusMap } from "../../types/plugin" +import { EnvironmentStatusMap } from "../../types/plugin/outputs" import { Command } from "../base" export class EnvironmentConfigureCommand extends Command { @@ -19,7 +19,7 @@ export class EnvironmentConfigureCommand extends Command { const { name } = ctx.getEnvironment() ctx.log.header({ emoji: "gear", command: `Configuring ${name} environment` }) - const result = await ctx.configureEnvironment() + const result = await ctx.configureEnvironment({}) ctx.log.info("") ctx.log.header({ emoji: "heavy_check_mark", command: `Done!` }) diff --git a/src/commands/environment/destroy.ts b/src/commands/environment/destroy.ts index 6fbe031c6c..5e6e967657 100644 --- a/src/commands/environment/destroy.ts +++ b/src/commands/environment/destroy.ts @@ -9,7 +9,7 @@ import { PluginContext } from "../../plugin-context" import { Command } from "../base" -import { EnvironmentStatusMap } from "../../types/plugin" +import { EnvironmentStatusMap } from "../../types/plugin/outputs" export class EnvironmentDestroyCommand extends Command { name = "destroy" @@ -20,7 +20,7 @@ export class EnvironmentDestroyCommand extends Command { const { name } = ctx.getEnvironment() ctx.log.header({ emoji: "skull_and_crossbones", command: `Destroying ${name} environment` }) - const result = await ctx.destroyEnvironment() + const result = await ctx.destroyEnvironment({}) ctx.log.finish() diff --git a/src/commands/login.ts b/src/commands/login.ts index 4fee395b16..85808643c7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -9,7 +9,7 @@ import { Command } from "./base" import { EntryStyle } from "../logger/types" import { PluginContext } from "../plugin-context" -import { LoginStatusMap } from "../types/plugin" +import { LoginStatusMap } from "../types/plugin/outputs" export class LoginCommand extends Command { name = "login" @@ -19,7 +19,7 @@ export class LoginCommand extends Command { ctx.log.header({ emoji: "unlock", command: "Login" }) ctx.log.info({ msg: "Logging in...", entryStyle: EntryStyle.activity }) - const result = await ctx.login() + const result = await ctx.login({}) ctx.log.info("\nLogin success!") diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 44b17cb2b4..c5b3a73a7e 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -9,7 +9,7 @@ import { Command } from "./base" import { EntryStyle } from "../logger/types" import { PluginContext } from "../plugin-context" -import { LoginStatusMap } from "../types/plugin" +import { LoginStatusMap } from "../types/plugin/outputs" export class LogoutCommand extends Command { name = "logout" @@ -21,7 +21,7 @@ export class LogoutCommand extends Command { const entry = ctx.log.info({ msg: "Logging out...", entryStyle: EntryStyle.activity }) - const result = await ctx.logout() + const result = await ctx.logout({}) entry.setSuccess("Logged out successfully") diff --git a/src/commands/logs.ts b/src/commands/logs.ts index 2af7051343..646b8e3fc8 100644 --- a/src/commands/logs.ts +++ b/src/commands/logs.ts @@ -9,7 +9,7 @@ import { PluginContext } from "../plugin-context" import { BooleanParameter, Command, ParameterValues, StringParameter } from "./base" import chalk from "chalk" -import { ServiceLogEntry } from "../types/plugin" +import { ServiceLogEntry } from "../types/plugin/outputs" import Bluebird = require("bluebird") import { Service } from "../types/service" import Stream from "ts-stream" @@ -39,6 +39,7 @@ export class LogsCommand extends Command { async action(ctx: PluginContext, args: Args, opts: Opts) { const names = args.service ? args.service.split(",") : undefined + const tail = opts.tail const services = await ctx.getServices(names) const result: ServiceLogEntry[] = [] @@ -53,7 +54,7 @@ export class LogsCommand extends Command { // NOTE: This will work differently when we have Elasticsearch set up for logging, but is // quite servicable for now. await Bluebird.map(services, async (service: Service) => { - await ctx.getServiceLogs(service, stream, opts.tail) + await ctx.getServiceLogs({ serviceName: service.name, stream, tail }) }) return result diff --git a/src/commands/run/module.ts b/src/commands/run/module.ts index f0cf9e73f6..5e2959d851 100644 --- a/src/commands/run/module.ts +++ b/src/commands/run/module.ts @@ -9,7 +9,7 @@ import chalk from "chalk" import { PluginContext } from "../../plugin-context" import { BuildTask } from "../../tasks/build" -import { RunResult } from "../../types/plugin" +import { RunResult } from "../../types/plugin/outputs" import { BooleanParameter, Command, ParameterValues, StringParameter } from "../base" import { uniq, @@ -50,19 +50,19 @@ export class RunModuleCommand extends Command { options = runOpts async action(ctx: PluginContext, args: Args, opts: Opts): Promise { - const name = args.module - const module = await ctx.getModule(name) + const moduleName = args.module + const module = await ctx.getModule(moduleName) const msg = args.command - ? `Running command ${chalk.white(args.command)} in module ${chalk.white(name)}` - : `Running module ${chalk.white(name)}` + ? `Running command ${chalk.white(args.command)} in module ${chalk.white(moduleName)}` + : `Running module ${chalk.white(moduleName)}` ctx.log.header({ emoji: "runner", command: msg, }) - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const buildTask = await BuildTask.factory({ ctx, module, force: opts["force-build"] }) await ctx.addTask(buildTask) @@ -79,6 +79,6 @@ export class RunModuleCommand extends Command { printRuntimeContext(ctx, runtimeContext) - return ctx.runModule({ module, command, runtimeContext, silent: false, interactive: opts.interactive }) + return ctx.runModule({ moduleName, command, runtimeContext, silent: false, interactive: opts.interactive }) } } diff --git a/src/commands/run/service.ts b/src/commands/run/service.ts index 258a933781..848548e6d7 100644 --- a/src/commands/run/service.ts +++ b/src/commands/run/service.ts @@ -9,7 +9,7 @@ import chalk from "chalk" import { PluginContext } from "../../plugin-context" import { BuildTask } from "../../tasks/build" -import { RunResult } from "../../types/plugin" +import { RunResult } from "../../types/plugin/outputs" import { BooleanParameter, Command, ParameterValues, StringParameter } from "../base" import { printRuntimeContext } from "./index" @@ -40,16 +40,16 @@ export class RunServiceCommand extends Command { options = runOpts async action(ctx: PluginContext, args: Args, opts: Opts): Promise { - const name = args.service - const service = await ctx.getService(name) + const serviceName = args.service + const service = await ctx.getService(serviceName) const module = service.module ctx.log.header({ emoji: "runner", - command: `Running service ${chalk.cyan(name)} in module ${chalk.cyan(module.name)}`, + command: `Running service ${chalk.cyan(serviceName)} in module ${chalk.cyan(module.name)}`, }) - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const buildTask = await BuildTask.factory({ ctx, module, force: opts["force-build"] }) await ctx.addTask(buildTask) @@ -60,6 +60,6 @@ export class RunServiceCommand extends Command { printRuntimeContext(ctx, runtimeContext) - return ctx.runService({ service, runtimeContext, silent: false, interactive: opts.interactive }) + return ctx.runService({ serviceName, runtimeContext, silent: false, interactive: opts.interactive }) } } diff --git a/src/commands/run/test.ts b/src/commands/run/test.ts index 25525eb372..7d996ab152 100644 --- a/src/commands/run/test.ts +++ b/src/commands/run/test.ts @@ -10,7 +10,7 @@ import chalk from "chalk" import { ParameterError } from "../../exceptions" import { PluginContext } from "../../plugin-context" import { BuildTask } from "../../tasks/build" -import { RunResult } from "../../types/plugin" +import { RunResult } from "../../types/plugin/outputs" import { findByName, getNames, @@ -52,15 +52,14 @@ export class RunTestCommand extends Command { const moduleName = args.module const testName = args.test const module = await ctx.getModule(moduleName) - const config = module.config - const testSpec = findByName(config.test, testName) + const testConfig = findByName(module.tests, testName) - if (!testSpec) { + if (!testConfig) { throw new ParameterError(`Could not find test "${testName}" in module ${moduleName}`, { moduleName, testName, - availableTests: getNames(config.test), + availableTests: getNames(module.tests), }) } @@ -69,18 +68,18 @@ export class RunTestCommand extends Command { command: `Running test ${chalk.cyan(testName)} in module ${chalk.cyan(moduleName)}`, }) - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const buildTask = await BuildTask.factory({ ctx, module, force: opts["force-build"] }) await ctx.addTask(buildTask) await ctx.processTasks() const interactive = opts.interactive - const deps = await ctx.getServices(testSpec.dependencies) + const deps = await ctx.getServices(testConfig.dependencies) const runtimeContext = await module.prepareRuntimeContext(deps) printRuntimeContext(ctx, runtimeContext) - return ctx.testModule({ module, interactive, runtimeContext, silent: false, testSpec }) + return ctx.testModule({ moduleName, interactive, runtimeContext, silent: false, testConfig }) } } diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 6be4a754df..7049c6243d 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -24,7 +24,7 @@ export class ScanCommand extends Command { const modules = await ctx.getModules() const output = await Bluebird.map(modules, async (m) => { - const config = await m.config + const config = m.config return { name: m.name, type: m.type, diff --git a/src/commands/test.ts b/src/commands/test.ts index 0bb722297c..5a924fa72f 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -49,7 +49,7 @@ export class TestCommand extends Command { command: `Running tests`, }) - await ctx.configureEnvironment() + await ctx.configureEnvironment({}) const group = opts.group const force = opts.force diff --git a/src/garden.ts b/src/garden.ts index 76631392ed..d6e176b352 100644 --- a/src/garden.ts +++ b/src/garden.ts @@ -32,11 +32,11 @@ import { } from "./plugins" import { Module, - ModuleConfig, - ModuleConfigType, } from "./types/module" import { + moduleActionDescriptions, moduleActionNames, + pluginActionDescriptions, pluginModuleSchema, pluginSchema, RegisterPluginParam, @@ -121,7 +121,9 @@ export type PluginActionMap = { export type ModuleActionMap = { [A in keyof ModuleActions]: { - [pluginName: string]: ModuleActions[A], + [moduleType: string]: { + [pluginName: string]: ModuleActions[A], + }, } } @@ -405,15 +407,19 @@ export class Garden { } for (const modulePath of plugin.modules || []) { - const module = await this.resolveModule(modulePath) + let module = await this.resolveModule(modulePath) if (!module) { throw new PluginError(`Could not load module "${modulePath}" specified in plugin "${pluginName}"`, { pluginName, modulePath, }) } - module.name = `${pluginName}.${module.name}` - module.updateConfig("name", module.name) + module = new Module( + this.pluginContext, + { ...module.config, name: `${pluginName}--${module.name}` }, + module.services, + module.tests, + ) await this.addModule(module) } @@ -451,9 +457,11 @@ export class Garden { pluginName: string, actionType: T, handler: PluginActions[T], ) { const plugin = this.getPlugin(pluginName) + const schema = pluginActionDescriptions[actionType].resultSchema - const wrapped = (...args) => { - return handler.apply(plugin, args) + const wrapped = async (...args) => { + const result = await handler.apply(plugin, args) + return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) } wrapped["actionType"] = actionType wrapped["pluginName"] = pluginName @@ -465,23 +473,25 @@ export class Garden { pluginName: string, actionType: T, moduleType: string, handler: ModuleActions[T], ) { const plugin = this.getPlugin(pluginName) + const schema = moduleActionDescriptions[actionType].resultSchema - const wrapped = (...args) => { - return handler.apply(plugin, args) + const wrapped = async (...args) => { + const result = await handler.apply(plugin, args) + return validate(result, schema, { context: `${actionType} output from plugin ${pluginName}` }) } wrapped["actionType"] = actionType wrapped["pluginName"] = pluginName wrapped["moduleType"] = moduleType - if (!this.moduleActionHandlers[moduleType]) { - this.moduleActionHandlers[moduleType] = {} + if (!this.moduleActionHandlers[actionType]) { + this.moduleActionHandlers[actionType] = {} } - if (!this.moduleActionHandlers[moduleType][actionType]) { - this.moduleActionHandlers[moduleType][actionType] = {} + if (!this.moduleActionHandlers[actionType][moduleType]) { + this.moduleActionHandlers[actionType][moduleType] = {} } - this.moduleActionHandlers[moduleType][actionType][pluginName] = wrapped + this.moduleActionHandlers[actionType][moduleType][pluginName] = wrapped } /* @@ -523,7 +533,7 @@ export class Garden { /** * Returns the module with the specified name. Throws error if it doesn't exist. */ - async getModule(name: string, noScan?: boolean): Promise> { + async getModule(name: string, noScan?: boolean): Promise { return (await this.getModules([name], noScan))[0] } @@ -605,9 +615,14 @@ export class Garden { this.modulesScanned = true - await detectCircularDependencies( - await this.getModules(), - (await this.getServices()).map(s => s.name)) + await this.detectCircularDependencies() + } + + private async detectCircularDependencies() { + const modules = await this.getModules() + const services = await this.getServices() + + return detectCircularDependencies(modules, services) } /* @@ -631,7 +646,7 @@ export class Garden { this.modules[config.name] = module // Add to service-module map - for (const service of config.services || []) { + for (const service of module.services) { const serviceName = service.name if (!force && this.services[serviceName]) { @@ -648,6 +663,11 @@ export class Garden { this.services[serviceName] = await Service.factory(this.pluginContext, module, serviceName) } + + if (this.modulesScanned) { + // need to re-run this if adding modules after initial scan + await this.detectCircularDependencies() + } } /* @@ -658,32 +678,36 @@ export class Garden { // TODO: support git URLs */ - async resolveModule(nameOrLocation: string): Promise { + async resolveModule(nameOrLocation: string): Promise { const parsedPath = parse(nameOrLocation) if (parsedPath.dir === "") { // Looks like a name - const module = this.modules[nameOrLocation] + const existingModule = this.modules[nameOrLocation] - if (!module) { + if (!existingModule) { throw new ConfigurationError(`Module ${nameOrLocation} could not be found`, { name: nameOrLocation, }) } - return module + return existingModule } // Looks like a path const path = resolve(this.projectRoot, nameOrLocation) const config = await loadConfig(this.projectRoot, path) - const moduleConfig = >config.module + const moduleConfig = config.module if (!moduleConfig) { return null } - return this.pluginContext.parseModule(moduleConfig) + const moduleName = moduleConfig.name + + const { module, services, tests } = await this.pluginContext.parseModule({ moduleName, moduleConfig }) + + return new Module(this.pluginContext, module, services, tests) } async getTemplateContext(extraContext: TemplateStringContext = {}): Promise { @@ -692,7 +716,8 @@ export class Garden { return { ...await getTemplateContext(), config: async (key: string[]) => { - return _this.pluginContext.getConfig(key) + const { value } = await _this.pluginContext.getConfig({ key }) + return value === null ? undefined : value }, variables: this.config.variables, environment: { name: this.environment, config: this.config }, @@ -725,7 +750,7 @@ export class Garden { public getModuleActionHandlers>( actionType: T, moduleType: string, ): ModuleActionHandlerMap { - return pick((this.moduleActionHandlers[moduleType] || {})[actionType], this.getEnvPlugins()) + return pick((this.moduleActionHandlers[actionType] || {})[moduleType], this.getEnvPlugins()) } /** @@ -757,7 +782,7 @@ export class Garden { /** * Get the last configured handler for the specified action. */ - public getModuleActionHandler>( + public getModuleActionHandler( type: T, moduleType: string, defaultHandler?: ModuleActions[T], ): ModuleActions[T] { diff --git a/src/plugin-context.ts b/src/plugin-context.ts index 28ab55b7da..0821fd19be 100644 --- a/src/plugin-context.ts +++ b/src/plugin-context.ts @@ -8,8 +8,6 @@ import Bluebird = require("bluebird") import chalk from "chalk" -import { Stream } from "ts-stream" -import { NotFoundError } from "./exceptions" import { Garden, } from "./garden" @@ -23,30 +21,54 @@ import { PrimitiveMap, } from "./types/common" import { Module } from "./types/module" +import { + ModuleActions, + Provider, + ServiceActions, +} from "./types/plugin" import { BuildResult, BuildStatus, DeleteConfigResult, EnvironmentStatusMap, ExecInServiceResult, + GetConfigResult, + GetServiceLogsResult, + LoginStatusMap, + ModuleActionOutputs, + ParseModuleResult, + PushResult, + RunResult, + ServiceActionOutputs, + SetConfigResult, + TestResult, +} from "./types/plugin/outputs" +import { + BuildModuleParams, + DeleteConfigParams, + DeployServiceParams, + ExecInServiceParams, + GetConfigParams, + GetModuleBuildStatusParams, GetServiceLogsParams, + GetServiceOutputsParams, + GetServiceStatusParams, + GetTestResultParams, ModuleActionParams, + ParseModuleParams, + PluginActionContextParams, PluginActionParams, - PushModuleParams, - PushResult, - ServiceLogEntry, - TestResult, - ModuleActions, PluginActionParamsBase, - LoginStatusMap, - RunResult, - TestModuleParams, + PluginModuleActionParamsBase, + PluginServiceActionParamsBase, + PushModuleParams, RunModuleParams, RunServiceParams, - Provider, -} from "./types/plugin" + ServiceActionParams, + SetConfigParams, + TestModuleParams, +} from "./types/plugin/params" import { - RuntimeContext, Service, ServiceStatus, } from "./types/service" @@ -56,13 +78,13 @@ import { values, padEnd, keyBy, + omit, } from "lodash" import { Omit, registerCleanupFunction, sleep, } from "./util" -import { TreeVersion } from "./vcs/base" import { computeAutoReloadDependants, FSWatcher, @@ -77,7 +99,11 @@ export interface ContextStatus { services: { [name: string]: ServiceStatus } } -export type OmitBase = Omit +export type PluginContextParams = Omit +export type PluginContextModuleParams = + Omit & { moduleName: string } +export type PluginContextServiceParams = + Omit & { serviceName: string } export type WrappedFromGarden = Pick export interface PluginContext extends PluginContextGuard, WrappedFromGarden { - parseModule: (moduleConfig: T["_ConfigType"]) => Promise - getModuleBuildPath: (module: T) => Promise - getModuleBuildStatus: (module: T, logEntry?: LogEntry) => Promise - buildModule: ( - module: T, buildContext: PrimitiveMap, logEntry?: LogEntry, - ) => Promise - pushModule: (module: T, logEntry?: LogEntry) => Promise - runModule: (params: OmitBase>) => Promise, - testModule: (params: OmitBase>) => Promise - getTestResult: ( - module: T, testName: string, version: TreeVersion, logEntry?: LogEntry, - ) => Promise - getEnvironmentStatus: () => Promise - configureEnvironment: () => Promise - destroyEnvironment: () => Promise - getServiceStatus: (service: Service) => Promise - deployService: (service: Service, logEntry?: LogEntry) => Promise - getServiceOutputs: (service: Service) => Promise - execInService: (service: Service, command: string[]) => Promise - getServiceLogs: ( - service: Service, stream: Stream, tail?: boolean, - ) => Promise - runService: (params: OmitBase>) => Promise, - getConfig: (key: string[]) => Promise - setConfig: (key: string[], value: string) => Promise - deleteConfig: (key: string[]) => Promise - getLoginStatus: () => Promise - login: () => Promise - logout: () => Promise - - stageBuild: (module: T) => Promise + getEnvironmentStatus: (params: {}) => Promise + configureEnvironment: (params: {}) => Promise + destroyEnvironment: (params: {}) => Promise + getConfig: (params: PluginContextParams) => Promise + setConfig: (params: PluginContextParams) => Promise + deleteConfig: (params: PluginContextParams) => Promise + getLoginStatus: (params: {}) => Promise + login: (params: {}) => Promise + logout: (params: {}) => Promise + + parseModule: (params: PluginContextModuleParams) => Promise + + getModuleBuildStatus: (params: PluginContextModuleParams>) + => Promise + buildModule: (params: PluginContextModuleParams>) + => Promise + pushModule: (params: PluginContextModuleParams>) + => Promise + runModule: (params: PluginContextModuleParams>) + => Promise, + testModule: (params: PluginContextModuleParams>) + => Promise + getTestResult: (params: PluginContextModuleParams>) + => Promise + + getServiceStatus: (params: PluginContextServiceParams>) + => Promise + deployService: (params: PluginContextServiceParams>) + => Promise + getServiceOutputs: (params: PluginContextServiceParams>) + => Promise + execInService: (params: PluginContextServiceParams>) + => Promise + getServiceLogs: (params: PluginContextServiceParams>) + => Promise + runService: (params: PluginContextServiceParams>) + => Promise, + + getModuleBuildPath: (moduleName: string) => Promise + stageBuild: (moduleName: string) => Promise getStatus: () => Promise deployServices: ( params: { names?: string[], force?: boolean, forceBuild?: boolean, logEntry?: LogEntry }, @@ -162,18 +197,52 @@ export function createPluginContext(garden: Garden): PluginContext { } } - async function resolveModule(handler, module: T): Promise { - const provider = getProvider(handler) - return module.resolveConfig({ provider }) + async function getModuleAndHandler( + moduleName: string, actionType: T, defaultHandler?: (ModuleActions & ServiceActions)[T], + ): Promise<{ handler: (ModuleActions & ServiceActions)[T], module: Module }> { + const module = await garden.getModule(moduleName) + const handler = garden.getModuleActionHandler(actionType, module.type, defaultHandler) + const provider = getProvider(handler) + + return { + handler, + module: await module.resolveConfig({ provider }), + } } - async function resolveService(handler, service: T, runtimeContext?: RuntimeContext): Promise { - const provider = getProvider(handler) - service.module = await resolveModule(handler, service.module) - if (!runtimeContext) { - runtimeContext = await service.prepareRuntimeContext() + async function callModuleHandler( + params: PluginContextModuleParams, actionType: T, defaultHandler?: ModuleActions[T], + ): Promise { + const { module, handler } = await getModuleAndHandler(params.moduleName, actionType, defaultHandler) + const handlerParams: ModuleActionParams[T] = { + ...commonParams(handler), + ...omit(params, ["moduleName"]), + module, } - return service.resolveConfig({ provider, ...runtimeContext }) + // TODO: figure out why this doesn't compile without the function cast + return (handler)(handlerParams) + } + + async function callServiceHandler( + params: PluginContextServiceParams, actionType: T, defaultHandler?: ServiceActions[T], + ): Promise { + const service = await garden.getService(params.serviceName) + + const { module, handler } = await getModuleAndHandler(service.module.name, actionType, defaultHandler) + service.module = module + + // TODO: figure out why this doesn't compile without the casts + const runtimeContext = (params).runtimeContext || await service.prepareRuntimeContext() + const provider = getProvider(handler) + + const handlerParams: any = { + ...commonParams(handler), + ...omit(params, ["moduleName"]), + module, + service: await service.resolveConfig({ provider, ...runtimeContext }), + } + + return (handler)(handlerParams) } const ctx: PluginContext = { @@ -195,64 +264,9 @@ export function createPluginContext(garden: Garden): PluginContext { addTask: wrap(garden.addTask), processTasks: wrap(garden.processTasks), - resolveModule: async (nameOrLocation: string) => { - const module = await garden.resolveModule(nameOrLocation) - return module ? module : null - }, - - parseModule: async (moduleConfig: T["_ConfigType"]) => { - const handler = garden.getModuleActionHandler("parseModule", moduleConfig.type) - return handler({ ...commonParams(handler), moduleConfig }) - }, - - getModuleBuildStatus: async (module: T, logEntry?: LogEntry) => { - const defaultHandler = garden.getModuleActionHandler("getModuleBuildStatus", "generic") - const handler = garden.getModuleActionHandler("getModuleBuildStatus", module.type, defaultHandler) - module = await resolveModule(handler, module) - return handler({ ...commonParams(handler), module, logEntry }) - }, - - buildModule: async (module: T, buildContext: PrimitiveMap, logEntry?: LogEntry) => { - const defaultHandler = garden.getModuleActionHandler("buildModule", "generic") - const handler = garden.getModuleActionHandler("buildModule", module.type, defaultHandler) - module = await resolveModule(handler, module) - await ctx.stageBuild(module) - return handler({ ...commonParams(handler), module, buildContext, logEntry }) - }, - - stageBuild: async (module: T) => { - await garden.buildDir.syncDependencyProducts(ctx, module) - }, - - pushModule: async (module: T, logEntry?: LogEntry) => { - const handler = garden.getModuleActionHandler("pushModule", module.type, dummyPushHandler) - module = await resolveModule(handler, module) - return handler({ ...commonParams(handler), module, logEntry }) - }, - - runModule: async (params: OmitBase>) => { - const handler = garden.getModuleActionHandler("runModule", params.module.type) - params.module = await resolveModule(handler, params.module) - return handler({ ...commonParams(handler), ...params }) - }, - - testModule: async (params: OmitBase>) => { - const module = params.module - - const defaultHandler = garden.getModuleActionHandler("testModule", "generic") - const handler = garden.getModuleActionHandler("testModule", module.type, defaultHandler) - params.module = await resolveModule(handler, params.module) - - return handler({ ...commonParams(handler), ...params }) - }, - - getTestResult: async ( - module: T, testName: string, version: TreeVersion, logEntry?: LogEntry, - ) => { - const handler = garden.getModuleActionHandler("getTestResult", module.type, async () => null) - module = await resolveModule(handler, module) - return handler({ ...commonParams(handler), module, testName, version, logEntry }) - }, + //=========================================================================== + //region Environment Actions + //=========================================================================== getEnvironmentStatus: async () => { const handlers = garden.getActionHandlers("getEnvironmentStatus") @@ -262,7 +276,7 @@ export function createPluginContext(garden: Garden): PluginContext { configureEnvironment: async () => { const handlers = garden.getActionHandlers("configureEnvironment") - const statuses = await ctx.getEnvironmentStatus() + const statuses = await ctx.getEnvironmentStatus({}) await Bluebird.each(toPairs(handlers), async ([name, handler]) => { const status = statuses[name] || { configured: false } @@ -281,102 +295,133 @@ export function createPluginContext(garden: Garden): PluginContext { logEntry.setSuccess("Configured") }) - return ctx.getEnvironmentStatus() + return ctx.getEnvironmentStatus({}) }, destroyEnvironment: async () => { const handlers = garden.getActionHandlers("destroyEnvironment") await Bluebird.each(values(handlers), h => h({ ...commonParams(h) })) - return ctx.getEnvironmentStatus() + return ctx.getEnvironmentStatus({}) }, - getServiceStatus: async (service: Service) => { - const handler = garden.getModuleActionHandler("getServiceStatus", service.module.type) - service = await resolveService(handler, service) - return handler({ ...commonParams(handler), service }) + getConfig: async ({ key }: PluginContextParams) => { + garden.validateConfigKey(key) + // TODO: allow specifying which provider to use for configs + const handler = garden.getActionHandler("getConfig") + return handler({ ...commonParams(handler), key }) }, - deployService: async (service: Service, logEntry?: LogEntry) => { - const handler = garden.getModuleActionHandler("deployService", service.module.type) + setConfig: async ({ key, value }: PluginContextParams) => { + garden.validateConfigKey(key) + const handler = garden.getActionHandler("setConfig") + return handler({ ...commonParams(handler), key, value }) + }, - const runtimeContext = await service.prepareRuntimeContext() - service = await resolveService(handler, service, runtimeContext) + deleteConfig: async ({ key }: PluginContextParams) => { + garden.validateConfigKey(key) + const handler = garden.getActionHandler("deleteConfig") + return handler({ ...commonParams(handler), key }) + }, + + //endregion - return handler({ ...commonParams(handler), service, runtimeContext, logEntry }) + //=========================================================================== + //region Module Actions + //=========================================================================== + + parseModule: async ({ moduleConfig }: PluginContextModuleParams>) => { + const handler = garden.getModuleActionHandler("parseModule", moduleConfig.type) + return handler({ ...commonParams(handler), moduleConfig }) }, - getServiceOutputs: async (service: Service) => { - // TODO: We might want to generally allow for "default handlers" - let handler: ModuleActions["getServiceOutputs"] - try { - handler = garden.getModuleActionHandler("getServiceOutputs", service.module.type) - } catch (err) { - return {} - } - service = await resolveService(handler, service) - return handler({ ...commonParams(handler), service }) + getModuleBuildStatus: async ( + params: PluginContextModuleParams>, + ) => { + const defaultHandler = garden.getModuleActionHandler("getModuleBuildStatus", "generic") + return callModuleHandler(params, "getModuleBuildStatus", defaultHandler) }, - execInService: async (service: Service, command: string[]) => { - const handler = garden.getModuleActionHandler("execInService", service.module.type) - service = await resolveService(handler, service) - return handler({ ...commonParams(handler), service, command }) + buildModule: async (params: PluginContextModuleParams>) => { + const defaultHandler = garden.getModuleActionHandler("buildModule", "generic") + const { module, handler } = await getModuleAndHandler(params.moduleName, "buildModule", defaultHandler) + await garden.buildDir.syncDependencyProducts(module) + return handler({ ...commonParams(handler), module, logEntry: params.logEntry }) }, - getServiceLogs: async (service: Service, stream: Stream, tail?: boolean) => { - const handler = garden.getModuleActionHandler("getServiceLogs", service.module.type, dummyLogStreamer) - service = await resolveService(handler, service) - return handler({ ...commonParams(handler), service, stream, tail }) + pushModule: async (params: PluginContextModuleParams>) => { + return callModuleHandler(params, "pushModule", dummyPushHandler) }, - runService: async (params: OmitBase>) => { - const handler = garden.getModuleActionHandler("runService", params.service.module.type) - params.service = await resolveService(handler, params.service) - return handler({ ...commonParams(handler), ...params }) + runModule: async (params: PluginContextModuleParams>) => { + return callModuleHandler(params, "runModule") }, - getConfig: async (key: string[]) => { - garden.validateConfigKey(key) - // TODO: allow specifying which provider to use for configs - const handler = garden.getActionHandler("getConfig") - const value = await handler({ ...commonParams(handler), key }) + testModule: async (params: PluginContextModuleParams>) => { + const defaultHandler = garden.getModuleActionHandler("testModule", "generic") + return callModuleHandler(params, "testModule", defaultHandler) + }, - if (value === null) { - throw new NotFoundError(`Could not find config key ${key}`, { key }) - } else { - return value - } + getTestResult: async (params: PluginContextModuleParams>) => { + return callModuleHandler(params, "getTestResult", async () => null) }, - setConfig: async (key: string[], value: string) => { - garden.validateConfigKey(key) - const handler = garden.getActionHandler("setConfig") - return handler({ ...commonParams(handler), key, value }) + //endregion + + //=========================================================================== + //region Service Actions + //=========================================================================== + + getServiceStatus: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "getServiceStatus") }, - deleteConfig: async (key: string[]) => { - garden.validateConfigKey(key) - const handler = garden.getActionHandler("deleteConfig") - const res = await handler({ ...commonParams(handler), key }) + deployService: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "deployService") + }, - if (!res.found) { - throw new NotFoundError(`Could not find config key ${key}`, { key }) - } else { - return res - } + getServiceOutputs: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "getServiceOutputs", async () => ({})) + }, + + execInService: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "execInService") + }, + + getServiceLogs: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "getServiceLogs", dummyLogStreamer) + }, + + runService: async (params: PluginContextServiceParams) => { + return callServiceHandler(params, "runService") + }, + + //endregion + + //=========================================================================== + //region Helper Methods + //=========================================================================== + + resolveModule: async (nameOrLocation: string) => { + const module = await garden.resolveModule(nameOrLocation) + return module || null + }, + stageBuild: async (moduleName: string) => { + const module = await garden.getModule(moduleName) + await garden.buildDir.syncDependencyProducts(module) }, - getModuleBuildPath: async (module: T) => { + getModuleBuildPath: async (moduleName: string) => { + const module = await garden.getModule(moduleName) return await garden.buildDir.buildPath(module) }, getStatus: async () => { - const envStatus: EnvironmentStatusMap = await ctx.getEnvironmentStatus() + const envStatus: EnvironmentStatusMap = await ctx.getEnvironmentStatus({}) const services = await ctx.getServices() const serviceStatus = await Bluebird.map( - services, (service: Service) => ctx.getServiceStatus(service), + services, (service: Service) => ctx.getServiceStatus({ serviceName: service.name }), ) return { @@ -463,15 +508,16 @@ export function createPluginContext(garden: Garden): PluginContext { login: async () => { const handlers = garden.getActionHandlers("login") await Bluebird.each(values(handlers), h => h({ ...commonParams(h) })) - return ctx.getLoginStatus() + return ctx.getLoginStatus({}) }, logout: async () => { const handlers = garden.getActionHandlers("logout") await Bluebird.each(values(handlers), h => h({ ...commonParams(h) })) - return ctx.getLoginStatus() + return ctx.getLoginStatus({}) }, + //endregion } return ctx @@ -482,6 +528,7 @@ const dummyLogStreamer = async ({ ctx, service }: GetServiceLogsParams) => { section: service.name, msg: chalk.yellow(`No handler for log retrieval available for module type ${service.module.type}`), }) + return {} } const dummyPushHandler = async ({ module }: PushModuleParams) => { diff --git a/src/plugins/container.ts b/src/plugins/container.ts index aaa9e124e5..4461c85886 100644 --- a/src/plugins/container.ts +++ b/src/plugins/container.ts @@ -9,31 +9,45 @@ import * as Joi from "joi" import * as childProcess from "child-process-promise" import { PluginContext } from "../plugin-context" -import { baseModuleSchema, baseServiceSchema, Module, ModuleConfig } from "../types/module" +import { + Module, + ModuleConfig, +} from "../types/module" import { LogSymbolType } from "../logger/types" import { joiIdentifier, joiArray, validate, + PrimitiveMap, + joiPrimitive, } from "../types/common" import { existsSync } from "fs" import { join } from "path" import { ConfigurationError } from "../exceptions" +import { + GardenPlugin, +} from "../types/plugin" import { BuildModuleParams, GetModuleBuildStatusParams, - GardenPlugin, - PushModuleParams, ParseModuleParams, + PushModuleParams, RunServiceParams, -} from "../types/plugin" +} from "../types/plugin/params" import { + baseServiceSchema, + BaseServiceSpec, Service, ServiceConfig, } from "../types/service" import { DEFAULT_PORT_PROTOCOL } from "../constants" import { splitFirst } from "../util" import { keyBy } from "lodash" +import { + genericModuleSpecSchema, + GenericModuleSpec, + GenericTestSpec, +} from "./generic" export interface ServiceEndpointSpec { paths?: string[] @@ -68,8 +82,8 @@ export interface ServiceHealthCheckSpec { tcpPort?: string, } -export interface ContainerServiceConfig extends ServiceConfig { - command?: string[], +export interface ContainerServiceSpec extends BaseServiceSpec { + command: string[], daemon: boolean endpoints: ServiceEndpointSpec[], healthCheck?: ServiceHealthCheckSpec, @@ -77,11 +91,7 @@ export interface ContainerServiceConfig extends ServiceConfig { volumes: ServiceVolumeSpec[], } -export interface ContainerModuleConfig - - extends ModuleConfig { - image?: string -} +export type ContainerServiceConfig = ServiceConfig const endpointSchema = Joi.object() .keys({ @@ -103,6 +113,7 @@ const healthCheckSchema = Joi.object() const portSchema = Joi.object() .keys({ + name: joiIdentifier().required(), protocol: Joi.string().allow("TCP", "UDP").default(DEFAULT_PORT_PROTOCOL), containerPort: Joi.number().required(), hostPort: Joi.number(), @@ -112,7 +123,7 @@ const portSchema = Joi.object() const volumeSchema = Joi.object() .keys({ - name: joiIdentifier(), + name: joiIdentifier().required(), containerPath: Joi.string().required(), hostPath: Joi.string(), }) @@ -127,137 +138,168 @@ const serviceSchema = baseServiceSchema volumes: joiArray(volumeSchema).unique("name"), }) -const containerSchema = baseModuleSchema.keys({ - type: Joi.string().allow("container").required(), - path: Joi.string().required(), +export interface ContainerModuleSpec extends GenericModuleSpec { + buildArgs: PrimitiveMap, + image?: string, + services: ContainerServiceSpec[], +} + +export type ContainerModuleConfig = ModuleConfig + +export const containerModuleSpecSchema = genericModuleSpecSchema.keys({ + buildArgs: Joi.object().pattern(/.+/, joiPrimitive()).default(() => ({}), "{}"), image: Joi.string(), services: joiArray(serviceSchema).unique("name"), }) export class ContainerService extends Service { } -export class ContainerModule extends Module { - image?: string - - constructor(ctx: PluginContext, config: T) { - super(ctx, config) +export class ContainerModule< + M extends ContainerModuleSpec = ContainerModuleSpec, + S extends ContainerServiceSpec = ContainerServiceSpec, + T extends GenericTestSpec = GenericTestSpec, + > extends Module { } - this.image = config.image - } +export async function getImage(module: ContainerModule) { + return module.spec.image +} - async getLocalImageId() { - if (this.hasDockerfile()) { - const { versionString } = await this.getVersion() - return `${this.name}:${versionString}` +export const helpers = { + async getLocalImageId(module: ContainerModule) { + if (helpers.hasDockerfile(module)) { + const { versionString } = await module.getVersion() + return `${module.name}:${versionString}` } else { - return this.image + return getImage(module) } - } + }, - async getRemoteImageId() { + async getRemoteImageId(module: ContainerModule) { // TODO: allow setting a default user/org prefix in the project/plugin config - if (this.image) { - let [imageName, version] = splitFirst(this.image, ":") + const image = await getImage(module) + if (image) { + let [imageName, version] = splitFirst(image, ":") if (version) { // we use the specified version in the image name, if specified // (allows specifying version on source images, and also setting specific version name when pushing images) - return this.image + return image } else { - const { versionString } = await this.getVersion() + const { versionString } = await module.getVersion() return `${imageName}:${versionString}` } } else { - return this.getLocalImageId() + return helpers.getLocalImageId(module) } - } + }, - async pullImage(ctx: PluginContext) { - const identifier = await this.getRemoteImageId() + async pullImage(ctx: PluginContext, module: ContainerModule) { + const identifier = await helpers.getRemoteImageId(module) - ctx.log.info({ section: this.name, msg: `pulling image ${identifier}...` }) - await this.dockerCli(`pull ${identifier}`) - } + ctx.log.info({ section: module.name, msg: `pulling image ${identifier}...` }) + await helpers.dockerCli(module, `pull ${identifier}`) + }, - async imageExistsLocally() { - const identifier = await this.getLocalImageId() - const exists = (await this.dockerCli(`images ${identifier} -q`)).stdout.trim().length > 0 + async imageExistsLocally(module: ContainerModule) { + const identifier = await helpers.getLocalImageId(module) + const exists = (await helpers.dockerCli(module, `images ${identifier} -q`)).stdout.trim().length > 0 return exists ? identifier : null - } + }, - async dockerCli(args) { + async dockerCli(module: ContainerModule, args) { // TODO: use dockerode instead of CLI - return childProcess.exec("docker " + args, { cwd: await this.getBuildPath(), maxBuffer: 1024 * 1024 }) - } + return childProcess.exec("docker " + args, { cwd: await module.getBuildPath(), maxBuffer: 1024 * 1024 }) + }, - hasDockerfile() { - return existsSync(join(this.path, "Dockerfile")) - } + hasDockerfile(module: ContainerModule) { + return existsSync(join(module.path, "Dockerfile")) + }, } -// TODO: rename this plugin to docker -export const gardenPlugin = (): GardenPlugin => ({ - moduleActions: { - container: { - async parseModule({ ctx, moduleConfig }: ParseModuleParams) { - moduleConfig = validate(moduleConfig, containerSchema, { context: `module ${moduleConfig.name}` }) +export async function parseContainerModule({ ctx, moduleConfig }: ParseModuleParams) { + moduleConfig.spec = validate(moduleConfig.spec, containerModuleSpecSchema, { context: `module ${moduleConfig.name}` }) - const module = new ContainerModule(ctx, moduleConfig) + // validate services + const services: ContainerServiceConfig[] = moduleConfig.spec.services.map(spec => { + // make sure ports are correctly configured + const name = spec.name + const definedPorts = spec.ports + const portsByName = keyBy(spec.ports, "name") - // make sure we can build the thing - if (!module.image && !module.hasDockerfile()) { - throw new ConfigurationError( - `Module ${moduleConfig.name} neither specifies image nor provides Dockerfile`, - {}, - ) - } + for (const endpoint of spec.endpoints) { + const endpointPort = endpoint.port - // validate services - for (const service of module.services) { - // make sure ports are correctly configured - const name = service.name - const definedPorts = service.ports - const portsByName = keyBy(service.ports, "name") - - for (const endpoint of service.endpoints) { - const endpointPort = endpoint.port - - if (!portsByName[endpointPort]) { - throw new ConfigurationError( - `Service ${name} does not define port ${endpointPort} defined in endpoint`, - { definedPorts, endpointPort }, - ) - } - } + if (!portsByName[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 (spec.healthCheck && spec.healthCheck.httpGet) { + const healthCheckHttpPort = spec.healthCheck.httpGet.port - if (!portsByName[healthCheckHttpPort]) { - throw new ConfigurationError( - `Service ${name} does not define port ${healthCheckHttpPort} defined in httpGet health check`, - { definedPorts, healthCheckHttpPort }, - ) - } - } + if (!portsByName[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 (spec.healthCheck && spec.healthCheck.tcpPort) { + const healthCheckTcpPort = spec.healthCheck.tcpPort - if (!portsByName[healthCheckTcpPort]) { - throw new ConfigurationError( - `Service ${name} does not define port ${healthCheckTcpPort} defined in tcpPort health check`, - { definedPorts, healthCheckTcpPort }, - ) - } - } - } + if (!portsByName[healthCheckTcpPort]) { + throw new ConfigurationError( + `Service ${name} does not define port ${healthCheckTcpPort} defined in tcpPort health check`, + { definedPorts, healthCheckTcpPort }, + ) + } + } - return module - }, + return { + name, + dependencies: spec.dependencies, + outputs: spec.outputs, + spec, + } + }) + + const tests = moduleConfig.spec.tests.map(t => ({ + name: t.name, + dependencies: t.dependencies, + spec: t, + timeout: t.timeout, + variables: t.variables, + })) + + const module = new ContainerModule(ctx, moduleConfig, services, tests) + + // make sure we can build the thing + if (!(await getImage(module)) && !helpers.hasDockerfile(module)) { + throw new ConfigurationError( + `Module ${moduleConfig.name} neither specifies image nor provides Dockerfile`, + {}, + ) + } + + return { + module: moduleConfig, + services, + tests, + } +} + +// TODO: rename this plugin to docker +export const gardenPlugin = (): GardenPlugin => ({ + moduleActions: { + container: { + parseModule: parseContainerModule, async getModuleBuildStatus({ module, logEntry }: GetModuleBuildStatusParams) { - const identifier = await module.imageExistsLocally() + const identifier = await helpers.imageExistsLocally(module) if (identifier) { logEntry && logEntry.debug({ @@ -270,56 +312,57 @@ export const gardenPlugin = (): GardenPlugin => ({ return { ready: !!identifier } }, - async buildModule({ ctx, module, buildContext, logEntry }: BuildModuleParams) { + async buildModule({ ctx, module, logEntry }: BuildModuleParams) { const buildPath = await module.getBuildPath() const dockerfilePath = join(buildPath, "Dockerfile") + const image = await getImage(module) - if (!!module.image && !existsSync(dockerfilePath)) { - if (await module.imageExistsLocally()) { + if (!!image && !existsSync(dockerfilePath)) { + if (await helpers.imageExistsLocally(module)) { return { fresh: false } } - logEntry && logEntry.setState(`Pulling image ${module.image}...`) - await module.pullImage(ctx) + logEntry && logEntry.setState(`Pulling image ${image}...`) + await helpers.pullImage(ctx, module) return { fetched: true } } - const identifier = await module.getLocalImageId() + const identifier = await helpers.getLocalImageId(module) // build doesn't exist, so we create it logEntry && logEntry.setState(`Building ${identifier}...`) - const buildArgs = Object.entries(buildContext).map(([key, value]) => { + const buildArgs = Object.entries(module.spec.buildArgs).map(([key, value]) => { // TODO: may need to escape this return `--build-arg ${key}=${value}` }).join(" ") // TODO: log error if it occurs // TODO: stream output to log if at debug log level - await module.dockerCli(`build ${buildArgs} -t ${identifier} ${buildPath}`) + await helpers.dockerCli(module, `build ${buildArgs} -t ${identifier} ${buildPath}`) return { fresh: true, details: { identifier } } }, async pushModule({ module, logEntry }: PushModuleParams) { - if (!module.hasDockerfile()) { + if (!helpers.hasDockerfile(module)) { logEntry && logEntry.setState({ msg: `Nothing to push` }) return { pushed: false } } - const localId = await module.getLocalImageId() - const remoteId = await module.getRemoteImageId() + const localId = await helpers.getLocalImageId(module) + const remoteId = await helpers.getRemoteImageId(module) // build doesn't exist, so we create it logEntry && logEntry.setState({ msg: `Pushing image ${remoteId}...` }) if (localId !== remoteId) { - await module.dockerCli(`tag ${localId} ${remoteId}`) + await helpers.dockerCli(module, `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}`) + await helpers.dockerCli(module, `push ${remoteId}`) return { pushed: true } }, @@ -328,8 +371,8 @@ export const gardenPlugin = (): GardenPlugin => ({ { ctx, service, interactive, runtimeContext, silent, timeout }: RunServiceParams, ) { return ctx.runModule({ - module: service.module, - command: service.config.command || [], + moduleName: service.module.name, + command: service.spec.command || [], interactive, runtimeContext, silent, diff --git a/src/plugins/generic.ts b/src/plugins/generic.ts index 0a5386ca4b..b9d9c185b6 100644 --- a/src/plugins/generic.ts +++ b/src/plugins/generic.ts @@ -7,84 +7,130 @@ */ import { exec } from "child-process-promise" +import * as Joi from "joi" import { - BuildModuleParams, - BuildResult, - BuildStatus, - GetModuleBuildStatusParams, - ParseModuleParams, - TestModuleParams, - TestResult, + joiArray, + validate, +} from "../types/common" +import { + GardenPlugin, } from "../types/plugin" import { Module, ModuleConfig, + ModuleSpec, } from "../types/module" +import { + BuildResult, + BuildStatus, + ParseModuleResult, + TestResult, +} from "../types/plugin/outputs" +import { + BuildModuleParams, + GetModuleBuildStatusParams, + ParseModuleParams, + TestModuleParams, +} from "../types/plugin/params" import { ServiceConfig, } from "../types/service" +import { + BaseTestSpec, + baseTestSpecSchema, +} from "../types/test" import { spawn } from "../util" -import { mapValues } from "lodash" export const name = "generic" -// TODO: find a different way to solve type export issues -let _serviceConfig: ServiceConfig +export interface GenericTestSpec extends BaseTestSpec { + command: string[], +} + +export const genericTestSchema = baseTestSpecSchema.keys({ + command: Joi.array().items(Joi.string()), +}) -export const genericPlugin = { +export interface GenericModuleSpec extends ModuleSpec { + tests: GenericTestSpec[], +} + +export const genericModuleSpecSchema = Joi.object().keys({ + tests: joiArray(genericTestSchema), +}).unknown(false) + +export class GenericModule extends Module { } + +export async function parseGenericModule( + { moduleConfig }: ParseModuleParams, +): Promise { + moduleConfig.spec = validate(moduleConfig.spec, genericModuleSpecSchema, { context: `module ${moduleConfig.name}` }) + + return { + module: moduleConfig, + services: [], + tests: moduleConfig.spec.tests.map(t => ({ + name: t.name, + dependencies: t.dependencies, + spec: t, + timeout: t.timeout, + variables: t.variables, + })), + } +} + +export async function buildGenericModule({ 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 = module.config + + if (config.build.command) { + const buildPath = await module.getBuildPath() + const result = await exec(config.build.command, { + cwd: buildPath, + env: { ...process.env }, + }) + + return { + fresh: true, + buildLog: result.stdout, + } + } else { + return {} + } +} + +export async function testGenericModule({ module, testConfig }: TestModuleParams): Promise { + const startedAt = new Date() + const command = testConfig.spec.command + const result = await spawn( + command[0], command.slice(1), { cwd: module.path, ignoreError: true }, + ) + + return { + moduleName: module.name, + command, + testName: testConfig.name, + version: await module.getVersion(), + success: result.code === 0, + startedAt, + completedAt: new Date(), + output: result.output, + } +} + +export const genericPlugin: GardenPlugin = { moduleActions: { generic: { - async parseModule({ ctx, moduleConfig }: ParseModuleParams): Promise { - return new Module(ctx, moduleConfig) - }, + parseModule: parseGenericModule, + buildModule: buildGenericModule, + testModule: testGenericModule, 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: !module.config.build.command } }, - - async buildModule({ module, buildContext }: 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 = module.config - - const contextEnv = mapValues(buildContext, v => v + "") - - if (config.build.command) { - const buildPath = await module.getBuildPath() - const result = await exec(config.build.command, { - cwd: buildPath, - env: { ...process.env, contextEnv }, - }) - - return { - fresh: true, - buildLog: result.stdout, - } - } else { - return {} - } - }, - - async testModule({ module, testSpec }: TestModuleParams): Promise { - const startedAt = new Date() - const command = testSpec.command - const result = await spawn( - command[0], command.slice(1), { cwd: module.path, ignoreError: true }, - ) - - return { - moduleName: module.name, - command, - testName: testSpec.name, - version: await module.getVersion(), - success: result.code === 0, - startedAt, - completedAt: new Date(), - output: result.output, - } - }, }, }, } diff --git a/src/plugins/google/common.ts b/src/plugins/google/common.ts index be1954c9cd..5a420515f3 100644 --- a/src/plugins/google/common.ts +++ b/src/plugins/google/common.ts @@ -6,24 +6,33 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Module, ModuleConfig } from "../../types/module" -import { Service, ServiceConfig } from "../../types/service" +import { + Module, + ModuleSpec, +} from "../../types/module" +import { ConfigureEnvironmentParams } from "../../types/plugin/params" +import { + BaseServiceSpec, + Service, +} from "../../types/service" import { ConfigurationError } from "../../exceptions" +import { GenericTestSpec } from "../generic" import { GCloud } from "./gcloud" import { - ConfigureEnvironmentParams, Provider, } from "../../types/plugin" export const GOOGLE_CLOUD_DEFAULT_REGION = "us-central1" -export interface GoogleCloudServiceConfig extends ServiceConfig { - project?: string +export interface GoogleCloudServiceSpec extends BaseServiceSpec { + project?: string, } -export interface GoogleCloudModuleConfig extends ModuleConfig { } - -export abstract class GoogleCloudModule extends Module { } +export abstract class GoogleCloudModule< + M extends ModuleSpec = ModuleSpec, + S extends GoogleCloudServiceSpec = GoogleCloudServiceSpec, + T extends GenericTestSpec = GenericTestSpec, + > extends Module { } export async function getEnvironmentStatus() { let sdkInfo @@ -83,6 +92,8 @@ export async function configureEnvironment({ ctx, status }: ConfigureEnvironment }) await gcloud().tty(["init"], { silent: false }) } + + return {} } export function gcloud(project?: string, account?: string) { @@ -90,5 +101,5 @@ export function gcloud(project?: string, account?: string) { } export function getProject(service: Service, provider: Provider) { - return service.config.project || provider.config["default-project"] || null + return service.spec.project || provider.config["default-project"] || null } diff --git a/src/plugins/google/google-app-engine.ts b/src/plugins/google/google-app-engine.ts index d7b3d3a5c5..19ca98dc7a 100644 --- a/src/plugins/google/google-app-engine.ts +++ b/src/plugins/google/google-app-engine.ts @@ -6,6 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { + DeployServiceParams, + GetServiceOutputsParams, +} from "../../types/plugin/params" import { ServiceStatus } from "../../types/service" import { join } from "path" import { @@ -17,21 +21,21 @@ import { GOOGLE_CLOUD_DEFAULT_REGION, configureEnvironment, } from "./common" -import { ContainerModule, ContainerModuleConfig, ContainerServiceConfig } from "../container" +import { + ContainerModule, + ContainerModuleSpec, + ContainerServiceSpec, +} from "../container" import { dumpYaml } from "../../util" import { - DeployServiceParams, GardenPlugin, - GetServiceOutputsParams, } from "../../types/plugin" -export interface GoogleAppEngineServiceConfig extends ContainerServiceConfig { - project: string +export interface GoogleAppEngineServiceSpec extends ContainerServiceSpec { + project?: string } -export interface GoogleAppEngineModuleConfig extends ContainerModuleConfig { } - -export class GoogleAppEngineModule extends ContainerModule { } +export class GoogleAppEngineModule extends ContainerModule { } export const gardenPlugin = (): GardenPlugin => ({ actions: { @@ -57,7 +61,7 @@ export const gardenPlugin = (): GardenPlugin => ({ msg: `Deploying app...`, }) - const config = service.config + const config = service.spec // prepare app.yaml const appYaml: any = { @@ -91,6 +95,8 @@ export const gardenPlugin = (): GardenPlugin => ({ ], { cwd: service.module.path }) ctx.log.info({ section: service.name, msg: `App deployed` }) + + return {} }, async getServiceOutputs({ service, provider }: GetServiceOutputsParams) { diff --git a/src/plugins/google/google-cloud-functions.ts b/src/plugins/google/google-cloud-functions.ts index 287dc1dbb6..cf544a9ad7 100644 --- a/src/plugins/google/google-cloud-functions.ts +++ b/src/plugins/google/google-cloud-functions.ts @@ -10,50 +10,84 @@ import { joiArray, validate, } from "../../types/common" -import { baseServiceSchema, Module, ModuleConfig } from "../../types/module" import { - ServiceConfig, + Module, + ModuleSpec, +} from "../../types/module" +import { ParseModuleResult } from "../../types/plugin/outputs" +import { + DeployServiceParams, + GetServiceOutputsParams, + GetServiceStatusParams, + ParseModuleParams, +} from "../../types/plugin/params" +import { + baseServiceSchema, ServiceState, ServiceStatus, - Service, } from "../../types/service" import { resolve, } from "path" import * as Joi from "joi" import { GARDEN_ANNOTATION_KEYS_VERSION } from "../../constants" +import { GenericTestSpec } from "../generic" import { configureEnvironment, gcloud, getEnvironmentStatus, getProject, GOOGLE_CLOUD_DEFAULT_REGION, + GoogleCloudServiceSpec, } from "./common" import { - DeployServiceParams, GardenPlugin, - GetServiceOutputsParams, - GetServiceStatusParams, - ParseModuleParams, } from "../../types/plugin" -export interface GoogleCloudFunctionsServiceConfig extends ServiceConfig { +export interface GcfServiceSpec extends GoogleCloudServiceSpec { function: string, entrypoint?: string, path: string, - project?: string, } -export interface GoogleCloudFunctionsModuleConfig extends ModuleConfig { } - export const gcfServicesSchema = joiArray(baseServiceSchema.keys({ entrypoint: Joi.string(), path: Joi.string().default("."), project: Joi.string(), })).unique("name") -export class GoogleCloudFunctionsModule extends Module { } -export class GoogleCloudFunctionsService extends Service { } +export interface GcfModuleSpec extends ModuleSpec { + functions: GcfServiceSpec[], + tests: GenericTestSpec[], +} + +export class GcfModule extends Module { } + +export async function parseGcfModule( + { moduleConfig }: ParseModuleParams, +): Promise> { + // TODO: check that each function exists at the specified path + const functions = validate( + moduleConfig.spec.functions, gcfServicesSchema, { context: `services in module ${moduleConfig.name}` }, + ) + + return { + module: moduleConfig, + services: functions.map(f => ({ + name: f.name, + dependencies: f.dependencies, + outputs: f.outputs, + spec: f, + })), + tests: moduleConfig.spec.tests.map(t => ({ + name: t.name, + dependencies: t.dependencies, + variables: t.variables, + timeout: t.timeout, + spec: t, + })), + } +} export const gardenPlugin = (): GardenPlugin => ({ actions: { @@ -62,25 +96,15 @@ export const gardenPlugin = (): GardenPlugin => ({ }, 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, { context: `services in module ${moduleConfig.name}` }, - ) - - return module - }, + parseModule: parseGcfModule, async deployService( - { ctx, provider, service, env }: DeployServiceParams, + { ctx, provider, module, service, env }: DeployServiceParams, ) { // TODO: provide env vars somehow to function const project = getProject(service, provider) - const functionPath = resolve(service.module.path, service.config.path) - const entrypoint = service.config.entrypoint || service.name + const functionPath = resolve(service.module.path, service.spec.path) + const entrypoint = service.spec.entrypoint || service.name await gcloud(project).call([ "beta", "functions", @@ -91,10 +115,10 @@ export const gardenPlugin = (): GardenPlugin => ({ "--trigger-http", ]) - return getServiceStatus({ ctx, provider, service, env }) + return getServiceStatus({ ctx, provider, module, service, env }) }, - async getServiceOutputs({ service, provider }: GetServiceOutputsParams) { + async getServiceOutputs({ service, provider }: GetServiceOutputsParams) { // TODO: we may want to pull this from the service status instead, along with other outputs const project = getProject(service, provider) @@ -107,7 +131,7 @@ export const gardenPlugin = (): GardenPlugin => ({ }) export async function getServiceStatus( - { service, provider }: GetServiceStatusParams, + { service, provider }: GetServiceStatusParams, ): Promise { const project = getProject(service, provider) const functions: any[] = await gcloud(project).json(["beta", "functions", "list"]) diff --git a/src/plugins/kubernetes/actions.ts b/src/plugins/kubernetes/actions.ts index 184ba8afea..9694107c7a 100644 --- a/src/plugins/kubernetes/actions.ts +++ b/src/plugins/kubernetes/actions.ts @@ -10,6 +10,14 @@ import * as inquirer from "inquirer" import * as Joi from "joi" import { DeploymentError, NotFoundError, TimeoutError } from "../../exceptions" +import { + GetServiceLogsResult, + LoginStatus, +} from "../../types/plugin/outputs" +import { + RunResult, + TestResult, +} from "../../types/plugin/outputs" import { ConfigureEnvironmentParams, DeleteConfigParams, @@ -21,17 +29,15 @@ import { GetServiceOutputsParams, GetServiceStatusParams, GetTestResultParams, - LoginStatus, PluginActionParamsBase, RunModuleParams, - RunResult, SetConfigParams, TestModuleParams, - TestResult, -} from "../../types/plugin" +} from "../../types/plugin/params" import { TreeVersion } from "../../vcs/base" import { ContainerModule, + helpers, } from "../container" import { values, every, uniq } from "lodash" import { deserializeKeys, prompt, serializeKeys, splitFirst, sleep } from "../../util" @@ -128,6 +134,8 @@ export async function configureEnvironment( logEntry && logEntry.setState({ section: "kubernetes", msg: `Creating namespace ${ns}` }) await createNamespace(context, ns) } + + return {} } export async function getServiceStatus(params: GetServiceStatusParams): Promise { @@ -174,6 +182,7 @@ export async function destroyEnvironment({ ctx, provider, env }: DestroyEnvironm } } + return {} } export async function getServiceOutputs({ service }: GetServiceOutputsParams) { @@ -183,10 +192,10 @@ export async function getServiceOutputs({ service }: GetServiceOutputsParams, + { ctx, provider, module, service, env, command }: ExecInServiceParams, ) { const context = provider.config.context - const status = await getServiceStatus({ ctx, provider, service, env }) + const status = await getServiceStatus({ ctx, provider, module, service, env }) const namespace = await getAppNamespace(ctx, provider) // TODO: this check should probably live outside of the plugin @@ -227,7 +236,7 @@ export async function runModule( const envArgs = Object.entries(runtimeContext.envVars).map(([k, v]) => `--env=${k}=${v}`) const commandStr = command.join(" ") - const image = await module.getLocalImageId() + const image = await helpers.getLocalImageId(module) const version = await module.getVersion() const opts = [ @@ -271,13 +280,13 @@ export async function runModule( } export async function testModule( - { ctx, provider, env, interactive, module, runtimeContext, silent, testSpec }: + { ctx, provider, env, interactive, module, runtimeContext, silent, testConfig }: TestModuleParams, ): Promise { - const testName = testSpec.name - const command = testSpec.command - runtimeContext.envVars = { ...runtimeContext.envVars, ...testSpec.variables } - const timeout = testSpec.timeout || DEFAULT_TEST_TIMEOUT + const testName = testConfig.name + const command = testConfig.spec.command + runtimeContext.envVars = { ...runtimeContext.envVars, ...testConfig.variables } + const timeout = testConfig.timeout || DEFAULT_TEST_TIMEOUT const result = await runModule({ ctx, provider, env, module, command, interactive, runtimeContext, silent, timeout }) @@ -325,7 +334,7 @@ export async function getServiceLogs( { ctx, provider, service, stream, tail }: GetServiceLogsParams, ) { const context = provider.config.context - const resourceType = service.config.daemon ? "daemonset" : "deployment" + const resourceType = service.spec.daemon ? "daemonset" : "deployment" const kubectlArgs = ["logs", `${resourceType}/${service.name}`, "--timestamps=true"] @@ -343,17 +352,17 @@ export async function getServiceLogs( return } const [timestampStr, msg] = splitFirst(s, " ") - const timestamp = moment(timestampStr) + const timestamp = moment(timestampStr).toDate() stream.write({ serviceName: service.name, timestamp, msg }) }) proc.stderr.pipe(process.stderr) - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { proc.on("error", reject) proc.on("exit", () => { - resolve() + resolve({}) }) }) } @@ -362,7 +371,8 @@ export async function getConfig({ ctx, provider, key }: GetConfigParams) { const context = provider.config.context const ns = getMetadataNamespace(ctx, provider) const res = await apiGetOrNull(coreApi(context).namespaces(ns).secrets, key.join(".")) - return res && Buffer.from(res.data.value, "base64").toString() + const value = res && Buffer.from(res.data.value, "base64").toString() + return { value } } export async function setConfig({ ctx, provider, key, value }: SetConfigParams) { @@ -385,6 +395,8 @@ export async function setConfig({ ctx, provider, key, value }: SetConfigParams) } await apiPostOrPut(coreApi(context).namespaces(ns).secrets, key.join("."), body) + + return {} } export async function deleteConfig({ ctx, provider, key }: DeleteConfigParams) { diff --git a/src/plugins/kubernetes/deployment.ts b/src/plugins/kubernetes/deployment.ts index cc1bf6398f..dba8e2bcbf 100644 --- a/src/plugins/kubernetes/deployment.ts +++ b/src/plugins/kubernetes/deployment.ts @@ -6,11 +6,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DeployServiceParams } from "../../types/plugin" +import { DeployServiceParams } from "../../types/plugin/params" import { + helpers, ContainerModule, ContainerService, - ContainerServiceConfig, } from "../container" import { toPairs, @@ -72,10 +72,10 @@ export async function deployService( } export async function createDeployment(service: ContainerService, runtimeContext: RuntimeContext) { - const config: ContainerServiceConfig = service.config + const spec = service.spec const { versionString } = await service.module.getVersion() // TODO: support specifying replica count - const configuredReplicas = 1 // service.config.count[env.name] || 1 + const configuredReplicas = 1 // service.spec.count[env.name] || 1 // TODO: moar type-safety const deployment: any = { @@ -142,9 +142,9 @@ export async function createDeployment(service: ContainerService, runtimeContext }) const container: any = { - args: service.config.command || [], + args: service.spec.command || [], name: service.name, - image: await service.module.getLocalImageId(), + image: await helpers.getLocalImageId(service.module), env, ports: [], // TODO: make these configurable @@ -165,7 +165,7 @@ export async function createDeployment(service: ContainerService, runtimeContext // container.command = [config.entrypoint] // } - if (config.healthCheck) { + if (spec.healthCheck) { container.readinessProbe = { initialDelaySeconds: 10, periodSeconds: 5, @@ -182,20 +182,20 @@ export async function createDeployment(service: ContainerService, runtimeContext failureThreshold: 3, } - const portsByName = keyBy(config.ports, "name") + const portsByName = keyBy(spec.ports, "name") - if (config.healthCheck.httpGet) { - const httpGet: any = extend({}, config.healthCheck.httpGet) + if (spec.healthCheck.httpGet) { + const httpGet: any = extend({}, spec.healthCheck.httpGet) httpGet.port = portsByName[httpGet.port].containerPort container.readinessProbe.httpGet = httpGet container.livenessProbe.httpGet = httpGet - } else if (config.healthCheck.command) { - container.readinessProbe.exec = { command: config.healthCheck.command.map(s => s.toString()) } + } else if (spec.healthCheck.command) { + container.readinessProbe.exec = { command: spec.healthCheck.command.map(s => s.toString()) } container.livenessProbe.exec = container.readinessProbe.exec - } else if (config.healthCheck.tcpPort) { + } else if (spec.healthCheck.tcpPort) { container.readinessProbe.tcpSocket = { - port: portsByName[config.healthCheck.tcpPort].containerPort, + port: portsByName[spec.healthCheck.tcpPort].containerPort, } container.livenessProbe.tcpSocket = container.readinessProbe.tcpSocket } else { @@ -217,11 +217,11 @@ export async function createDeployment(service: ContainerService, runtimeContext deployment.spec.selector.matchLabels = { service: service.name } deployment.spec.template.metadata.labels = labels - if (config.volumes && config.volumes.length) { + if (spec.volumes && spec.volumes.length) { const volumes: any[] = [] const volumeMounts: any[] = [] - for (const volume of config.volumes) { + for (const volume of spec.volumes) { const volumeName = volume.name const volumeType = !!volume.hostPath ? "hostPath" : "emptyDir" @@ -258,7 +258,7 @@ export async function createDeployment(service: ContainerService, runtimeContext container.volumeMounts = volumeMounts } - const ports = config.ports + const ports = spec.ports for (const port of ports) { container.ports.push({ @@ -267,7 +267,7 @@ export async function createDeployment(service: ContainerService, runtimeContext }) } - if (config.daemon) { + if (spec.daemon) { // this runs a pod on every node deployment.kind = "DaemonSet" deployment.spec.updateStrategy = { diff --git a/src/plugins/kubernetes/ingress.ts b/src/plugins/kubernetes/ingress.ts index f27d29cfe5..9f9270996e 100644 --- a/src/plugins/kubernetes/ingress.ts +++ b/src/plugins/kubernetes/ingress.ts @@ -13,11 +13,11 @@ import { KubernetesProvider } from "./index" export async function createIngress(ctx: PluginContext, provider: KubernetesProvider, service: ContainerService) { // FIXME: ingresses don't get updated when deployment is already running (rethink status check) - if (service.config.endpoints.length === 0) { + if (service.spec.endpoints.length === 0) { return null } - const rules = service.config.endpoints.map(e => { + const rules = service.spec.endpoints.map(e => { const rule: any = {} // TODO: support separate hostnames per endpoint @@ -25,7 +25,7 @@ export async function createIngress(ctx: PluginContext, provider: KubernetesProv const backend = { serviceName: service.name, - servicePort: findByName(service.config.ports, e.port)!.containerPort, + servicePort: findByName(service.spec.ports, e.port)!.containerPort, } rule.http = { diff --git a/src/plugins/kubernetes/local.ts b/src/plugins/kubernetes/local.ts index 95e4878a97..ba7883f745 100644 --- a/src/plugins/kubernetes/local.ts +++ b/src/plugins/kubernetes/local.ts @@ -17,10 +17,12 @@ import * as Joi from "joi" import { join } from "path" import { validate } from "../../types/common" import { - ConfigureEnvironmentParams, GardenPlugin, - GetEnvironmentStatusParams, } from "../../types/plugin" +import { + ConfigureEnvironmentParams, + GetEnvironmentStatusParams, +} from "../../types/plugin/params" import { providerConfigBase } from "../../types/project" import { findByName } from "../../util" import { @@ -69,7 +71,7 @@ async function configureLocalEnvironment( const sysGarden = await getSystemGarden(provider) const sysProvider = { name: provider.name, - config: findByName(sysGarden.config.providers, provider.name), + config: findByName(sysGarden.config.providers, provider.name)!, } const sysStatus = await getEnvironmentStatus({ ctx: sysGarden.pluginContext, @@ -85,6 +87,8 @@ async function configureLocalEnvironment( }) await sysGarden.pluginContext.deployServices({ logEntry }) } + + return {} } function getKubeConfig(): any { diff --git a/src/plugins/kubernetes/service.ts b/src/plugins/kubernetes/service.ts index d7b3b14b80..06e9c865c9 100644 --- a/src/plugins/kubernetes/service.ts +++ b/src/plugins/kubernetes/service.ts @@ -35,7 +35,7 @@ export async function createServices(service: ContainerService) { // first add internally exposed (ClusterIP) service const internalPorts: any = [] - const ports = service.config.ports + const ports = service.spec.ports for (const portSpec of ports) { internalPorts.push({ diff --git a/src/plugins/kubernetes/specs-module.ts b/src/plugins/kubernetes/specs-module.ts index f8d6f69430..9c9a5bacaa 100644 --- a/src/plugins/kubernetes/specs-module.ts +++ b/src/plugins/kubernetes/specs-module.ts @@ -10,36 +10,41 @@ import Bluebird = require("bluebird") import * as Joi from "joi" import { GARDEN_ANNOTATION_KEYS_VERSION } from "../../constants" import { - joiArray, joiIdentifier, validate, } from "../../types/common" import { - baseServiceSchema, Module, - ModuleConfig, + ModuleSpec, } from "../../types/module" +import { ModuleActions } from "../../types/plugin" +import { + ParseModuleResult, +} from "../../types/plugin/outputs" import { DeployServiceParams, GetServiceStatusParams, ParseModuleParams, -} from "../../types/plugin" +} from "../../types/plugin/params" import { ServiceConfig, + ServiceSpec, ServiceStatus, } from "../../types/service" +import { + TestConfig, + TestSpec, +} from "../../types/test" import { apply, } from "./kubectl" import { getAppNamespace } from "./namespace" -export interface KubernetesSpecsServiceConfig extends ServiceConfig { - specs: object[] +export interface KubernetesSpecsModuleSpec extends ModuleSpec { + specs: any[], } -export interface KubernetesSpecsModuleConfig extends ModuleConfig { } - -export class KubernetesSpecsModule extends Module { } +export class KubernetesSpecsModule extends Module { } const k8sSpecSchema = Joi.object().keys({ apiVersion: Joi.string().required(), @@ -48,26 +53,31 @@ const k8sSpecSchema = Joi.object().keys({ annotations: Joi.object(), name: joiIdentifier().required(), namespace: joiIdentifier(), - }).required(), + labels: Joi.object(), + }).required().unknown(true), }).unknown(true) -const specsServicesSchema = joiArray(baseServiceSchema.keys({ - specs: Joi.array().items(k8sSpecSchema).required(), - // TODO: support spec files as well - // specFiles: Joi.array().items(Joi.string()), -})).unique("name") - -export const kubernetesSpecHandlers = { - parseModule: async ({ ctx, moduleConfig }: ParseModuleParams): Promise => { - moduleConfig.services = validate( - moduleConfig.services, - specsServicesSchema, - { context: `${moduleConfig.name} services` }, - ) +const k8sSpecsSchema = Joi.array().items(k8sSpecSchema).min(1) +export const kubernetesSpecHandlers: Partial = { + async parseModule({ moduleConfig }: ParseModuleParams): Promise { // TODO: check that each spec namespace is the same as on the project, if specified - - return new KubernetesSpecsModule(ctx, moduleConfig) + const services: ServiceConfig[] = [{ + name: moduleConfig.name, + dependencies: [], + outputs: {}, + spec: { + specs: validate(moduleConfig.spec.specs, k8sSpecsSchema, { context: `${moduleConfig.name} kubernetes specs` }), + }, + }] + + const tests: TestConfig[] = [] + + return { + module: moduleConfig, + services, + tests, + } }, getServiceStatus: async ( @@ -78,7 +88,7 @@ export const kubernetesSpecHandlers = { const currentVersion = await service.module.getVersion() const dryRunOutputs = await Bluebird.map( - service.config.specs, + service.module.spec.specs, (spec) => apply(context, spec, { dryRun: true, namespace }), ) @@ -100,7 +110,7 @@ export const kubernetesSpecHandlers = { const namespace = await getAppNamespace(ctx, provider) const currentVersion = await service.module.getVersion() - return Bluebird.each(service.config.specs, async (spec) => { + await Bluebird.each(service.module.spec.specs, async (spec) => { const annotatedSpec = { metadata: {}, ...spec, @@ -114,5 +124,7 @@ export const kubernetesSpecHandlers = { await apply(context, annotatedSpec, { namespace }) }) + + return {} }, } diff --git a/src/plugins/kubernetes/status.ts b/src/plugins/kubernetes/status.ts index 27b8fa24ce..b45be58eb5 100644 --- a/src/plugins/kubernetes/status.ts +++ b/src/plugins/kubernetes/status.ts @@ -35,11 +35,11 @@ export async function checkDeploymentStatus( { ctx: PluginContext, provider: Provider, service: ContainerService, resourceVersion?: number }, ): Promise { const context = provider.config.context - const type = service.config.daemon ? "daemonsets" : "deployments" + const type = service.spec.daemon ? "daemonsets" : "deployments" const hostname = getServiceHostname(ctx, provider, service) const namespace = await getAppNamespace(ctx, provider) - const endpoints = service.config.endpoints.map((e: ServiceEndpointSpec) => { + const endpoints = service.spec.endpoints.map((e: ServiceEndpointSpec) => { // TODO: this should be HTTPS, once we've set up TLS termination at the ingress controller level const protocol: ServiceProtocol = "http" @@ -143,7 +143,7 @@ export async function checkDeploymentStatus( if (statusRes.metadata.generation > status.observedGeneration) { statusMsg = `Waiting for spec update to be observed...` out.state = "deploying" - } else if (service.config.daemon) { + } else if (service.spec.daemon) { const desired = status.desiredNumberScheduled || 0 const updated = status.updatedNumberScheduled || 0 available = status.numberAvailable || 0 diff --git a/src/plugins/local/local-docker-swarm.ts b/src/plugins/local/local-docker-swarm.ts index acdff3b7e4..d61c7f088a 100644 --- a/src/plugins/local/local-docker-swarm.ts +++ b/src/plugins/local/local-docker-swarm.ts @@ -11,13 +11,18 @@ import { exec } from "child-process-promise" import { DeploymentError } from "../../exceptions" import { PluginContext } from "../../plugin-context" import { + GardenPlugin, +} from "../../types/plugin" +import { + DeployServiceParams, ExecInServiceParams, GetServiceOutputsParams, GetServiceStatusParams, - GardenPlugin, - DeployServiceParams, -} from "../../types/plugin" -import { ContainerModule } from "../container" +} from "../../types/plugin/params" +import { + helpers, + ContainerModule, +} from "../container" import { map, sortBy, @@ -40,15 +45,15 @@ export const gardenPlugin = (): GardenPlugin => ({ getServiceStatus, async deployService( - { ctx, provider, service, runtimeContext, env }: DeployServiceParams, + { ctx, provider, module, service, runtimeContext, 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 = service.config.ports.map(p => { + const identifier = await helpers.getLocalImageId(module) + const ports = service.spec.ports.map(p => { const port: any = { Protocol: p.protocol ? p.protocol.toLowerCase() : "tcp", TargetPort: p.containerPort, @@ -61,7 +66,7 @@ export const gardenPlugin = (): GardenPlugin => ({ const envVars = map(runtimeContext.envVars, (v, k) => `${k}=${v}`) - const volumeMounts = service.config.volumes.map(v => { + const volumeMounts = service.spec.volumes.map(v => { // TODO-LOW: Support named volumes if (v.hostPath) { return { @@ -87,7 +92,7 @@ export const gardenPlugin = (): GardenPlugin => ({ TaskTemplate: { ContainerSpec: { Image: identifier, - Command: service.config.command, + Command: service.spec.command, Env: envVars, Mounts: volumeMounts, }, @@ -112,7 +117,7 @@ export const gardenPlugin = (): GardenPlugin => ({ } const docker = getDocker() - const serviceStatus = await getServiceStatus({ ctx, provider, service, env }) + const serviceStatus = await getServiceStatus({ ctx, provider, service, env, module }) let swarmServiceStatus let serviceId @@ -168,7 +173,7 @@ export const gardenPlugin = (): GardenPlugin => ({ msg: `Ready`, }) - return getServiceStatus({ ctx, provider, service, env }) + return getServiceStatus({ ctx, provider, module, service, env }) }, async getServiceOutputs({ ctx, service }: GetServiceOutputsParams) { @@ -180,7 +185,7 @@ export const gardenPlugin = (): GardenPlugin => ({ async execInService( { ctx, provider, env, service, command }: ExecInServiceParams, ) { - const status = await getServiceStatus({ ctx, provider, service, env }) + const status = await getServiceStatus({ ctx, provider, service, env, module: service.module }) if (!status.state || status.state !== "ready") { throw new DeploymentError(`Service ${service.name} is not running`, { @@ -234,6 +239,7 @@ async function getEnvironmentStatus() { async function configureEnvironment() { await getDocker().swarmInit({}) + return {} } async function getServiceStatus({ ctx, service }: GetServiceStatusParams): Promise { diff --git a/src/plugins/local/local-google-cloud-functions.ts b/src/plugins/local/local-google-cloud-functions.ts index 7e8104fed6..913f3b70d4 100644 --- a/src/plugins/local/local-google-cloud-functions.ts +++ b/src/plugins/local/local-google-cloud-functions.ts @@ -6,35 +6,32 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { PluginContext } from "../../plugin-context" -import { ServiceStatus } from "../../types/service" +import { + ModuleConfig, +} from "../../types/module" +import { ParseModuleParams } from "../../types/plugin/params" +import { + ServiceConfig, +} from "../../types/service" import { join } from "path" import { - gcfServicesSchema, - GoogleCloudFunctionsModule, - GoogleCloudFunctionsService, + GcfModule, + parseGcfModule, } from "../google/google-cloud-functions" import { - DeployServiceParams, - GetServiceLogsParams, - GetServiceOutputsParams, - GetServiceStatusParams, - ParseModuleParams, GardenPlugin, - BuildModuleParams, - GetModuleBuildStatusParams, } from "../../types/plugin" import { STATIC_DIR } from "../../constants" import { - ContainerModule, - ContainerModuleConfig, - ContainerService, + ContainerModuleSpec, + ContainerServiceSpec, ServicePortProtocol, } from "../container" -import { validate } from "../../types/common" -const baseContainerName = "local-google-cloud-functions.local-gcf-container" -const emulatorBaseModulePath = join(STATIC_DIR, "local-gcf-container") +const pluginName = "local-google-cloud-functions" +const emulatorModuleName = "local-gcf-container" +const baseContainerName = `${pluginName}--${emulatorModuleName}` +const emulatorBaseModulePath = join(STATIC_DIR, emulatorModuleName) const emulatorPort = 8010 export const gardenPlugin = (): GardenPlugin => ({ @@ -42,112 +39,78 @@ export const gardenPlugin = (): GardenPlugin => ({ moduleActions: { "google-cloud-function": { - async parseModule({ ctx, moduleConfig }: ParseModuleParams) { - moduleConfig.build.dependencies.push({ - name: baseContainerName, - copy: [], + async parseModule(params: ParseModuleParams) { + const parsed = await parseGcfModule(params) + + // convert the module and services to containers to run locally + const services: ServiceConfig[] = parsed.services.map((s) => { + const functionEntrypoint = s.spec.entrypoint || s.name + + const spec = { + name: s.name, + dependencies: s.dependencies, + outputs: { + endpoint: `http://${s.name}:${emulatorPort}/local/local/${functionEntrypoint}`, + }, + command: ["/app/start.sh", functionEntrypoint], + daemon: false, + endpoints: [{ + port: "http", + }], + healthCheck: { tcpPort: "http" }, + ports: [ + { + name: "http", + protocol: "TCP", + containerPort: 8010, + }, + ], + volumes: [], + } + + return { + name: spec.name, + dependencies: spec.dependencies, + outputs: spec.outputs, + spec, + } }) - const module = new GoogleCloudFunctionsModule(ctx, moduleConfig) - - // TODO: check that each function exists at the specified path - - module.services = validate( - moduleConfig.services, - gcfServicesSchema, - { context: `services in module ${moduleConfig.name}` }, - ) - - return module - }, - - async getModuleBuildStatus({ ctx, module }: GetModuleBuildStatusParams) { - const emulator = await getEmulatorModule(ctx, module) - return ctx.getModuleBuildStatus(emulator) - }, - - async buildModule({ ctx, module, logEntry }: BuildModuleParams) { - const baseModule = await ctx.getModule(baseContainerName) - const emulator = await getEmulatorModule(ctx, module) - const baseImageName = (await baseModule.getLocalImageId())! - return ctx.buildModule(emulator, { baseImageName }, logEntry) - }, - - async getServiceStatus( - { ctx, service }: GetServiceStatusParams, - ): Promise { - const emulator = await getEmulatorService(ctx, service) - return ctx.getServiceStatus(emulator) - }, + const module: ModuleConfig = { + allowPush: true, + build: { + dependencies: parsed.module.build.dependencies.concat([{ + name: emulatorModuleName, + plugin: pluginName, + copy: [{ + source: "child/Dockerfile", + target: "Dockerfile", + }], + }]), + }, + name: parsed.module.name, + path: parsed.module.path, + type: "container", + variables: parsed.module.variables, + + spec: { + buildArgs: { + baseImageName: `${baseContainerName}:\${dependencies.${baseContainerName}.version}`, + }, + image: `${parsed.module.name}:\${module.version}`, + services: services.map(s => s.spec), + tests: [], + }, + } - async deployService({ ctx, service }: DeployServiceParams) { - const emulatorService = await getEmulatorService(ctx, service) - return ctx.deployService(emulatorService) - }, + const tests = parsed.tests - async getServiceOutputs({ service }: GetServiceOutputsParams) { return { - endpoint: `http://${service.name}:${emulatorPort}/local/local/${service.config.entrypoint || service.name}`, + module, + services, + tests, } }, - - async getServiceLogs({ ctx, service, stream, tail }: GetServiceLogsParams) { - const emulator = await getEmulatorService(ctx, service) - return ctx.getServiceLogs(emulator, stream, tail) - }, }, }, }) - -async function getEmulatorModule(ctx: PluginContext, module: GoogleCloudFunctionsModule) { - const services = module.services.map((s) => { - const functionEntrypoint = s.entrypoint || s.name - - return { - name: s.name, - command: ["/app/start.sh", functionEntrypoint], - daemon: false, - dependencies: s.dependencies, - endpoints: [{ - port: "http", - }], - healthCheck: { tcpPort: "http" }, - ports: [ - { - name: "http", - protocol: "TCP", - containerPort: 8010, - }, - ], - volumes: [], - } - }) - - const config = module.config - const version = await module.getVersion() - - return new ContainerModule(ctx, { - allowPush: true, - build: { - dependencies: config.build.dependencies.concat([{ - name: baseContainerName, - copy: [{ - source: "child/Dockerfile", - target: "Dockerfile", - }], - }]), - }, - image: `${module.name}:${version.versionString}`, - name: module.name, - path: module.path, - services, - test: config.test, - type: "container", - variables: config.variables, - }) -} - -async function getEmulatorService(ctx: PluginContext, service: GoogleCloudFunctionsService) { - const emulatorModule = await getEmulatorModule(ctx, service.module) - return ContainerService.factory(ctx, emulatorModule, service.name) -} diff --git a/src/plugins/npm-package.ts b/src/plugins/npm-package.ts index 27f11665c2..3e8cb0978a 100644 --- a/src/plugins/npm-package.ts +++ b/src/plugins/npm-package.ts @@ -18,6 +18,6 @@ let _serviceConfig: ServiceConfig export const gardenPlugin = (): GardenPlugin => ({ moduleActions: { - "npm-package": genericPlugin.moduleActions.generic, + "npm-package": genericPlugin.moduleActions!.generic, }, }) diff --git a/src/tasks/build.ts b/src/tasks/build.ts index 9fd0dbca80..041e8138a4 100644 --- a/src/tasks/build.ts +++ b/src/tasks/build.ts @@ -12,7 +12,7 @@ import { round } from "lodash" import { PluginContext } from "../plugin-context" import { Module } from "../types/module" import { EntryStyle } from "../logger/types" -import { BuildResult } from "../types/plugin" +import { BuildResult } from "../types/plugin/outputs" import { Task, TaskParams, TaskVersion } from "../types/task" export interface BuildTaskParams extends TaskParams { @@ -60,23 +60,28 @@ export class BuildTask extends Task { } async process(): Promise { - if (!this.force && (await this.ctx.getModuleBuildStatus(this.module)).ready) { + const moduleName = this.module.name + + if (!this.force && (await this.ctx.getModuleBuildStatus({ moduleName })).ready) { // this is necessary in case other modules depend on files from this one - await this.ctx.stageBuild(this.module) + await this.ctx.stageBuild(moduleName) return { fresh: false } } - const entry = this.ctx.log.info({ + const logEntry = this.ctx.log.info({ section: this.module.name, msg: "Building", entryStyle: EntryStyle.activity, }) const startTime = new Date().getTime() - const result = await this.ctx.buildModule(this.module, {}, entry) + const result = await this.ctx.buildModule({ + moduleName, + logEntry, + }) const buildTime = (new Date().getTime()) - startTime - entry.setSuccess({ msg: chalk.green(`Done (took ${round(buildTime / 1000, 1)} sec)`), append: true }) + logEntry.setSuccess({ msg: chalk.green(`Done (took ${round(buildTime / 1000, 1)} sec)`), append: true }) return result } diff --git a/src/tasks/deploy.ts b/src/tasks/deploy.ts index acb6ddb9e6..4215695521 100644 --- a/src/tasks/deploy.ts +++ b/src/tasks/deploy.ts @@ -74,7 +74,7 @@ export class DeployTask extends Task { } async process(): Promise { - const entry = (this.logEntry || this.ctx.log).info({ + const logEntry = (this.logEntry || this.ctx.log).info({ section: this.service.name, msg: "Checking status", entryStyle: EntryStyle.activity, @@ -82,7 +82,7 @@ export class DeployTask extends Task { // TODO: get version from build task results const { versionString } = await this.service.module.getVersion() - const status = await this.ctx.getServiceStatus(this.service) + const status = await this.ctx.getServiceStatus({ serviceName: this.service.name, logEntry }) if ( !this.force && @@ -90,18 +90,22 @@ export class DeployTask extends Task { status.state === "ready" ) { // already deployed and ready - entry.setSuccess({ + logEntry.setSuccess({ msg: `Version ${versionString} already deployed`, append: true, }) return status } - entry.setState({ section: this.service.name, msg: "Deploying" }) + logEntry.setState({ section: this.service.name, msg: "Deploying" }) - const result = await this.ctx.deployService(this.service, entry) + const result = await this.ctx.deployService({ + serviceName: this.service.name, + runtimeContext: await this.service.prepareRuntimeContext(), + logEntry, + }) - entry.setSuccess({ msg: chalk.green(`Ready`), append: true }) + logEntry.setSuccess({ msg: chalk.green(`Ready`), append: true }) return result } diff --git a/src/tasks/push.ts b/src/tasks/push.ts index d35e9928f1..fa7c62c2e9 100644 --- a/src/tasks/push.ts +++ b/src/tasks/push.ts @@ -11,7 +11,7 @@ import { PluginContext } from "../plugin-context" import { BuildTask } from "./build" import { Module } from "../types/module" import { EntryStyle } from "../logger/types" -import { PushResult } from "../types/plugin" +import { PushResult } from "../types/plugin/outputs" import { Task, TaskParams, TaskVersion } from "../types/task" export interface PushTaskParams extends TaskParams { @@ -65,18 +65,18 @@ export class PushTask extends Task { return { pushed: false } } - const entry = this.ctx.log.info({ + const logEntry = this.ctx.log.info({ section: this.module.name, msg: "Pushing", entryStyle: EntryStyle.activity, }) - const result = await this.ctx.pushModule(this.module, entry) + const result = await this.ctx.pushModule({ moduleName: this.module.name, logEntry }) if (result.pushed) { - entry.setSuccess({ msg: chalk.green(result.message || `Ready`), append: true }) + logEntry.setSuccess({ msg: chalk.green(result.message || `Ready`), append: true }) } else { - entry.setWarn({ msg: result.message, append: true }) + logEntry.setWarn({ msg: result.message, append: true }) } return result diff --git a/src/tasks/test.ts b/src/tasks/test.ts index acb132ada1..4f22549971 100644 --- a/src/tasks/test.ts +++ b/src/tasks/test.ts @@ -9,10 +9,11 @@ import * as Bluebird from "bluebird" import chalk from "chalk" import { PluginContext } from "../plugin-context" -import { Module, TestSpec } from "../types/module" +import { Module } from "../types/module" +import { TestConfig } from "../types/test" import { BuildTask } from "./build" import { DeployTask } from "./deploy" -import { TestResult } from "../types/plugin" +import { TestResult } from "../types/plugin/outputs" import { Task, TaskParams, TaskVersion } from "../types/task" import { EntryStyle } from "../logger/types" @@ -25,7 +26,7 @@ class TestError extends Error { export interface TestTaskParams extends TaskParams { ctx: PluginContext module: Module - testSpec: TestSpec + testConfig: TestConfig force: boolean forceBuild: boolean } @@ -35,7 +36,7 @@ export class TestTask extends Task { private ctx: PluginContext private module: Module - private testSpec: TestSpec + private testConfig: TestConfig private force: boolean private forceBuild: boolean @@ -43,7 +44,7 @@ export class TestTask extends Task { super(initArgs) this.ctx = initArgs.ctx this.module = initArgs.module - this.testSpec = initArgs.testSpec + this.testConfig = initArgs.testConfig this.force = initArgs.force this.forceBuild = initArgs.forceBuild } @@ -60,10 +61,11 @@ export class TestTask extends Task { return [] } - const services = await this.ctx.getServices(this.testSpec.dependencies) + const services = await this.ctx.getServices(this.testConfig.dependencies) const deps: Promise[] = [BuildTask.factory({ - ctx: this.ctx, module: this.module, + ctx: this.ctx, + module: this.module, force: this.forceBuild, })] @@ -80,11 +82,11 @@ export class TestTask extends Task { } getName() { - return `${this.module.name}.${this.testSpec.name}` + return `${this.module.name}.${this.testConfig.name}` } getDescription() { - return `running ${this.testSpec.name} tests in module ${this.module.name}` + return `running ${this.testConfig.name} tests in module ${this.module.name}` } async process(): Promise { @@ -95,7 +97,7 @@ export class TestTask extends Task { if (testResult && testResult.success) { const passedEntry = this.ctx.log.info({ section: this.module.name, - msg: `${this.testSpec.name} tests`, + msg: `${this.testConfig.name} tests`, }) passedEntry.setSuccess({ msg: chalk.green("Already passed"), append: true }) return testResult @@ -104,19 +106,19 @@ export class TestTask extends Task { const entry = this.ctx.log.info({ section: this.module.name, - msg: `Running ${this.testSpec.name} tests`, + msg: `Running ${this.testConfig.name} tests`, entryStyle: EntryStyle.activity, }) - const dependencies = await this.ctx.getServices(this.testSpec.dependencies) + const dependencies = await this.ctx.getServices(this.testConfig.dependencies) const runtimeContext = await this.module.prepareRuntimeContext(dependencies) const result = await this.ctx.testModule({ interactive: false, - module: this.module, + moduleName: this.module.name, runtimeContext, silent: true, - testSpec: this.testSpec, + testConfig: this.testConfig, }) if (result.success) { @@ -134,7 +136,11 @@ export class TestTask extends Task { return null } - const testResult = await this.ctx.getTestResult(this.module, this.testSpec.name, await this.module.getVersion()) + const testResult = await this.ctx.getTestResult({ + moduleName: this.module.name, + testName: this.testConfig.name, + version: await this.module.getVersion(), + }) return testResult && testResult.success && testResult } } diff --git a/src/template-string.ts b/src/template-string.ts index f8a73720df..a9b8313b40 100644 --- a/src/template-string.ts +++ b/src/template-string.ts @@ -15,7 +15,7 @@ import * as deepMap from "deep-map" import { GardenError } from "./exceptions" export type StringOrStringPromise = Promise | string -export type KeyResolver = (keyParts: string[]) => StringOrStringPromise +export type KeyResolver = (keyParts: string[]) => Promise | string | undefined export interface TemplateStringContext { [type: string]: Primitive | KeyResolver | TemplateStringContext | undefined @@ -76,6 +76,15 @@ export async function resolveTemplateString( } export function genericResolver(context: TemplateStringContext, ignoreMissingKeys = false): KeyResolver { + function pathOrError(path) { + if (ignoreMissingKeys) { + // return the format string unchanged if option is set + return `\$\{${path}\}` + } else { + throw new TemplateStringError(`Could not find key: ${path}`, { path, context }) + } + } + return (parts: string[]) => { const path = parts.join(".") let value @@ -87,15 +96,11 @@ export function genericResolver(context: TemplateStringContext, ignoreMissingKey switch (typeof value) { case "function": // pass the rest of the key parts to the resolver function - return value(parts.slice(p + 1)) + const resolvedValue = value(parts.slice(p + 1)) + return resolvedValue === undefined ? pathOrError(path) : resolvedValue case "undefined": - if (ignoreMissingKeys) { - // return the format string unchanged if option is set - return `\$\{${path}\}` - } else { - throw new TemplateStringError(`Could not find key: ${path}`, { path, context }) - } + return pathOrError(path) } } diff --git a/src/types/common.ts b/src/types/common.ts index 1add686594..1ac751e94c 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -18,6 +18,10 @@ export type Primitive = string | number | boolean export interface PrimitiveMap { [key: string]: Primitive } export interface DeepPrimitiveMap { [key: string]: Primitive | DeepPrimitiveMap } +// export type ConfigWithSpec = { +// spec: Omit & Partial +// } + export const enumToArray = Enum => ( Object.values(Enum).filter(k => typeof k === "string") as string[] ) @@ -34,7 +38,9 @@ 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 joiIdentifierMap = (valueSchema: JoiObject) => Joi + .object().pattern(identifierRegex, valueSchema) + .default(() => ({}), "{}") export const joiVariables = () => Joi .object().pattern(/[\w\d]+/i, joiPrimitive()) diff --git a/src/types/config.ts b/src/types/config.ts index 3a4249a14e..0c53dbcb0f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -11,13 +11,14 @@ import { findByName, getNames, } from "../util" -import { baseModuleSchema, ModuleConfig } from "./module" +import { baseModuleSpecSchema, ModuleConfig } from "./module" import { joiIdentifier, validate } from "./common" import { ConfigurationError } from "../exceptions" import * as Joi from "joi" import * as yaml from "js-yaml" import { readFileSync } from "fs" import { defaultEnvironments, ProjectConfig, projectSchema } from "./project" +import { omit } from "lodash" const CONFIG_FILENAME = "garden.yml" @@ -35,17 +36,19 @@ export const configSchema = Joi.object() version: Joi.string().default("0").only("0"), dirname: Joi.string(), path: Joi.string(), - module: baseModuleSchema, + module: baseModuleSpecSchema, project: projectSchema, }) .optionalKeys(["module", "project"]) .required() +const baseModuleSchemaKeys = Object.keys(baseModuleSpecSchema.describe().children) + 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 - let config + let spec: any try { fileData = readFileSync(absPath) @@ -54,12 +57,12 @@ export async function loadConfig(projectRoot: string, path: string): Promiseyaml.safeLoad(fileData) || {} + spec = yaml.safeLoad(fileData) || {} } catch (err) { throw new ConfigurationError(`Could not parse ${CONFIG_FILENAME} in directory ${path} as valid YAML`, err) } - if (config.module) { + if (spec.module) { /* We allow specifying modules by name only as a shorthand: @@ -67,19 +70,17 @@ export async function loadConfig(projectRoot: string, path: string): Promise (typeof dep) === "string" ? { name: dep } : dep) } } - const parsed = validate(config, configSchema, { context: relative(projectRoot, absPath) }) + const parsed = validate(spec, configSchema, { context: relative(projectRoot, absPath) }) + const dirname = parse(absPath).dir.split(sep).slice(-1)[0] const project = parsed.project - const module = parsed.module - - parsed.dirname = parse(absPath).dir.split(sep).slice(-1)[0] - parsed.path = path + let module = parsed.module if (project) { // we include the default local environment unless explicitly overridden @@ -105,11 +106,21 @@ export async function loadConfig(projectRoot: string, path: string): Promise [], "[]"), +}) + export interface BuildConfig { // TODO: this should be a string array, to match other command specs command?: string, dependencies: BuildDependencyConfig[], } -const serviceOutputsSchema = Joi.object().pattern(/.+/, joiPrimitive()) - -export interface TestSpec { - name: string - command: string[] - dependencies: string[] - variables: PrimitiveMap - timeout?: number -} - -export const baseTestSpecSchema = Joi.object().keys({ - name: joiIdentifier().required(), - command: Joi.array().items(Joi.string()).required(), - dependencies: Joi.array().items(Joi.string()).default(() => [], "[]"), - variables: joiVariables(), - timeout: Joi.number(), -}) - const versionFileSchema = Joi.object().keys({ versionString: Joi.string().required(), latestCommit: Joi.string().required(), dirtyTimestamp: Joi.number().allow(null).required(), }) -export interface ModuleConfig { +export interface ModuleSpec { } + +export interface BaseModuleSpec { allowPush: boolean build: BuildConfig description?: string name: string path: string - services: T[] - test: TestSpec[] type: string variables: PrimitiveMap } -export const baseServiceSchema = Joi.object() - .keys({ - dependencies: Joi.array().items((joiIdentifier())).default(() => [], "[]"), - }) - .options({ allowUnknown: true }) - -export const baseDependencySchema = Joi.object().keys({ - name: joiIdentifier().required(), - copy: Joi.array().items(copySchema).default(() => [], "[]"), -}) - -export const baseModuleSchema = Joi.object().keys({ +export const baseModuleSpecSchema = Joi.object().keys({ type: joiIdentifier().required(), name: joiIdentifier(), description: Joi.string(), variables: joiVariables(), - services: joiArray(baseServiceSchema).unique("name"), allowPush: Joi.boolean() .default(true, "Set to false to disable pushing this module to remote registries"), build: Joi.object().keys({ command: Joi.string(), - dependencies: Joi.array().items(baseDependencySchema).default(() => [], "[]"), + dependencies: Joi.array().items(buildDependencySchema).default(() => [], "[]"), }).default(() => ({ dependencies: [] }), "{}"), - test: joiArray(baseTestSpecSchema).unique("name"), }).required().unknown(true) -export class Module { - public name: string - public type: string - public path: string - public services: T["services"] +export interface ModuleConfig extends BaseModuleSpec { + // Plugins can add custom fields that are kept here + spec: T +} + +export const moduleConfigSchema = baseModuleSpecSchema.keys({ + spec: Joi.object(), +}) + +export interface ModuleConstructor< + M extends ModuleSpec = ModuleSpec, + S extends ServiceSpec = ServiceSpec, + T extends TestSpec = TestSpec, + > { + new(ctx: PluginContext, config: ModuleConfig, serviceConfigs: ServiceConfig[], testConfigs: TestConfig[]) + : Module, +} + +export class Module< + M extends ModuleSpec = any, + S extends ServiceSpec = any, + T extends TestSpec = any, + > { + public readonly name: string + public readonly type: string + public readonly path: string + + public readonly spec: M + public readonly services: ServiceConfig[] + public readonly tests: TestConfig[] private _buildDependencies: Module[] - _ConfigType: T + readonly _ConfigType: ModuleConfig - constructor(private ctx: PluginContext, public config: T) { + constructor( + private ctx: PluginContext, + public config: ModuleConfig, + serviceConfigs: ServiceConfig[], + testConfigs: TestConfig[], + ) { + this.config = config + this.spec = config.spec this.name = config.name this.type = config.type this.path = config.path - this.services = config.services + this.services = serviceConfigs + this.tests = testConfigs } - async resolveConfig(context?: TemplateStringContext) { + async resolveConfig(context?: TemplateStringContext): Promise> { // TODO: allow referencing other module configs (non-trivial, need to save for later) - const templateContext = await this.ctx.getTemplateContext(context) - const config = extend({}, this.config) + const runtimeContext = await this.prepareRuntimeContext([]) + const templateContext = await this.ctx.getTemplateContext({ + ...context, + ...runtimeContext, + }) + const config = { ...this.config } config.build = await resolveTemplateStrings(config.build, templateContext) - config.test = await Bluebird.map(config.test, t => resolveTemplateStrings(t, templateContext)) + config.spec = await resolveTemplateStrings(config.spec, templateContext, { ignoreMissingKeys: true }) config.variables = await resolveTemplateStrings(config.variables, templateContext) - const cls = Object.getPrototypeOf(this).constructor - return new cls(this.ctx, config) + const services = await resolveTemplateStrings(this.services, templateContext, { ignoreMissingKeys: true }) + const tests = await resolveTemplateStrings(this.tests, templateContext, { ignoreMissingKeys: true }) + + const cls = Object.getPrototypeOf(this).constructor + return new cls(this.ctx, config, services, tests) } updateConfig(key: string, value: any) { @@ -201,7 +220,7 @@ export class Module { } async getBuildPath() { - return await this.ctx.getModuleBuildPath(this) + return await this.ctx.getModuleBuildPath(this.name) } async getBuildDependencies(): Promise { @@ -213,8 +232,9 @@ export class Module { const modules = keyBy(await this.ctx.getModules(), "name") const deps: Module[] = [] - for (let dependencyConfig of this.config.build.dependencies) { - const dependencyName = dependencyConfig.name + for (let dep of this.config.build.dependencies) { + // TODO: find a more elegant way of dealing with plugin module dependencies + const dependencyName = dep.plugin ? `${dep.plugin}--${dep.name}` : dep.name const dependency = modules[dependencyName] if (!dependency) { @@ -253,14 +273,14 @@ export class Module { ) { const tasks: Promise[] = [] - for (const test of this.config.test) { + for (const test of this.tests) { if (group && test.name !== group) { continue } tasks.push(TestTask.factory({ force, forceBuild, - testSpec: test, + testConfig: test, ctx: this.ctx, module: this, })) @@ -269,7 +289,10 @@ export class Module { return Bluebird.all(tasks) } - async prepareRuntimeContext(dependencies: Service[], extraEnvVars: PrimitiveMap = {}): Promise { + async prepareRuntimeContext( + serviceDependencies: Service[], extraEnvVars: PrimitiveMap = {}, + ): Promise { + const buildDependencies = await this.getBuildDependencies() const { versionString } = await this.getVersion() const envVars = { GARDEN_VERSION: versionString, @@ -293,13 +316,23 @@ export class Module { const deps = {} - for (const dep of dependencies) { - const depContext = deps[dep.name] = { - version: versionString, + for (const module of buildDependencies) { + deps[module.name] = { + version: (await module.getVersion()).versionString, outputs: {}, } + } - const outputs = await this.ctx.getServiceOutputs(dep) + for (const dep of serviceDependencies) { + if (!deps[dep.name]) { + deps[dep.name] = { + version: (await dep.module.getVersion()).versionString, + outputs: {}, + } + } + const depContext = deps[dep.name] + + const outputs = await this.ctx.getServiceOutputs({ serviceName: dep.name }) const serviceEnvName = dep.getEnvVarName() validate(outputs, serviceOutputsSchema, { context: `outputs for service ${dep.name}` }) @@ -312,8 +345,16 @@ export class Module { } } - return { envVars, dependencies: deps } + return { + envVars, + dependencies: deps, + module: { + name: this.name, + type: this.type, + version: versionString, + }, + } } } -export type ModuleConfigType = T["_ConfigType"] +export type ModuleConfigType = M["_ConfigType"] diff --git a/src/types/plugin.ts b/src/types/plugin.ts deleted file mode 100644 index 53d676f3b0..0000000000 --- a/src/types/plugin.ts +++ /dev/null @@ -1,338 +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 * as Joi from "joi" -import { PluginContext } from "../plugin-context" -import { Module, TestSpec } from "./module" -import { - Environment, - joiIdentifier, - joiIdentifierMap, - Primitive, - PrimitiveMap, -} from "./common" -import { Service, RuntimeContext, 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" - -// TODO: split this module up - -export interface Provider { - name: string - config: T -} - -export interface PluginActionParamsBase { - ctx: PluginContext - env: Environment - provider: Provider - logEntry?: LogEntry -} - -export interface ParseModuleParams extends PluginActionParamsBase { - moduleConfig: T["_ConfigType"] -} - -export interface GetEnvironmentStatusParams extends PluginActionParamsBase { } - -export interface ConfigureEnvironmentParams extends PluginActionParamsBase { - status: EnvironmentStatus -} - -export interface DestroyEnvironmentParams extends PluginActionParamsBase { } - -export interface GetConfigParams extends PluginActionParamsBase { - key: string[] -} - -export interface SetConfigParams extends PluginActionParamsBase { - key: string[] - value: Primitive -} - -export interface DeleteConfigParams extends PluginActionParamsBase { - key: string[] -} - -export interface PluginActionParams { - getEnvironmentStatus: GetEnvironmentStatusParams - configureEnvironment: ConfigureEnvironmentParams - destroyEnvironment: DestroyEnvironmentParams - - getConfig: GetConfigParams - setConfig: SetConfigParams - deleteConfig: DeleteConfigParams - - getLoginStatus: PluginActionParamsBase - login: PluginActionParamsBase - logout: PluginActionParamsBase -} - -export interface GetModuleBuildStatusParams extends PluginActionParamsBase { - module: T -} - -export interface BuildModuleParams extends PluginActionParamsBase { - module: T - buildContext: PrimitiveMap -} - -export interface PushModuleParams extends PluginActionParamsBase { - module: T -} - -export interface RunModuleParams extends PluginActionParamsBase { - module: T - command: string[] - interactive: boolean - runtimeContext: RuntimeContext - silent: boolean - timeout?: number -} - -export interface TestModuleParams extends PluginActionParamsBase { - module: T - interactive: boolean - runtimeContext: RuntimeContext - silent: boolean - testSpec: TestSpec -} - -export interface GetTestResultParams extends PluginActionParamsBase { - module: T - testName: string - version: TreeVersion -} - -export interface GetServiceStatusParams extends PluginActionParamsBase { - service: Service, -} - -export interface DeployServiceParams extends PluginActionParamsBase { - service: Service, - runtimeContext: RuntimeContext, -} - -export interface GetServiceOutputsParams extends PluginActionParamsBase { - service: Service, -} - -export interface ExecInServiceParams extends PluginActionParamsBase { - service: Service, - command: string[], -} - -export interface GetServiceLogsParams extends PluginActionParamsBase { - service: Service, - stream: Stream, - tail?: boolean, - startTime?: Date, -} - -export interface RunServiceParams extends PluginActionParamsBase { - service: Service - interactive: boolean - runtimeContext: RuntimeContext - silent: boolean - timeout?: number -} - -export interface ModuleActionParams { - parseModule: ParseModuleParams - getModuleBuildStatus: GetModuleBuildStatusParams - buildModule: BuildModuleParams - pushModule: PushModuleParams - runModule: RunModuleParams - testModule: TestModuleParams - getTestResult: GetTestResultParams - - getServiceStatus: GetServiceStatusParams - deployService: DeployServiceParams - getServiceOutputs: GetServiceOutputsParams - execInService: ExecInServiceParams - getServiceLogs: GetServiceLogsParams - runService: RunServiceParams -} - -export interface BuildResult { - buildLog?: string - fetched?: boolean - fresh?: boolean - version?: string - details?: any -} - -export interface PushResult { - pushed: boolean - message?: string -} - -export interface RunResult { - moduleName: string - command: string[] - version: TreeVersion - success: boolean - startedAt: Moment | Date - completedAt: Moment | Date - output: string -} - -export interface TestResult extends RunResult { - testName: string -} - -export interface BuildStatus { - ready: boolean -} - -export interface EnvironmentStatus { - configured: boolean - detail?: any -} - -export type EnvironmentStatusMap = { - [key: string]: EnvironmentStatus, -} - -export interface ExecInServiceResult { - code: number - output: string - stdout?: string - stderr?: string -} - -export interface ServiceLogEntry { - serviceName: string - timestamp: Moment | Date - msg: string -} - -export interface DeleteConfigResult { - found: boolean -} - -export interface LoginStatus { - loggedIn: boolean -} - -export interface LoginStatusMap { - [key: string]: LoginStatus, -} - -export interface PluginActionOutputs { - getEnvironmentStatus: Promise - configureEnvironment: Promise - destroyEnvironment: Promise - - getConfig: Promise - setConfig: Promise - deleteConfig: Promise - - getLoginStatus: Promise - login: Promise - logout: Promise -} - -export interface ModuleActionOutputs { - parseModule: Promise - getModuleBuildStatus: Promise - buildModule: Promise - pushModule: Promise - runModule: Promise - testModule: Promise - getTestResult: Promise - - getServiceStatus: Promise - deployService: Promise // TODO: specify - getServiceOutputs: Promise - execInService: Promise - getServiceLogs: Promise - runService: Promise -} - -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 ModuleActionName = keyof ModuleActions - -interface PluginActionDescription { - description?: string -} - -const pluginActionDescriptions: { [P in PluginActionName]: PluginActionDescription } = { - getEnvironmentStatus: {}, - configureEnvironment: {}, - destroyEnvironment: {}, - - getConfig: {}, - setConfig: {}, - deleteConfig: {}, - - getLoginStatus: {}, - login: {}, - logout: {}, -} - -const moduleActionDescriptions: { [P in ModuleActionName]: PluginActionDescription } = { - parseModule: {}, - getModuleBuildStatus: {}, - buildModule: {}, - pushModule: {}, - runModule: {}, - testModule: {}, - getTestResult: {}, - - getServiceStatus: {}, - deployService: {}, - getServiceOutputs: {}, - execInService: {}, - getServiceLogs: {}, - runService: {}, -} - -export const pluginActionNames: PluginActionName[] = Object.keys(pluginActionDescriptions) -export const moduleActionNames: ModuleActionName[] = Object.keys(moduleActionDescriptions) - -export interface GardenPlugin { - config?: object - configKeys?: string[] - - modules?: string[] - - actions?: Partial - moduleActions?: { [moduleType: string]: Partial> } -} - -export interface PluginFactory { - ({ config: object, logEntry: LogEntry }): GardenPlugin - pluginName?: string -} -export type RegisterPluginParam = string | PluginFactory - -export const pluginSchema = Joi.object().keys({ - config: Joi.object(), - modules: Joi.array().items(Joi.string()), - 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/plugin/index.ts b/src/types/plugin/index.ts new file mode 100644 index 0000000000..6ef4b06269 --- /dev/null +++ b/src/types/plugin/index.ts @@ -0,0 +1,186 @@ +/* + * 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 * as Joi from "joi" +import { mapValues } from "lodash" +import { + DeepPrimitiveMap, + joiIdentifier, + joiIdentifierMap, + PrimitiveMap, +} from "../common" +import { Module } from "../module" +import { + serviceOutputsSchema, + serviceStatusSchema, +} from "../service" +import { + buildModuleResultSchema, + buildStatusSchema, + configureEnvironmentResultSchema, + deleteConfigResultSchema, + destroyEnvironmentResultSchema, + environmentStatusSchema, + execInServiceResultSchema, + getConfigResultSchema, + getServiceLogsResultSchema, + getTestResultSchema, + loginStatusSchema, + ModuleActionOutputs, + parseModuleResultSchema, + PluginActionOutputs, + pushModuleResultSchema, + runResultSchema, + ServiceActionOutputs, + setConfigResultSchema, + testResultSchema, +} from "./outputs" +import { + ModuleActionParams, + PluginActionParams, + ServiceActionParams, +} from "./params" + +export interface Provider extends DeepPrimitiveMap { + name: string + config: T +} + +export type PluginActions = { + [P in keyof PluginActionParams]: (params: PluginActionParams[P]) => PluginActionOutputs[P] +} + +export type ServiceActions = { + [P in keyof ServiceActionParams]: (params: ServiceActionParams[P]) => ServiceActionOutputs[P] +} + +export type ModuleActions = { + [P in keyof ModuleActionParams]: (params: ModuleActionParams[P]) => ModuleActionOutputs[P] +} + +export type PluginActionName = keyof PluginActions +export type ServiceActionName = keyof ServiceActions +export type ModuleActionName = keyof ModuleActions + +export interface PluginActionDescription { + description?: string + resultSchema: Joi.Schema, +} + +export const pluginActionDescriptions: { [P in PluginActionName]: PluginActionDescription } = { + getEnvironmentStatus: { + resultSchema: environmentStatusSchema, + }, + configureEnvironment: { + resultSchema: configureEnvironmentResultSchema, + }, + destroyEnvironment: { + resultSchema: destroyEnvironmentResultSchema, + }, + + getConfig: { + resultSchema: getConfigResultSchema, + }, + setConfig: { + resultSchema: setConfigResultSchema, + }, + deleteConfig: { + resultSchema: deleteConfigResultSchema, + }, + + getLoginStatus: { + resultSchema: loginStatusSchema, + }, + login: { + resultSchema: loginStatusSchema, + }, + logout: { + resultSchema: loginStatusSchema, + }, +} + +export const serviceActionDescriptions: { [P in ServiceActionName]: PluginActionDescription } = { + getServiceStatus: { + resultSchema: serviceStatusSchema, + }, + deployService: { + resultSchema: serviceStatusSchema, + }, + getServiceOutputs: { + resultSchema: serviceOutputsSchema, + }, + execInService: { + resultSchema: execInServiceResultSchema, + }, + getServiceLogs: { + resultSchema: getServiceLogsResultSchema, + }, + runService: { + resultSchema: runResultSchema, + }, +} + +export const moduleActionDescriptions: { [P in ModuleActionName]: PluginActionDescription } = { + parseModule: { + resultSchema: parseModuleResultSchema, + }, + getModuleBuildStatus: { + resultSchema: buildStatusSchema, + }, + buildModule: { + resultSchema: buildModuleResultSchema, + }, + pushModule: { + resultSchema: pushModuleResultSchema, + }, + runModule: { + resultSchema: runResultSchema, + }, + testModule: { + resultSchema: testResultSchema, + }, + getTestResult: { + resultSchema: getTestResultSchema, + }, + + ...serviceActionDescriptions, +} + +export const pluginActionNames: PluginActionName[] = Object.keys(pluginActionDescriptions) +export const serviceActionNames: ServiceActionName[] = Object.keys(serviceActionDescriptions) +export const moduleActionNames: ModuleActionName[] = Object.keys(moduleActionDescriptions) + +export interface GardenPlugin { + config?: object + configKeys?: string[] + + modules?: string[] + + actions?: Partial + moduleActions?: { [moduleType: string]: Partial } +} + +export interface PluginFactory { + ({ config: object, logEntry: LogEntry }): GardenPlugin + pluginName?: string +} +export type RegisterPluginParam = string | PluginFactory + +export const pluginSchema = Joi.object().keys({ + config: Joi.object(), + modules: Joi.array().items(Joi.string()), + 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/plugin/outputs.ts b/src/types/plugin/outputs.ts new file mode 100644 index 0000000000..f0cb8ce49a --- /dev/null +++ b/src/types/plugin/outputs.ts @@ -0,0 +1,222 @@ +/* + * 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 * as Joi from "joi" +import { TreeVersion } from "../../vcs/base" +import { + joiArray, + PrimitiveMap, +} from "../common" +import { + Module, + moduleConfigSchema, +} from "../module" +import { + serviceConfigSchema, + ServiceStatus, +} from "../service" +import { testConfigSchema } from "../test" + +export interface EnvironmentStatus { + configured: boolean + detail?: any +} + +export const environmentStatusSchema = Joi.object().keys({ + configured: Joi.boolean().required(), + detail: Joi.object(), +}) + +export type EnvironmentStatusMap = { + [key: string]: EnvironmentStatus, +} + +export interface ConfigureEnvironmentResult { } + +export const configureEnvironmentResultSchema = Joi.object().keys({}) + +export interface DestroyEnvironmentResult { } + +export const destroyEnvironmentResultSchema = Joi.object().keys({}) + +export interface GetConfigResult { + value: string | null +} + +export const getConfigResultSchema = Joi.object().keys({ + value: Joi.string().allow(null).required(), +}) + +export interface SetConfigResult { } + +export const setConfigResultSchema = Joi.object().keys({}) + +export interface DeleteConfigResult { + found: boolean +} + +export const deleteConfigResultSchema = Joi.object().keys({ + found: Joi.boolean().required(), +}) + +export interface LoginStatus { + loggedIn: boolean +} + +export const loginStatusSchema = Joi.object().keys({ + loggedIn: Joi.boolean().required(), +}) + +export interface LoginStatusMap { + [key: string]: LoginStatus, +} + +export interface ExecInServiceResult { + code: number + output: string + stdout?: string + stderr?: string +} + +export const execInServiceResultSchema = Joi.object().keys({ + code: Joi.number().required(), + output: Joi.string().required(), + stdout: Joi.string(), + stderr: Joi.string(), +}) + +export interface ServiceLogEntry { + serviceName: string + timestamp: Date + msg: string +} + +export const serviceLogEntrySchema = Joi.object().keys({ + serviceName: Joi.string().required(), + timestamp: Joi.date().required(), + msg: Joi.string().required(), +}) + +export interface GetServiceLogsResult { } + +export const getServiceLogsResultSchema = Joi.object().keys({}) + +export interface ParseModuleResult { + module: T["config"] + services: T["services"] + tests: T["tests"] +} + +export const parseModuleResultSchema = Joi.object().keys({ + module: moduleConfigSchema.required(), + services: joiArray(serviceConfigSchema).required(), + tests: joiArray(testConfigSchema).required(), +}) + +export interface BuildResult { + buildLog?: string + fetched?: boolean + fresh?: boolean + version?: string + details?: any +} + +export const buildModuleResultSchema = Joi.object().keys({ + buildLog: Joi.string(), + fetched: Joi.boolean(), + fresh: Joi.boolean(), + version: Joi.string(), + details: Joi.object(), +}) + +export interface PushResult { + pushed: boolean + message?: string +} + +export const pushModuleResultSchema = Joi.object().keys({ + pushed: Joi.boolean().required(), + message: Joi.string(), +}) + +export interface RunResult { + moduleName: string + command: string[] + version: TreeVersion + success: boolean + startedAt: Date + completedAt: Date + output: string +} + +export const treeVersionSchema = Joi.object().keys({ + versionString: Joi.string().required(), + latestCommit: Joi.string().required(), + dirtyTimestamp: Joi.number().allow(null).required(), +}) + +export const runResultSchema = Joi.object().keys({ + moduleName: Joi.string(), + command: Joi.array().items(Joi.string()).required(), + version: treeVersionSchema, + success: Joi.boolean().required(), + startedAt: Joi.date().required(), + completedAt: Joi.date().required(), + output: Joi.string().required(), +}) + +export interface TestResult extends RunResult { + testName: string +} + +export const testResultSchema = runResultSchema.keys({ + testName: Joi.string().required(), +}) + +export const getTestResultSchema = testResultSchema.allow(null) + +export interface BuildStatus { + ready: boolean +} + +export const buildStatusSchema = Joi.object().keys({ + ready: Joi.boolean().required(), +}) + +export interface PluginActionOutputs { + getEnvironmentStatus: Promise + configureEnvironment: Promise + destroyEnvironment: Promise + + getConfig: Promise + setConfig: Promise + deleteConfig: Promise + + getLoginStatus: Promise + login: Promise + logout: Promise +} + +export interface ServiceActionOutputs { + getServiceStatus: Promise + deployService: Promise + getServiceOutputs: Promise + execInService: Promise + getServiceLogs: Promise<{}> + runService: Promise +} + +export interface ModuleActionOutputs extends ServiceActionOutputs { + parseModule: Promise + getModuleBuildStatus: Promise + buildModule: Promise + pushModule: Promise + runModule: Promise + testModule: Promise + getTestResult: Promise +} diff --git a/src/types/plugin/params.ts b/src/types/plugin/params.ts new file mode 100644 index 0000000000..d099bc6ca3 --- /dev/null +++ b/src/types/plugin/params.ts @@ -0,0 +1,164 @@ +/* + * 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 Stream from "ts-stream" +import { LogEntry } from "../../logger" +import { PluginContext } from "../../plugin-context" +import { TreeVersion } from "../../vcs/base" +import { + Environment, + Primitive, +} from "../common" +import { Module } from "../module" +import { + RuntimeContext, + Service, +} from "../service" +import { + Provider, +} from "./index" +import { + EnvironmentStatus, + ServiceLogEntry, +} from "./outputs" + +export interface PluginActionContextParams { + ctx: PluginContext + env: Environment + provider: Provider +} + +export interface PluginActionParamsBase extends PluginActionContextParams { + logEntry?: LogEntry +} + +export interface PluginModuleActionParamsBase extends PluginActionParamsBase { + module: T +} + +export interface PluginServiceActionParamsBase extends PluginModuleActionParamsBase { + service: Service + runtimeContext?: RuntimeContext +} + +export interface ParseModuleParams extends PluginActionParamsBase { + module?: T + moduleConfig: T["_ConfigType"] +} + +export interface GetEnvironmentStatusParams extends PluginActionParamsBase { +} + +export interface ConfigureEnvironmentParams extends PluginActionParamsBase { + status: EnvironmentStatus +} + +export interface DestroyEnvironmentParams extends PluginActionParamsBase { +} + +export interface GetConfigParams extends PluginActionParamsBase { + key: string[] +} + +export interface SetConfigParams extends PluginActionParamsBase { + key: string[] + value: Primitive +} + +export interface DeleteConfigParams extends PluginActionParamsBase { + key: string[] +} + +export interface PluginActionParams { + getEnvironmentStatus: GetEnvironmentStatusParams + configureEnvironment: ConfigureEnvironmentParams + destroyEnvironment: DestroyEnvironmentParams + + getConfig: GetConfigParams + setConfig: SetConfigParams + deleteConfig: DeleteConfigParams + + getLoginStatus: PluginActionParamsBase + login: PluginActionParamsBase + logout: PluginActionParamsBase +} + +export interface GetModuleBuildStatusParams extends PluginModuleActionParamsBase { +} + +export interface BuildModuleParams extends PluginModuleActionParamsBase { +} + +export interface PushModuleParams extends PluginModuleActionParamsBase { +} + +export interface RunModuleParams extends PluginModuleActionParamsBase { + command: string[] + interactive: boolean + runtimeContext: RuntimeContext + silent: boolean + timeout?: number +} + +export interface TestModuleParams extends PluginModuleActionParamsBase { + interactive: boolean + runtimeContext: RuntimeContext + silent: boolean + testConfig: T["tests"][0] +} + +export interface GetTestResultParams extends PluginModuleActionParamsBase { + testName: string + version: TreeVersion +} + +export interface GetServiceStatusParams extends PluginServiceActionParamsBase { +} + +export interface DeployServiceParams extends PluginServiceActionParamsBase { + runtimeContext: RuntimeContext, +} + +export interface GetServiceOutputsParams extends PluginServiceActionParamsBase { +} + +export interface ExecInServiceParams extends PluginServiceActionParamsBase { + command: string[], +} + +export interface GetServiceLogsParams extends PluginServiceActionParamsBase { + stream: Stream, + tail?: boolean, + startTime?: Date, +} + +export interface RunServiceParams extends PluginServiceActionParamsBase { + interactive: boolean + runtimeContext: RuntimeContext + silent: boolean + timeout?: number +} + +export interface ServiceActionParams { + getServiceStatus: GetServiceStatusParams + deployService: DeployServiceParams + getServiceOutputs: GetServiceOutputsParams + execInService: ExecInServiceParams + getServiceLogs: GetServiceLogsParams + runService: RunServiceParams +} + +export interface ModuleActionParams extends ServiceActionParams { + parseModule: ParseModuleParams + getModuleBuildStatus: GetModuleBuildStatusParams + buildModule: BuildModuleParams + pushModule: PushModuleParams + runModule: RunModuleParams + testModule: TestModuleParams + getTestResult: GetTestResultParams +} diff --git a/src/types/service.ts b/src/types/service.ts index 411d56e6a9..5a745fdca3 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -7,12 +7,23 @@ */ import Bluebird = require("bluebird") +import * as Joi from "joi" +import { ConfigurationError } from "../exceptions" import { PluginContext } from "../plugin-context" +import { + resolveTemplateStrings, + TemplateOpts, + TemplateStringContext, +} from "../template-string" import { findByName } from "../util" +import { + joiArray, + joiIdentifier, + joiIdentifierMap, + joiPrimitive, + PrimitiveMap, +} from "./common" import { Module } from "./module" -import { PrimitiveMap } from "./common" -import { ConfigurationError } from "../exceptions" -import { resolveTemplateStrings, TemplateOpts, TemplateStringContext } from "../template-string" export type ServiceState = "ready" | "deploying" | "stopped" | "unhealthy" @@ -26,11 +37,42 @@ export interface ServiceEndpoint { paths?: string[] } -export interface ServiceConfig { +export const serviceEndpointSchema = Joi.object().keys({ + protocol: Joi.string().only("http", "https", "tcp", "udp").required(), + hostname: Joi.string().required(), + port: Joi.number(), + url: Joi.string().required(), + paths: Joi.array().items(Joi.string()), +}) + +export interface ServiceSpec { } + +export interface BaseServiceSpec extends ServiceSpec { name: string dependencies: string[] + outputs: PrimitiveMap +} + +export const serviceOutputsSchema = joiIdentifierMap(joiPrimitive()) + +export const baseServiceSchema = Joi.object() + .keys({ + name: joiIdentifier().required(), + dependencies: joiArray(joiIdentifier()), + outputs: serviceOutputsSchema, + }) + .unknown(true) + +export interface ServiceConfig extends BaseServiceSpec { + // Plugins can add custom fields that are kept here + spec: T } +export const serviceConfigSchema = baseServiceSchema.keys({ + spec: Joi.object(), +}) + +// TODO: revise this schema export interface ServiceStatus { providerId?: string providerVersion?: string @@ -45,6 +87,20 @@ export interface ServiceStatus { detail?: any } +export const serviceStatusSchema = Joi.object().keys({ + providerId: Joi.string(), + providerVersion: Joi.string(), + version: Joi.string(), + state: Joi.string(), + runningReplicas: Joi.number(), + endpoints: Joi.array().items(serviceEndpointSchema), + lastMessage: Joi.string().allow(""), + lastError: Joi.string(), + createdAt: Joi.string(), + updatedAt: Joi.string(), + detail: Joi.object(), +}) + export type RuntimeContext = { envVars: PrimitiveMap dependencies: { @@ -53,13 +109,22 @@ export type RuntimeContext = { outputs: PrimitiveMap, }, }, + module: { + name: string, + type: string, + version: string, + }, } export class Service { + public spec: M["services"][0]["spec"] + constructor( protected ctx: PluginContext, public module: M, public name: string, public config: M["services"][0], - ) { } + ) { + this.spec = config.spec + } static async factory, M extends Module>( this: (new (ctx: PluginContext, module: M, name: string, config: S["config"]) => S), @@ -81,7 +146,7 @@ export class Service { */ async getDependencies(): Promise[]> { return Bluebird.map( - this.config.dependencies, + this.config.dependencies || [], async (depName: string) => await this.ctx.getService(depName), ) } diff --git a/src/types/test.ts b/src/types/test.ts new file mode 100644 index 0000000000..fa7210945c --- /dev/null +++ b/src/types/test.ts @@ -0,0 +1,39 @@ +/* + * 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 * as Joi from "joi" +import { + joiIdentifier, + joiVariables, + PrimitiveMap, +} from "./common" + +export interface TestSpec { } + +export interface BaseTestSpec extends TestSpec { + name: string + dependencies: string[] + variables: PrimitiveMap + timeout: number | null +} + +export const baseTestSpecSchema = Joi.object().keys({ + name: joiIdentifier().required(), + dependencies: Joi.array().items(Joi.string()).default(() => [], "[]"), + variables: joiVariables(), + timeout: Joi.number().allow(null).default(null), +}) + +export interface TestConfig extends BaseTestSpec { + // Plugins can add custom fields that are kept here + spec: T +} + +export const testConfigSchema = baseTestSpecSchema.keys({ + spec: Joi.object(), +}) diff --git a/src/util/detectCycles.ts b/src/util/detectCycles.ts index 7aa9623804..1844017e66 100644 --- a/src/util/detectCycles.ts +++ b/src/util/detectCycles.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { forEach, get, isEqual, join, set, uniqWith } from "lodash" -import { Module, ModuleConfig } from "../types/module" +import { get, isEqual, join, set, uniqWith } from "lodash" +import { Module } from "../types/module" import { ConfigurationError } from "../exceptions" +import { Service } from "../types/service" export type Cycle = string[] @@ -19,8 +20,7 @@ export type Cycle = string[] Throws an error if cycles were found. */ -export async function detectCircularDependencies(modules: Module[], serviceNames: string[]) { - +export async function detectCircularDependencies(modules: Module[], services: Service[]) { // Sparse matrices const buildGraph = {} const serviceGraph = {} @@ -30,23 +30,21 @@ export async function detectCircularDependencies(modules: Module[], serviceNames are accounted for via service dependencies. */ for (const module of modules) { - const conf: ModuleConfig = await module.getConfig() - // Build dependencies - for (const buildDep of get(conf, ["build", "dependencies"], [])) { + for (const buildDep of module.config.build.dependencies) { const depName = buildDep.name set(buildGraph, [module.name, depName], { distance: 1, next: depName }) } // Service dependencies - forEach(get(conf, ["services"], {}), (serviceConfig, serviceName) => { - for (const depName of get(serviceConfig, ["dependencies"], [])) { - set(serviceGraph, [serviceName, depName], { distance: 1, next: depName }) + for (const service of module.services || []) { + for (const depName of service.dependencies) { + set(serviceGraph, [service.name, depName], { distance: 1, next: depName }) } - }) - + } } + const serviceNames = services.map(s => s.name) const buildCycles = detectCycles(buildGraph, modules.map(m => m.name)) const serviceCycles = detectCycles(serviceGraph, serviceNames) diff --git a/src/util/index.ts b/src/util/index.ts index 6298002fcc..f232bf3ca3 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -39,6 +39,7 @@ export type HookCallback = (callback?: () => void) => void const exitHookNames: string[] = [] // For debugging/testing/inspection purposes export type Omit = Pick> +export type Diff = T extends U ? never : T export type Nullable = { [P in keyof T]: T[P] | null } export function shutdown(code) { diff --git a/static/kubernetes/system/kubernetes-dashboard/garden.yml b/static/kubernetes/system/kubernetes-dashboard/garden.yml index 90d903ce87..fe51f76d14 100644 --- a/static/kubernetes/system/kubernetes-dashboard/garden.yml +++ b/static/kubernetes/system/kubernetes-dashboard/garden.yml @@ -2,145 +2,143 @@ module: description: Kubernetes dashboard configuration name: kubernetes-dashboard type: kubernetes-specs - services: - - name: kubernetes-dashboard - specs: - # ------------------- Dashboard Secret ------------------- # - - apiVersion: v1 - kind: Secret - metadata: - labels: - k8s-app: kubernetes-dashboard - name: kubernetes-dashboard-certs - namespace: garden-system - type: Opaque - # ------------------- Dashboard Service Account ------------------- # - - apiVersion: v1 - kind: ServiceAccount - metadata: - labels: - k8s-app: kubernetes-dashboard + specs: + # ------------------- Dashboard Secret ------------------- # + - apiVersion: v1 + kind: Secret + metadata: + labels: + k8s-app: kubernetes-dashboard + name: kubernetes-dashboard-certs + namespace: garden-system + type: Opaque + # ------------------- Dashboard Service Account ------------------- # + - apiVersion: v1 + kind: ServiceAccount + metadata: + labels: + k8s-app: kubernetes-dashboard + name: kubernetes-dashboard + namespace: garden-system + # ------------------- Dashboard Role & Role Binding ------------------- # + - kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + labels: + k8s-app: kubernetes-dashboard + name: kubernetes-dashboard-minimal + namespace: garden-system + rules: + # Allow Dashboard to create 'kubernetes-dashboard-key-holder' secret. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + # Allow Dashboard to create 'kubernetes-dashboard-settings' config map. + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] + # Allow Dashboard to get, update and delete Dashboard exclusive secrets. + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["kubernetes-dashboard-key-holder", "kubernetes-dashboard-certs"] + verbs: ["get", "update", "delete"] + # Allow Dashboard to get and update 'kubernetes-dashboard-settings' config map. + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["kubernetes-dashboard-settings"] + verbs: ["get", "update"] + # Allow Dashboard to get metrics from heapster. + - apiGroups: [""] + resources: ["services"] + resourceNames: ["heapster"] + verbs: ["proxy"] + - apiGroups: [""] + resources: ["services/proxy"] + resourceNames: ["heapster", "http:heapster:", "https:heapster:"] + verbs: ["get"] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + labels: + k8s-app: kubernetes-dashboard + name: kubernetes-dashboard-minimal + namespace: garden-system + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubernetes-dashboard-minimal + subjects: + - kind: ServiceAccount name: kubernetes-dashboard namespace: garden-system - # ------------------- Dashboard Role & Role Binding ------------------- # - - kind: Role - apiVersion: rbac.authorization.k8s.io/v1 - metadata: - labels: - k8s-app: kubernetes-dashboard - name: kubernetes-dashboard-minimal - namespace: garden-system - rules: - # Allow Dashboard to create 'kubernetes-dashboard-key-holder' secret. - - apiGroups: [""] - resources: ["secrets"] - verbs: ["create"] - # Allow Dashboard to create 'kubernetes-dashboard-settings' config map. - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["create"] - # Allow Dashboard to get, update and delete Dashboard exclusive secrets. - - apiGroups: [""] - resources: ["secrets"] - resourceNames: ["kubernetes-dashboard-key-holder", "kubernetes-dashboard-certs"] - verbs: ["get", "update", "delete"] - # Allow Dashboard to get and update 'kubernetes-dashboard-settings' config map. - - apiGroups: [""] - resources: ["configmaps"] - resourceNames: ["kubernetes-dashboard-settings"] - verbs: ["get", "update"] - # Allow Dashboard to get metrics from heapster. - - apiGroups: [""] - resources: ["services"] - resourceNames: ["heapster"] - verbs: ["proxy"] - - apiGroups: [""] - resources: ["services/proxy"] - resourceNames: ["heapster", "http:heapster:", "https:heapster:"] - verbs: ["get"] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: RoleBinding - metadata: - labels: - k8s-app: kubernetes-dashboard - name: kubernetes-dashboard-minimal - namespace: garden-system - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: kubernetes-dashboard-minimal - subjects: - - kind: ServiceAccount - name: kubernetes-dashboard - namespace: garden-system - # ------------------- Dashboard Deployment ------------------- # - - kind: Deployment - apiVersion: apps/v1 - metadata: - labels: + # ------------------- Dashboard Deployment ------------------- # + - kind: Deployment + apiVersion: apps/v1 + metadata: + labels: + k8s-app: kubernetes-dashboard + name: kubernetes-dashboard + namespace: garden-system + spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: k8s-app: kubernetes-dashboard - name: kubernetes-dashboard - namespace: garden-system - spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - matchLabels: + template: + metadata: + labels: k8s-app: kubernetes-dashboard - template: - metadata: - labels: - k8s-app: kubernetes-dashboard - spec: - containers: - - name: kubernetes-dashboard - image: k8s.gcr.io/kubernetes-dashboard-amd64:v1.8.2 - ports: - - containerPort: 8443 - protocol: TCP - args: - - --auto-generate-certificates - # Uncomment the following line to manually specify Kubernetes API server Host - # If not specified, Dashboard will attempt to auto discover the API server and connect - # to it. Uncomment only if the default does not work. - # - --apiserver-host=http://my-address:port - volumeMounts: - - name: kubernetes-dashboard-certs - mountPath: /certs - # Create on-disk volume to store exec logs - - mountPath: /tmp - name: tmp-volume - livenessProbe: - httpGet: - scheme: HTTPS - path: / - port: 8443 - initialDelaySeconds: 30 - timeoutSeconds: 30 - volumes: + spec: + containers: + - name: kubernetes-dashboard + image: k8s.gcr.io/kubernetes-dashboard-amd64:v1.8.2 + ports: + - containerPort: 8443 + protocol: TCP + args: + - --auto-generate-certificates + # Uncomment the following line to manually specify Kubernetes API server Host + # If not specified, Dashboard will attempt to auto discover the API server and connect + # to it. Uncomment only if the default does not work. + # - --apiserver-host=http://my-address:port + volumeMounts: - name: kubernetes-dashboard-certs - secret: - secretName: kubernetes-dashboard-certs - - name: tmp-volume - emptyDir: {} - serviceAccountName: kubernetes-dashboard - # Comment the following tolerations if Dashboard must not be deployed on master - tolerations: - - key: node-role.kubernetes.io/master - effect: NoSchedule - # ------------------- Dashboard Service ------------------- # - - kind: Service - apiVersion: v1 - metadata: - labels: - k8s-app: kubernetes-dashboard - name: dashboard - namespace: garden-system - spec: - type: NodePort - ports: - - port: 443 - nodePort: 32005 - targetPort: 8443 - selector: - k8s-app: kubernetes-dashboard + mountPath: /certs + # Create on-disk volume to store exec logs + - mountPath: /tmp + name: tmp-volume + livenessProbe: + httpGet: + scheme: HTTPS + path: / + port: 8443 + initialDelaySeconds: 30 + timeoutSeconds: 30 + volumes: + - name: kubernetes-dashboard-certs + secret: + secretName: kubernetes-dashboard-certs + - name: tmp-volume + emptyDir: {} + serviceAccountName: kubernetes-dashboard + # Comment the following tolerations if Dashboard must not be deployed on master + tolerations: + - key: node-role.kubernetes.io/master + effect: NoSchedule + # ------------------- Dashboard Service ------------------- # + - kind: Service + apiVersion: v1 + metadata: + labels: + k8s-app: kubernetes-dashboard + name: dashboard + namespace: garden-system + spec: + type: NodePort + ports: + - port: 443 + nodePort: 32005 + targetPort: 8443 + selector: + k8s-app: kubernetes-dashboard diff --git a/static/local-gcf-container/child/Dockerfile b/static/local-gcf-container/child/Dockerfile index f0988fcea6..25df7568b8 100644 --- a/static/local-gcf-container/child/Dockerfile +++ b/static/local-gcf-container/child/Dockerfile @@ -2,3 +2,5 @@ ARG baseImageName FROM ${baseImageName} ADD . /functions + +WORKDIR /functions diff --git a/static/local-gcf-container/start.sh b/static/local-gcf-container/start.sh index 4ba7a1ba17..196bbed4fe 100755 --- a/static/local-gcf-container/start.sh +++ b/static/local-gcf-container/start.sh @@ -1,10 +1,12 @@ #!/bin/sh +set -e + cd /functions functions-emulator start --bindHost 0.0.0.0 -functions-emulator deploy $2 \ +functions-emulator deploy $1 \ --trigger-http \ --project local \ --region local diff --git a/test/data/test-project-a/module-a/garden.yml b/test/data/test-project-a/module-a/garden.yml index 8f189f3d35..ed6b1aff60 100644 --- a/test/data/test-project-a/module-a/garden.yml +++ b/test/data/test-project-a/module-a/garden.yml @@ -1,10 +1,10 @@ module: name: module-a - type: generic + type: test services: - name: service-a build: command: echo A - test: + tests: - name: unit command: [echo, OK] diff --git a/test/data/test-project-a/module-b/garden.yml b/test/data/test-project-a/module-b/garden.yml index 2f1dad50a5..21e8952ffd 100644 --- a/test/data/test-project-a/module-b/garden.yml +++ b/test/data/test-project-a/module-b/garden.yml @@ -1,6 +1,6 @@ module: name: module-b - type: generic + type: test services: - name: service-b dependencies: @@ -9,6 +9,6 @@ module: command: echo B dependencies: - module-a - test: + tests: - name: unit command: [echo, OK] diff --git a/test/data/test-project-a/module-c/garden.yml b/test/data/test-project-a/module-c/garden.yml index 8a5162c30d..09cb206afc 100644 --- a/test/data/test-project-a/module-c/garden.yml +++ b/test/data/test-project-a/module-c/garden.yml @@ -1,11 +1,11 @@ module: name: module-c - type: generic + type: test services: - name: service-c build: dependencies: - module-b - test: + tests: - name: unit command: [echo, OK] diff --git a/test/data/test-project-b/garden.yml b/test/data/test-project-b/garden.yml index fc289ee552..faaa18c8d5 100644 --- a/test/data/test-project-b/garden.yml +++ b/test/data/test-project-b/garden.yml @@ -1,5 +1,7 @@ project: name: test-project-b + global: + providers: [] environments: - name: local providers: diff --git a/test/data/test-project-b/module-a/garden.yml b/test/data/test-project-b/module-a/garden.yml index 2498a31afc..27507d98e8 100644 --- a/test/data/test-project-b/module-a/garden.yml +++ b/test/data/test-project-b/module-a/garden.yml @@ -1,10 +1,14 @@ module: name: module-a - type: generic + type: container + image: scratch services: - name: service-a endpoints: - paths: [/path-a] + port: http + ports: + - name: http containerPort: 8080 build: command: echo A diff --git a/test/data/test-project-b/module-b/garden.yml b/test/data/test-project-b/module-b/garden.yml index c7b20ba9f6..84e846970a 100644 --- a/test/data/test-project-b/module-b/garden.yml +++ b/test/data/test-project-b/module-b/garden.yml @@ -1,10 +1,14 @@ module: name: module-b - type: generic + type: container + image: scratch services: - name: service-b endpoints: - paths: [/path-b] + port: http + ports: + - name: http containerPort: 8080 dependencies: - service-a diff --git a/test/data/test-project-b/module-c/garden.yml b/test/data/test-project-b/module-c/garden.yml index 97a5ea50c6..0601d3a308 100644 --- a/test/data/test-project-b/module-c/garden.yml +++ b/test/data/test-project-b/module-c/garden.yml @@ -1,11 +1,15 @@ module: name: module-c - type: generic + type: container + image: scratch allowPush: false services: - name: service-c endpoints: - paths: [/path-c] + port: http + ports: + - name: http containerPort: 8080 build: dependencies: diff --git a/test/data/test-project-build-products/garden.yml b/test/data/test-project-build-products/garden.yml index 0b96ef41fd..905d256c95 100644 --- a/test/data/test-project-build-products/garden.yml +++ b/test/data/test-project-build-products/garden.yml @@ -1,2 +1,5 @@ project: name: build-products + global: + providers: + - name: test-plugin diff --git a/test/data/test-project-build-products/module-a/garden.yml b/test/data/test-project-build-products/module-a/garden.yml index 90295f43fe..a314298b63 100644 --- a/test/data/test-project-build-products/module-a/garden.yml +++ b/test/data/test-project-build-products/module-a/garden.yml @@ -1,5 +1,5 @@ module: name: module-a - type: generic + type: test build: command: "echo A && touch a.txt" diff --git a/test/data/test-project-build-products/module-b/garden.yml b/test/data/test-project-build-products/module-b/garden.yml index 0bd2577c8f..4792032c5d 100644 --- a/test/data/test-project-build-products/module-b/garden.yml +++ b/test/data/test-project-build-products/module-b/garden.yml @@ -1,5 +1,5 @@ module: name: module-b - type: generic + type: test build: command: "echo B && mkdir -p build/build_subdir && touch build/b1.txt build/build_subdir/b2.txt build/unused.txt" diff --git a/test/data/test-project-build-products/module-c/garden.yml b/test/data/test-project-build-products/module-c/garden.yml index 1960ad0f88..93ed13e100 100644 --- a/test/data/test-project-build-products/module-c/garden.yml +++ b/test/data/test-project-build-products/module-c/garden.yml @@ -1,5 +1,5 @@ module: name: module-c - type: generic + type: test build: command: "echo C" diff --git a/test/data/test-project-build-products/module-d/garden.yml b/test/data/test-project-build-products/module-d/garden.yml index fe39aa8020..8e63ec0c19 100644 --- a/test/data/test-project-build-products/module-d/garden.yml +++ b/test/data/test-project-build-products/module-d/garden.yml @@ -1,6 +1,6 @@ module: name: module-d - type: generic + type: test build: command: "echo D && mkdir -p build && touch build/d.txt" dependencies: diff --git a/test/data/test-project-build-products/module-e/garden.yml b/test/data/test-project-build-products/module-e/garden.yml index 17b6c05f0c..6c71d69be9 100644 --- a/test/data/test-project-build-products/module-e/garden.yml +++ b/test/data/test-project-build-products/module-e/garden.yml @@ -1,6 +1,6 @@ module: name: module-e - type: generic + type: test build: command: "echo E" dependencies: diff --git a/test/data/test-project-templated/module-a/garden.yml b/test/data/test-project-templated/module-a/garden.yml index b547eea158..6752be1b37 100644 --- a/test/data/test-project-templated/module-a/garden.yml +++ b/test/data/test-project-templated/module-a/garden.yml @@ -1,11 +1,11 @@ module: name: module-a - type: generic + type: test services: - name: service-a - command: echo ${local.env.TEST_VARIABLE} + command: [echo, "${local.env.TEST_VARIABLE}"] build: command: ${variables.service-a-build-command} - test: + tests: - name: unit command: [echo, OK] diff --git a/test/data/test-project-templated/module-b/garden.yml b/test/data/test-project-templated/module-b/garden.yml index 9a0772734f..39735a774d 100644 --- a/test/data/test-project-templated/module-b/garden.yml +++ b/test/data/test-project-templated/module-b/garden.yml @@ -1,13 +1,13 @@ module: name: module-b - type: generic + type: test services: - name: service-b - command: echo ${dependencies.service-a.version} + command: [echo, "${dependencies.service-a.version}"] dependencies: - service-a build: command: ${variables.service-a-build-command} - test: + tests: - name: unit command: [echo, "${config.project.my.variable}"] diff --git a/test/helpers.ts b/test/helpers.ts index d3e411d1e3..3be2d09aee 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,25 +1,37 @@ import * as td from "testdouble" import { resolve } from "path" import { PluginContext } from "../src/plugin-context" +import { + ContainerModule, + containerModuleSpecSchema, + ContainerServiceConfig, +} from "../src/plugins/container" +import { + testGenericModule, +} from "../src/plugins/generic" import { TaskResults } from "../src/task-graph" import { - DeleteConfigParams, - GetConfigParams, - ParseModuleParams, + validate, +} from "../src/types/common" +import { GardenPlugin, PluginActions, PluginFactory, - SetConfigParams, ModuleActions, - RunModuleParams, - RunServiceParams, -} from "../src/types/plugin" + } from "../src/types/plugin" import { Garden } from "../src/garden" import { - Module, ModuleConfig, } from "../src/types/module" import { mapValues } from "lodash" +import { + DeleteConfigParams, + GetConfigParams, + ParseModuleParams, + RunModuleParams, + RunServiceParams, + SetConfigParams, +} from "../src/types/plugin/params" import { TreeVersion } from "../src/vcs/base" export const dataDir = resolve(__dirname, "data") @@ -45,7 +57,7 @@ export async function profileBlock(description: string, block: () => Promise { return { actions: { - async configureEnvironment() { }, + async configureEnvironment() { + return {} + }, async setConfig({ key, value }: SetConfigParams) { _config[key.join(".")] = value + return {} }, async getConfig({ key }: GetConfigParams) { - return _config[key.join(".")] || null + return { value: _config[key.join(".")] || null } }, async deleteConfig({ key }: DeleteConfigParams) { @@ -79,10 +94,39 @@ export const testPlugin: PluginFactory = (): GardenPlugin => { }, }, moduleActions: { - generic: { - async parseModule({ ctx, moduleConfig }: ParseModuleParams) { - return new Module(ctx, moduleConfig) + test: { + testModule: testGenericModule, + + async parseModule({ ctx, moduleConfig }: ParseModuleParams) { + moduleConfig.spec = validate( + moduleConfig.spec, + containerModuleSpecSchema, + { context: `test module ${moduleConfig.name}` }, + ) + + // validate services + const services: ContainerServiceConfig[] = moduleConfig.spec.services.map(spec => ({ + name: spec.name, + dependencies: spec.dependencies, + outputs: spec.outputs, + spec, + })) + + const tests = moduleConfig.spec.tests.map(t => ({ + name: t.name, + dependencies: t.dependencies, + spec: t, + timeout: t.timeout, + variables: t.variables, + })) + + return { + module: moduleConfig, + services, + tests, + } }, + async runModule(params: RunModuleParams) { const version = await params.module.getVersion() @@ -96,9 +140,10 @@ export const testPlugin: PluginFactory = (): GardenPlugin => { success: true, } }, + async runService({ ctx, service, interactive, runtimeContext, silent, timeout}: RunServiceParams) { return ctx.runModule({ - module: service.module, + moduleName: service.module.name, command: [service.name], interactive, runtimeContext, @@ -106,6 +151,7 @@ export const testPlugin: PluginFactory = (): GardenPlugin => { timeout, }) }, + async getServiceStatus() { return {} }, async deployService() { return {} }, }, @@ -117,12 +163,21 @@ testPlugin.pluginName = "test-plugin" export const testPluginB: PluginFactory = (params) => { const plugin = testPlugin(params) plugin.moduleActions = { - test: plugin.moduleActions!.generic, + test: plugin.moduleActions!.test, } return plugin } testPluginB.pluginName = "test-plugin-b" +export const testPluginC: PluginFactory = (params) => { + const plugin = testPlugin(params) + plugin.moduleActions = { + "test-c": plugin.moduleActions!.test, + } + return plugin +} +testPluginC.pluginName = "test-plugin-c" + export const defaultModuleConfig: ModuleConfig = { type: "test", name: "test", @@ -130,23 +185,30 @@ export const defaultModuleConfig: ModuleConfig = { allowPush: false, variables: {}, build: { dependencies: [] }, - services: [ - { - name: "testService", - dependencies: [], - }, - ], - test: [], + spec: { + services: [ + { + name: "testService", + dependencies: [], + }, + ], + }, } export const makeTestModule = (ctx: PluginContext, params: Partial = {}) => { - return new TestModule(ctx, { ...defaultModuleConfig, ...params }) + return new TestModule( + ctx, + { ...defaultModuleConfig, ...params }, + defaultModuleConfig.spec.services, + [], + ) } export const makeTestGarden = async (projectRoot: string, extraPlugins: PluginFactory[] = []) => { const testPlugins: PluginFactory[] = [ testPlugin, testPluginB, + testPluginC, ] const plugins: PluginFactory[] = testPlugins.concat(extraPlugins) @@ -174,9 +236,12 @@ export function stubAction ( } export function stubModuleAction> ( - garden: Garden, moduleType: string, pluginName: string, type: T, handler?: ModuleActions[T], + garden: Garden, moduleType: string, pluginName: string, actionType: T, handler: ModuleActions[T], ) { - return td.replace(garden["moduleActionHandlers"][moduleType][type], pluginName, handler) + handler["actionType"] = actionType + handler["pluginName"] = pluginName + handler["moduleType"] = moduleType + return td.replace(garden["moduleActionHandlers"][actionType][moduleType], pluginName, handler) } export async function expectError(fn: Function, typeOrCallback: string | ((err: any) => void)) { diff --git a/test/src/commands/call.ts b/test/src/commands/call.ts index 0dd9d99d2d..69de771e70 100644 --- a/test/src/commands/call.ts +++ b/test/src/commands/call.ts @@ -2,7 +2,9 @@ import { join } from "path" import { Garden } from "../../../src/garden" import { CallCommand } from "../../../src/commands/call" import { expect } from "chai" -import { GetServiceStatusParams, PluginFactory } from "../../../src/types/plugin" +import { parseContainerModule } from "../../../src/plugins/container" +import { PluginFactory } from "../../../src/types/plugin" +import { GetServiceStatusParams } from "../../../src/types/plugin/params" import { ServiceStatus } from "../../../src/types/service" import nock = require("nock") @@ -22,14 +24,13 @@ const testProvider: PluginFactory = () => { }, } - const getServiceStatus = async ({ service }: GetServiceStatusParams): Promise => { - return testStatuses[service.name] || {} + const getServiceStatus = async (params: GetServiceStatusParams): Promise => { + return testStatuses[params.service.name] || {} } return { moduleActions: { - generic: { getServiceStatus }, - container: { getServiceStatus }, + container: { parseModule: parseContainerModule, getServiceStatus }, }, } } diff --git a/test/src/commands/config/delete.ts b/test/src/commands/config/delete.ts index 1de2085835..597ec8037c 100644 --- a/test/src/commands/config/delete.ts +++ b/test/src/commands/config/delete.ts @@ -1,19 +1,20 @@ import { ConfigDeleteCommand } from "../../../../src/commands/config/delete" import { expectError, makeTestContextA } from "../../../helpers" +import { expect } from "chai" describe("ConfigDeleteCommand", () => { it("should delete a config variable", async () => { const ctx = await makeTestContextA() const command = new ConfigDeleteCommand() - await ctx.setConfig(["project", "mykey"], "myvalue") + const key = ["project", "mykey"] + const value = "myvalue" + + await ctx.setConfig({ key, value }) await command.action(ctx, { key: "project.mykey" }) - await expectError( - async () => await ctx.getConfig(["project", "mykey"]), - "not-found", - ) + expect(await ctx.getConfig({ key })).to.eql({ value: null }) }) it("should throw on invalid key", async () => { diff --git a/test/src/commands/config/get.ts b/test/src/commands/config/get.ts index e4aee4266c..ab0c44ca72 100644 --- a/test/src/commands/config/get.ts +++ b/test/src/commands/config/get.ts @@ -7,7 +7,7 @@ describe("ConfigGetCommand", () => { const ctx = await makeTestContextA() const command = new ConfigGetCommand() - await ctx.setConfig(["project", "mykey"], "myvalue") + await ctx.setConfig({ key: ["project", "mykey"], value: "myvalue" }) const res = await command.action(ctx, { key: "project.mykey" }) diff --git a/test/src/commands/config/set.ts b/test/src/commands/config/set.ts index 81cdd514ce..8e8880f929 100644 --- a/test/src/commands/config/set.ts +++ b/test/src/commands/config/set.ts @@ -9,7 +9,7 @@ describe("ConfigSetCommand", () => { await command.action(ctx, { key: "project.mykey", value: "myvalue" }) - expect(await ctx.getConfig(["project", "mykey"])).to.equal("myvalue") + expect(await ctx.getConfig({ key: ["project", "mykey"] })).to.eql({ value: "myvalue" }) }) it("should throw on invalid key", async () => { diff --git a/test/src/commands/deploy.ts b/test/src/commands/deploy.ts index d410e1005a..98313ecee5 100644 --- a/test/src/commands/deploy.ts +++ b/test/src/commands/deploy.ts @@ -2,11 +2,14 @@ import { join } from "path" import { Garden } from "../../../src/garden" import { DeployCommand } from "../../../src/commands/deploy" import { expect } from "chai" +import { parseContainerModule } from "../../../src/plugins/container" import { - DeployServiceParams, - GetServiceStatusParams, PluginFactory, } from "../../../src/types/plugin" +import { + DeployServiceParams, + GetServiceStatusParams, +} from "../../../src/types/plugin/params" import { ServiceState, ServiceStatus } from "../../../src/types/service" import { taskResultOutputs } from "../../helpers" @@ -43,8 +46,7 @@ const testProvider: PluginFactory = () => { return { moduleActions: { - generic: { deployService, getServiceStatus }, - container: { deployService, getServiceStatus }, + container: { parseModule: parseContainerModule, deployService, getServiceStatus }, }, } } diff --git a/test/src/commands/environment/destroy.ts b/test/src/commands/environment/destroy.ts index f5a8273844..936af01a06 100644 --- a/test/src/commands/environment/destroy.ts +++ b/test/src/commands/environment/destroy.ts @@ -3,11 +3,11 @@ import { join } from "path" import * as td from "testdouble" import { - EnvironmentStatus, PluginFactory, } from "../../../../src/types/plugin" import { EnvironmentDestroyCommand } from "../../../../src/commands/environment/destroy" import { Garden } from "../../../../src/garden" +import { EnvironmentStatus } from "../../../../src/types/plugin/outputs" const testProvider: PluginFactory = () => { const name = "test-plugin" @@ -16,6 +16,7 @@ const testProvider: PluginFactory = () => { const destroyEnvironment = async () => { testEnvStatuses[name] = { configured: false } + return {} } const getEnvironmentStatus = async () => { diff --git a/test/src/commands/push.ts b/test/src/commands/push.ts index aa87c1462c..eef41cd923 100644 --- a/test/src/commands/push.ts +++ b/test/src/commands/push.ts @@ -3,6 +3,7 @@ import { join } from "path" import { expect } from "chai" import * as td from "testdouble" import { Garden } from "../../../src/garden" +import { parseContainerModule } from "../../../src/plugins/container" import { PluginFactory, } from "../../../src/types/plugin" @@ -31,7 +32,8 @@ const pushModule = async () => { const testProvider: PluginFactory = () => { return { moduleActions: { - generic: { + container: { + parseModule: parseContainerModule, getModuleBuildStatus, buildModule, pushModule, @@ -45,7 +47,8 @@ testProvider.pluginName = "test-plugin" const testProviderB: PluginFactory = () => { return { moduleActions: { - generic: { + container: { + parseModule: parseContainerModule, getModuleBuildStatus, buildModule, }, @@ -58,7 +61,8 @@ testProviderB.pluginName = "test-plugin-b" const testProviderNoPush: PluginFactory = () => { return { moduleActions: { - generic: { + container: { + parseModule: parseContainerModule, getModuleBuildStatus, buildModule, }, @@ -197,7 +201,7 @@ describe("PushCommand", () => { expect(taskResultOutputs(result)).to.eql({ "build.module-a": { fresh: false }, - "push.module-a": { pushed: false, message: chalk.yellow("No push handler available for module type generic") }, + "push.module-a": { pushed: false, message: chalk.yellow("No push handler available for module type container") }, }) }) diff --git a/test/src/commands/run/module.ts b/test/src/commands/run/module.ts index a2646da72a..e281a1b01b 100644 --- a/test/src/commands/run/module.ts +++ b/test/src/commands/run/module.ts @@ -1,5 +1,5 @@ import { RunModuleCommand } from "../../../../src/commands/run/module" -import { RunResult } from "../../../../src/types/plugin" +import { RunResult } from "../../../../src/types/plugin/outputs" import { makeTestGardenA, makeTestModule, diff --git a/test/src/commands/run/service.ts b/test/src/commands/run/service.ts index e7727a0cbe..f5b107aa24 100644 --- a/test/src/commands/run/service.ts +++ b/test/src/commands/run/service.ts @@ -1,5 +1,5 @@ import { RunServiceCommand } from "../../../../src/commands/run/service" -import { RunResult } from "../../../../src/types/plugin" +import { RunResult } from "../../../../src/types/plugin/outputs" import { makeTestGardenA, makeTestModule, diff --git a/test/src/garden.ts b/test/src/garden.ts index 4059f21461..59b2c63655 100644 --- a/test/src/garden.ts +++ b/test/src/garden.ts @@ -492,15 +492,6 @@ describe("Garden", () => { expect(handler["pluginName"]).to.equal("test-plugin-b") }) - it("should filter to only handlers for the specified module type", async () => { - const ctx = await makeTestGardenA() - - const handler = ctx.getModuleActionHandler("deployService", "test") - - expect(handler["actionType"]).to.equal("deployService") - expect(handler["pluginName"]).to.equal("test-plugin-b") - }) - it("should throw if no handler is available", async () => { const ctx = await makeTestGardenA() await expectError(() => ctx.getModuleActionHandler("execInService", "container"), "parameter") diff --git a/test/src/logger.ts b/test/src/logger.ts index 3c758f3556..7546e739c3 100644 --- a/test/src/logger.ts +++ b/test/src/logger.ts @@ -1,6 +1,6 @@ import { expect } from "chai" -import { LogLevel, EntryStatus, LogSymbolType, LoggerType } from "../../src/logger/types" +import { LogLevel, EntryStatus, LogSymbolType } from "../../src/logger/types" import { BasicConsoleWriter, FancyConsoleWriter } from "../../src/logger/writers" import { RootLogNode } from "../../src/logger" import { getChildNodes } from "../../src/logger/util" diff --git a/test/src/plugin-context.ts b/test/src/plugin-context.ts index 35429d1337..e507864f64 100644 --- a/test/src/plugin-context.ts +++ b/test/src/plugin-context.ts @@ -12,8 +12,8 @@ describe("PluginContext", () => { const key = ["project", "my", "variable"] const value = "myvalue" - await ctx.setConfig(key, value) - expect(await ctx.getConfig(key)).to.equal(value) + await ctx.setConfig({ key, value }) + expect(await ctx.getConfig({ key })).to.eql({ value }) }) it("should throw with an invalid namespace in the key", async () => { @@ -22,7 +22,7 @@ describe("PluginContext", () => { const key = ["bla", "my", "variable"] const value = "myvalue" - await expectError(async () => await ctx.setConfig(key, value), "parameter") + await expectError(async () => await ctx.setConfig({ key, value }), "parameter") }) it("should throw with malformatted key", async () => { @@ -31,7 +31,7 @@ describe("PluginContext", () => { const key = ["project", "!4215"] const value = "myvalue" - await expectError(async () => await ctx.setConfig(key, value), "parameter") + await expectError(async () => await ctx.setConfig({ key, value }), "parameter") }) }) @@ -42,16 +42,8 @@ describe("PluginContext", () => { const key = ["project", "my", "variable"] const value = "myvalue" - await ctx.setConfig(key, value) - expect(await ctx.getConfig(key)).to.equal(value) - }) - - it("should throw if key does not exist", async () => { - const ctx = await makeTestContextA() - - const key = ["project", "my", "variable"] - - await expectError(async () => await ctx.getConfig(key), "not-found") + await ctx.setConfig({ key, value }) + expect(await ctx.getConfig({ key })).to.eql({ value }) }) it("should throw with an invalid namespace in the key", async () => { @@ -59,7 +51,7 @@ describe("PluginContext", () => { const key = ["bla", "my", "variable"] - await expectError(async () => await ctx.getConfig(key), "parameter") + await expectError(async () => await ctx.getConfig({ key }), "parameter") }) it("should throw with malformatted key", async () => { @@ -67,7 +59,7 @@ describe("PluginContext", () => { const key = ["project", "!4215"] - await expectError(async () => await ctx.getConfig(key), "parameter") + await expectError(async () => await ctx.getConfig({ key }), "parameter") }) }) @@ -78,8 +70,8 @@ describe("PluginContext", () => { const key = ["project", "my", "variable"] const value = "myvalue" - await ctx.setConfig(key, value) - expect(await ctx.deleteConfig(key)).to.eql({ found: true }) + await ctx.setConfig({ key, value }) + expect(await ctx.deleteConfig({ key })).to.eql({ found: true }) }) it("should return {found:false} if key does not exist", async () => { @@ -87,7 +79,7 @@ describe("PluginContext", () => { const key = ["project", "my", "variable"] - await expectError(async () => await ctx.deleteConfig(key), "not-found") + expect(await ctx.deleteConfig({ key })).to.eql({ found: false }) }) it("should throw with an invalid namespace in the key", async () => { @@ -95,7 +87,7 @@ describe("PluginContext", () => { const key = ["bla", "my", "variable"] - await expectError(async () => await ctx.deleteConfig(key), "parameter") + await expectError(async () => await ctx.deleteConfig({ key }), "parameter") }) it("should throw with malformatted key", async () => { @@ -103,7 +95,7 @@ describe("PluginContext", () => { const key = ["project", "!4215"] - await expectError(async () => await ctx.deleteConfig(key), "parameter") + await expectError(async () => await ctx.deleteConfig({ key }), "parameter") }) }) }) diff --git a/test/src/plugins/container.ts b/test/src/plugins/container.ts index a51dd8bf8c..dd0fabace3 100644 --- a/test/src/plugins/container.ts +++ b/test/src/plugins/container.ts @@ -4,8 +4,10 @@ import * as td from "testdouble" import { Garden } from "../../../src/garden" import { PluginContext } from "../../../src/plugin-context" import { + ContainerModule, ContainerModuleConfig, gardenPlugin, + helpers, } from "../../../src/plugins/container" import { Environment } from "../../../src/types/common" import { @@ -20,6 +22,9 @@ describe("container", () => { const handler = gardenPlugin() const parseModule = handler.moduleActions!.container!.parseModule! + const buildModule = handler.moduleActions!.container!.buildModule! + const pushModule = handler.moduleActions!.container!.pushModule! + const getModuleBuildStatus = handler.moduleActions!.container!.getModuleBuildStatus! let garden: Garden let ctx: PluginContext @@ -38,10 +43,11 @@ describe("container", () => { const provider = { name: "container", config: {} } async function getTestModule(moduleConfig: ContainerModuleConfig) { - return parseModule!({ ctx, env, provider, moduleConfig }) + const parseResults = await parseModule!({ ctx, env, provider, moduleConfig }) + return new ContainerModule(ctx, parseResults.module, parseResults.services, parseResults.tests) } - describe("ContainerModule", () => { + describe("helpers", () => { describe("getLocalImageId", () => { it("should create identifier with commit hash version if module has a Dockerfile", async () => { const module = await getTestModule({ @@ -51,16 +57,21 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image:1.1", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image:1.1", + services: [], + tests: [], + }, }) - td.replace(module, "hasDockerfile", () => true) + td.replace(helpers, "hasDockerfile", () => true) td.replace(module, "getVersion", async () => ({ versionString: "1234" })) - expect(await module.getLocalImageId()).to.equal("test:1234") + + expect(await helpers.getLocalImageId(module)).to.equal("test:1234") }) it("should create identifier with image name if module has no Dockerfile", async () => { @@ -71,15 +82,20 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image:1.1", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image:1.1", + services: [], + tests: [], + }, }) - td.replace(module, "hasDockerfile", () => false) - expect(await module.getLocalImageId()).to.equal("some/image:1.1") + td.replace(helpers, "hasDockerfile", () => false) + + expect(await helpers.getLocalImageId(module)).to.equal("some/image:1.1") }) }) @@ -92,14 +108,18 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image:1.1", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image:1.1", + services: [], + tests: [], + }, }) - expect(await module.getRemoteImageId()).to.equal("some/image:1.1") + expect(await helpers.getRemoteImageId(module)).to.equal("some/image:1.1") }) it("should use image name if specified with commit hash if no version is set", async () => { @@ -110,15 +130,20 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image", + services: [], + tests: [], + }, }) td.replace(module, "getVersion", async () => ({ versionString: "1234" })) - expect(await module.getRemoteImageId()).to.equal("some/image:1234") + + expect(await helpers.getRemoteImageId(module)).to.equal("some/image:1234") }) it("should use local id if no image name is set", async () => { @@ -129,14 +154,19 @@ describe("container", () => { }, name: "test", path: modulePath, - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + services: [], + tests: [], + }, }) - td.replace(module, "getLocalImageId", async () => "test:1234") - expect(await module.getRemoteImageId()).to.equal("test:1234") + td.replace(helpers, "getLocalImageId", async () => "test:1234") + + expect(await helpers.getRemoteImageId(module)).to.equal("test:1234") }) }) }) @@ -152,38 +182,44 @@ describe("container", () => { }, name: "module-a", path: modulePath, - services: [{ - name: "service-a", - command: ["echo"], - dependencies: [], - daemon: false, - endpoints: [ - { - paths: ["/"], - port: "http", - }, - ], - healthCheck: { - httpGet: { - path: "/health", - port: "http", + type: "container", + variables: {}, + + spec: { + buildArgs: {}, + services: [{ + name: "service-a", + command: ["echo"], + dependencies: [], + daemon: false, + endpoints: [ + { + paths: ["/"], + port: "http", + }, + ], + healthCheck: { + httpGet: { + path: "/health", + port: "http", + }, }, - }, - ports: [{ - name: "http", - protocol: "TCP", - containerPort: 8080, + ports: [{ + name: "http", + protocol: "TCP", + containerPort: 8080, + }], + outputs: {}, + volumes: [], }], - volumes: [], - }], - test: [{ - name: "unit", - command: ["echo", "OK"], - dependencies: [], - variables: {}, - }], - type: "test", - variables: {}, + tests: [{ + name: "unit", + command: ["echo", "OK"], + dependencies: [], + timeout: null, + variables: {}, + }], + }, } await parseModule({ ctx, env, provider, moduleConfig }) @@ -198,28 +234,34 @@ describe("container", () => { }, name: "module-a", path: modulePath, - services: [{ - name: "service-a", - command: ["echo"], - dependencies: [], - daemon: false, - endpoints: [ - { - paths: ["/"], - port: "bla", - }, - ], - ports: [], - volumes: [], - }], - test: [{ - name: "unit", - command: ["echo", "OK"], - dependencies: [], - variables: {}, - }], type: "test", variables: {}, + + spec: { + buildArgs: {}, + services: [{ + name: "service-a", + command: ["echo"], + dependencies: [], + daemon: false, + endpoints: [ + { + paths: ["/"], + port: "bla", + }, + ], + ports: [], + outputs: {}, + volumes: [], + }], + tests: [{ + name: "unit", + command: ["echo", "OK"], + dependencies: [], + timeout: null, + variables: {}, + }], + }, } await expectError( @@ -237,24 +279,29 @@ describe("container", () => { }, name: "module-a", path: modulePath, - services: [{ - name: "service-a", - command: ["echo"], - dependencies: [], - daemon: false, - endpoints: [], - healthCheck: { - httpGet: { - path: "/", - port: "bla", - }, - }, - ports: [], - volumes: [], - }], - test: [], type: "test", variables: {}, + + spec: { + buildArgs: {}, + services: [{ + name: "service-a", + command: ["echo"], + dependencies: [], + daemon: false, + endpoints: [], + healthCheck: { + httpGet: { + path: "/", + port: "bla", + }, + }, + ports: [], + outputs: {}, + volumes: [], + }], + tests: [], + }, } await expectError( @@ -272,21 +319,26 @@ describe("container", () => { }, name: "module-a", path: modulePath, - services: [{ - name: "service-a", - command: ["echo"], - dependencies: [], - daemon: false, - endpoints: [], - healthCheck: { - tcpPort: "bla", - }, - ports: [], - volumes: [], - }], - test: [], type: "test", variables: {}, + + spec: { + buildArgs: {}, + services: [{ + name: "service-a", + command: ["echo"], + dependencies: [], + daemon: false, + endpoints: [], + healthCheck: { + tcpPort: "bla", + }, + ports: [], + outputs: {}, + volumes: [], + }], + tests: [], + }, } await expectError( @@ -305,17 +357,20 @@ describe("container", () => { }, name: "test", path: modulePath, - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.imageExistsLocally()).thenResolve(true) - - const result = await ctx.getModuleBuildStatus(module) + td.replace(helpers, "imageExistsLocally", async () => true) + const result = await getModuleBuildStatus({ ctx, env, provider, module }) expect(result).to.eql({ ready: true }) }) @@ -327,17 +382,20 @@ describe("container", () => { }, name: "test", path: modulePath, - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.imageExistsLocally()).thenResolve(false) - - const result = await ctx.getModuleBuildStatus(module) + td.replace(helpers, "imageExistsLocally", async () => false) + const result = await getModuleBuildStatus({ ctx, env, provider, module }) expect(result).to.eql({ ready: false }) }) }) @@ -351,18 +409,25 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image", + services: [], + tests: [], + }, })) + td.when(module.getVersion()).thenResolve({ versionString: "1234" }) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) td.when(module.getBuildPath()).thenResolve("/tmp/jaoigjwaeoigjweaoglwaeghe") - td.when(module.pullImage(ctx)).thenResolve(null) - const result = await ctx.buildModule(module, {}) + td.replace(helpers, "pullImage", async () => null) + td.replace(helpers, "imageExistsLocally", async () => false) + + const result = await buildModule({ ctx, env, provider, module }) expect(result).to.eql({ fetched: true }) }) @@ -375,25 +440,32 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image", + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.getLocalImageId()).thenResolve("some/image") td.when(module.getBuildPath()).thenResolve(modulePath) - const result = await ctx.buildModule(module, {}) + td.replace(helpers, "getLocalImageId", async () => "some/image") + + const dockerCli = td.replace(helpers, "dockerCli") + + const result = await buildModule({ ctx, env, provider, module }) expect(result).to.eql({ fresh: true, details: { identifier: "some/image" }, }) - td.verify(module.dockerCli("build -t some/image " + modulePath)) + td.verify(dockerCli(module, "build -t some/image " + modulePath)) }) }) @@ -406,18 +478,22 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image", + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.hasDockerfile()).thenReturn(false) - const result = await ctx.pushModule(module) + td.replace(helpers, "hasDockerfile", () => false) + const result = await pushModule({ ctx, env, provider, module }) expect(result).to.eql({ pushed: false }) }) @@ -429,24 +505,30 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image:1.1", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image:1.1", + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.hasDockerfile()).thenReturn(true) - td.when(module.getLocalImageId()).thenReturn("some/image:12345") - td.when(module.getRemoteImageId()).thenReturn("some/image:12345") - const result = await ctx.pushModule(module) + td.replace(helpers, "hasDockerfile", () => true) + td.replace(helpers, "getLocalImageId", async () => "some/image:12345") + td.replace(helpers, "getRemoteImageId", async () => "some/image:12345") + + const dockerCli = td.replace(helpers, "dockerCli") + const result = await pushModule({ ctx, env, provider, module }) expect(result).to.eql({ pushed: true }) - td.verify(module.dockerCli("tag some/image:12345 some/image:12345"), { times: 0 }) - td.verify(module.dockerCli("push some/image:12345")) + td.verify(dockerCli(module, "tag some/image:12345 some/image:12345"), { times: 0 }) + td.verify(dockerCli(module, "push some/image:12345")) }) it("tag image if remote id differs from local id", async () => { @@ -457,24 +539,30 @@ describe("container", () => { }, name: "test", path: modulePath, - image: "some/image:1.1", - services: [], - test: [], type: "container", variables: {}, + + spec: { + buildArgs: {}, + image: "some/image:1.1", + services: [], + tests: [], + }, })) td.when(module.resolveConfig(), { ignoreExtraArgs: true }).thenResolve(module) - td.when(module.hasDockerfile()).thenReturn(true) - td.when(module.getLocalImageId()).thenReturn("some/image:12345") - td.when(module.getRemoteImageId()).thenReturn("some/image:1.1") - const result = await ctx.pushModule(module) + td.replace(helpers, "hasDockerfile", () => true) + td.replace(helpers, "getLocalImageId", () => "some/image:12345") + td.replace(helpers, "getRemoteImageId", () => "some/image:1.1") + + const dockerCli = td.replace(helpers, "dockerCli") + const result = await pushModule({ ctx, env, provider, module }) expect(result).to.eql({ pushed: true }) - td.verify(module.dockerCli("tag some/image:12345 some/image:1.1")) - td.verify(module.dockerCli("push some/image:1.1")) + td.verify(dockerCli(module, "tag some/image:12345 some/image:1.1")) + td.verify(dockerCli(module, "push some/image:1.1")) }) }) }) diff --git a/test/src/tasks/deploy.ts b/test/src/tasks/deploy.ts index 8974ba1349..8a468c7686 100644 --- a/test/src/tasks/deploy.ts +++ b/test/src/tasks/deploy.ts @@ -18,7 +18,7 @@ describe("DeployTask", () => { const garden = await makeTestGarden(resolve(dataDir, "test-project-templated")) const ctx = garden.pluginContext - await ctx.setConfig(["project", "my", "variable"], "OK") + await ctx.setConfig({ key: ["project", "my", "variable"], value: "OK" }) const serviceA = await ctx.getService("service-a") const serviceB = await ctx.getService("service-b") @@ -27,28 +27,42 @@ describe("DeployTask", () => { let actionParams: any = {} stubModuleAction( - garden, "generic", "test-plugin", "getServiceStatus", + garden, "test", "test-plugin", "getServiceStatus", async () => ({}), ) stubModuleAction( - garden, "generic", "test-plugin", "deployService", - async (params) => { actionParams = params }, + garden, "test", "test-plugin", "deployService", + async (params) => { + actionParams = params + return {} + }, ) await task.process() - const { versionString } = await serviceA.module.getVersion() + const versionStringA = (await serviceA.module.getVersion()).versionString + const versionStringB = (await serviceB.module.getVersion()).versionString expect(actionParams.service.config).to.eql({ name: "service-b", - command: `echo ${versionString}`, dependencies: ["service-a"], + outputs: {}, + spec: { + command: ["echo", versionStringA], + daemon: false, + dependencies: ["service-a"], + endpoints: [], + name: "service-b", + outputs: {}, + ports: [], + volumes: [], + }, }) expect(actionParams.runtimeContext.dependencies).to.eql({ "service-a": { outputs: {}, - version: versionString, + version: versionStringA, }, }) }) diff --git a/test/src/types/config.ts b/test/src/types/config.ts index 744bd35a04..3432e4530e 100644 --- a/test/src/types/config.ts +++ b/test/src/types/config.ts @@ -41,18 +41,20 @@ describe("loadConfig", () => { expect(parsed.module).to.eql({ name: "module-a", - type: "generic", + type: "test", + description: undefined, allowPush: true, - services: [{ name: "service-a", dependencies: [] }], build: { command: "echo A", dependencies: [] }, - test: [{ - name: "unit", - command: ["echo", "OK"], - dependencies: [], - variables: {}, - }], path: modulePathA, variables: {}, + + spec: { + services: [{ name: "service-a" }], + tests: [{ + name: "unit", + command: ["echo", "OK"], + }], + }, }) }) }) diff --git a/test/src/types/module.ts b/test/src/types/module.ts index 526c58fe47..420993f61c 100644 --- a/test/src/types/module.ts +++ b/test/src/types/module.ts @@ -2,48 +2,91 @@ import { Module } from "../../../src/types/module" import { resolve } from "path" import { dataDir, makeTestContextA, makeTestContext } from "../../helpers" import { expect } from "chai" -import { omitUndefined } from "../../../src/util" import { loadConfig } from "../../../src/types/config" const modulePathA = resolve(dataDir, "test-project-a", "module-a") describe("Module", () => { - describe("factory", () => { - it("should create a module instance with the given config", async () => { - const ctx = await makeTestContextA() - const config = await loadConfig(ctx.projectRoot, modulePathA) - const module = new Module(ctx, config.module!) - - expect(module.name).to.equal(config.module!.name) - expect(omitUndefined(module.config)).to.eql(config.module) + it("should create a module instance with the given config", async () => { + const ctx = await makeTestContextA() + const config = await loadConfig(ctx.projectRoot, modulePathA) + const module = new Module(ctx, config.module!, [], []) + + expect(module.name).to.equal(config.module!.name) + expect(module.config).to.eql({ + allowPush: true, + build: { + command: "echo A", + dependencies: [], + }, + description: undefined, + name: "module-a", + path: module.path, + spec: { + services: [ + { + name: "service-a", + }, + ], + tests: [ + { + command: [ + "echo", + "OK", + ], + name: "unit", + }, + ], + }, + type: "test", + variables: {}, }) }) describe("resolveConfig", () => { it("should resolve template strings", async () => { - const ctx = await makeTestContext(resolve(dataDir, "test-project-templated")) - const modulePath = resolve(ctx.projectRoot, "module-a") + process.env.TEST_VARIABLE = "banana" - const config = await loadConfig(ctx.projectRoot, modulePath) - const module = new Module(ctx, config.module!) + const ctx = await makeTestContext(resolve(dataDir, "test-project-templated")) + const module = await ctx.getModule("module-a") const resolved = await module.resolveConfig() - expect(module.name).to.equal(config.module!.name) + expect(module.name).to.equal("module-a") expect(resolved.config).to.eql({ allowPush: true, build: { command: "echo OK", dependencies: [] }, + description: undefined, name: "module-a", - path: modulePath, - services: [ - // service template strings are resolved later - { name: "service-a", command: "echo \${local.env.TEST_VARIABLE}", dependencies: [] }, - ], - test: [ - { name: "unit", command: ["echo", "OK"], dependencies: [], variables: {} }, - ], - type: "generic", + path: module.path, + type: "test", variables: {}, + + spec: { + buildArgs: {}, + services: [ + // service template strings are resolved later + { + name: "service-a", + command: ["echo", "banana"], + daemon: false, + dependencies: [], + endpoints: [], + outputs: {}, + ports: [], + volumes: [], + }, + ], + tests: [ + { + name: "unit", + command: ["echo", "OK"], + dependencies: [], + timeout: null, + variables: {}, + }, + ], + }, }) }) }) diff --git a/test/src/types/service.ts b/test/src/types/service.ts index 3c0d77ba99..25e3ee19b1 100644 --- a/test/src/types/service.ts +++ b/test/src/types/service.ts @@ -20,13 +20,27 @@ describe("Service", () => { process.env.TEST_PROVIDER_TYPE = "test-plugin" const ctx = await makeTestContext(resolve(dataDir, "test-project-templated")) - await ctx.setConfig(["project", "my", "variable"], "OK") + await ctx.setConfig({ key: ["project", "my", "variable"], value: "OK" }) const module = await ctx.getModule("module-a") const service = await Service.factory(ctx, module, "service-a") - expect(service.config).to.eql({ name: "service-a", command: "echo banana", dependencies: [] }) + expect(service.config).to.eql({ + name: "service-a", + dependencies: [], + outputs: {}, + spec: { + name: "service-a", + command: ["echo", "banana"], + daemon: false, + dependencies: [], + endpoints: [], + outputs: {}, + ports: [], + volumes: [], + }, + }) }) }) @@ -53,7 +67,7 @@ describe("Service", () => { process.env.TEST_VARIABLE = "banana" const ctx = await makeTestContext(resolve(dataDir, "test-project-templated")) - await ctx.setConfig(["project", "my", "variable"], "OK") + await ctx.setConfig({ key: ["project", "my", "variable"], value: "OK" }) const serviceA = await ctx.getService("service-a") const serviceB = await ctx.getService("service-b") @@ -62,8 +76,18 @@ describe("Service", () => { expect(resolved.config).to.eql({ name: "service-b", - command: `echo ${(await serviceA.module.getVersion()).versionString}`, dependencies: ["service-a"], + outputs: {}, + spec: { + name: "service-b", + command: ["echo", (await serviceA.module.getVersion()).versionString], + daemon: false, + dependencies: ["service-a"], + endpoints: [], + outputs: {}, + ports: [], + volumes: [], + }, }) delete process.env.TEST_PROVIDER_TYPE