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