From e6fcc8bf0703b955b0fd4610e517427aa14f3886 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Fri, 30 Nov 2018 03:41:39 +0100 Subject: [PATCH] feat: add websocket endpoint to API server The endpoint forwards any event emitted to `garden.events` to open connections, and allows commands to be executed via the socket as well. --- docs/reference/commands.md | 4 +- garden-service/package-lock.json | 159 +++++++++++++- garden-service/package.json | 6 +- garden-service/src/cli/cli.ts | 6 +- garden-service/src/commands/build.ts | 5 +- garden-service/src/commands/get/get-config.ts | 34 +-- garden-service/src/commands/serve.ts | 2 +- garden-service/src/garden.ts | 55 +++-- .../src/plugins/kubernetes/system.ts | 2 +- .../src/plugins/openfaas/openfaas.ts | 2 +- garden-service/src/process.ts | 2 +- .../src/{server.ts => server/commands.ts} | 117 ++--------- garden-service/src/server/server.ts | 103 +++++++++ garden-service/src/server/websocket.ts | 108 ++++++++++ garden-service/src/util/util.ts | 40 ++++ garden-service/test/src/commands/build.ts | 4 +- .../test/src/commands/get/get-config.ts | 3 +- garden-service/test/src/garden.ts | 6 +- garden-service/test/src/server/server.ts | 198 ++++++++++++++++++ garden-service/test/src/util/util.ts | 92 +++++++- 20 files changed, 779 insertions(+), 169 deletions(-) rename garden-service/src/{server.ts => server/commands.ts} (52%) create mode 100644 garden-service/src/server/server.ts create mode 100644 garden-service/src/server/websocket.ts create mode 100644 garden-service/test/src/server/server.ts diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 78a3b244ea..f68261caaf 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -36,13 +36,13 @@ Examples: ##### Usage - garden build [module] [options] + garden build [modules] [options] ##### Arguments | Argument | Required | Description | | -------- | -------- | ----------- | - | `module` | No | Specify module(s) to build. Use comma as a separator to specify multiple modules. + | `modules` | No | Specify module(s) to build. Use comma as a separator to specify multiple modules. ##### Options diff --git a/garden-service/package-lock.json b/garden-service/package-lock.json index 23fdb8e7ac..b886469331 100644 --- a/garden-service/package-lock.json +++ b/garden-service/package-lock.json @@ -396,6 +396,7 @@ "version": "1.3.5", "resolved": "http://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", "integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==", + "dev": true, "requires": { "@types/node": "*" } @@ -416,6 +417,7 @@ "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", + "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -450,14 +452,22 @@ "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", + "dev": true, "requires": { "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.0.tgz", + "integrity": "sha512-EIjmpvnHj+T4nMcKwHwxZKUfDmphIKJc2qnEMhSoOvr1lYEQpuRKRz8orWr//krYIIArS/KGGLfL2YGVUYXmIA==", + "dev": true + }, "@types/cookies": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.1.tgz", "integrity": "sha512-ku6IvbucEyuC6i4zAVK/KnuzWNXdbFd1HkXlNLg/zhWDGTtQT5VhumiPruB/BHW34PWVFwyfwGftDQHfWNxu3Q==", + "dev": true, "requires": { "@types/connect": "*", "@types/express": "*", @@ -499,7 +509,8 @@ "@types/events": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", - "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==" + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", + "dev": true }, "@types/execa": { "version": "0.9.0", @@ -514,6 +525,7 @@ "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.0.tgz", "integrity": "sha512-TtPEYumsmSTtTetAPXlJVf3kEqb6wZK0bZojpJQrnD/djV4q1oB6QQ8aKvKqwNPACoe02GNiy5zDzcYivR5Z2w==", + "dev": true, "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "*", @@ -524,6 +536,7 @@ "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.0.tgz", "integrity": "sha512-lTeoCu5NxJU4OD9moCgm0ESZzweAx0YqsAcab6OB0EB3+As1OaHtKnaGJvcngQxYsi9UNv0abn4/DRavrRxt4w==", + "dev": true, "requires": { "@types/events": "*", "@types/node": "*", @@ -598,7 +611,8 @@ "@types/http-assert": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.3.0.tgz", - "integrity": "sha512-RObYTpPMo0IY+ZksPtKHsXlYFRxsYIvUqd68e89Y7otDrXsjBy1VgMd53kxVV0JMsNlkCASjllFOlLlhxEv0iw==" + "integrity": "sha512-RObYTpPMo0IY+ZksPtKHsXlYFRxsYIvUqd68e89Y7otDrXsjBy1VgMd53kxVV0JMsNlkCASjllFOlLlhxEv0iw==", + "dev": true }, "@types/inquirer": { "version": "0.0.43", @@ -631,7 +645,8 @@ "@types/keygrip": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.1.tgz", - "integrity": "sha1-/1QEYtL7TQqIRBzq8n0oewHD2Hg=" + "integrity": "sha1-/1QEYtL7TQqIRBzq8n0oewHD2Hg=", + "dev": true }, "@types/klaw": { "version": "2.1.1", @@ -646,6 +661,7 @@ "version": "2.0.47", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.0.47.tgz", "integrity": "sha512-llhCaHNWKFDMx1GCrqwgsWgUO+C4Da0SccbgevHIYOKVxwegEjFzl0WaMWHk3wWx0P0AdqHR+gQYZ2ZAb0ez0Q==", + "dev": true, "requires": { "@types/accepts": "*", "@types/cookies": "*", @@ -659,6 +675,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@types/koa-bodyparser/-/koa-bodyparser-4.2.1.tgz", "integrity": "sha512-dd6mVT30OmGYIOmNRF3269Bv+IJ68AVrvYcPViB7bYnzxk7nZyfeAsUx96lvXmaTpOGF4XZ7WDCuSOd7Npi6pw==", + "dev": true, "requires": { "@types/koa": "*" } @@ -666,7 +683,8 @@ "@types/koa-compose": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.2.tgz", - "integrity": "sha1-3BBuAAu/kqOskA91bfRzRIh+6Ec=" + "integrity": "sha1-3BBuAAu/kqOskA91bfRzRIh+6Ec=", + "dev": true }, "@types/koa-router": { "version": "7.0.35", @@ -677,10 +695,21 @@ "@types/koa": "*" } }, + "@types/koa-websocket": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/koa-websocket/-/koa-websocket-5.0.3.tgz", + "integrity": "sha512-VgDEoySsNJTi+g0niUaUlnzYGJTeJmNoAxX+2cUGsHrVg19wczEN76TkQY+KGGh8r8vOSyky3qNqMXv1ncBdoA==", + "dev": true, + "requires": { + "@types/koa": "*", + "@types/koa-compose": "*", + "@types/ws": "*" + } + }, "@types/lodash": { - "version": "4.14.117", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.117.tgz", - "integrity": "sha512-xyf2m6tRbz8qQKcxYZa7PA4SllYcay+eh25DN3jmNYY6gSTL7Htc/bttVdkqj2wfJGbeWlQiX8pIyJpKU+tubw==", + "version": "4.14.118", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.118.tgz", + "integrity": "sha512-iiJbKLZbhSa6FYRip/9ZDX6HXhayXLDGY2Fqws9cOkEQ6XeKfaxB0sC541mowZJueYyMnVUmmG+al5/4fCDrgw==", "dev": true }, "@types/log-symbols": { @@ -701,7 +730,8 @@ "@types/mime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", - "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==" + "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==", + "dev": true }, "@types/minimatch": { "version": "3.0.3", @@ -762,7 +792,8 @@ "@types/range-parser": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.2.tgz", - "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==" + "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==", + "dev": true }, "@types/rx": { "version": "4.1.1", @@ -900,6 +931,7 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", + "dev": true, "requires": { "@types/express-serve-static-core": "*", "@types/mime": "*" @@ -911,6 +943,25 @@ "integrity": "sha512-dA5z2WlP7uurAiveIWTDRgfr1U58Qdmo6doDeAyJlYFQ3vnUOW7BqJ+tl+M8FaLcflhrVvwIfzxJJvlz6anx4A==", "dev": true }, + "@types/superagent": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-3.8.4.tgz", + "integrity": "sha512-Dnh0Iw6NO55z1beXvlsvUrfk4cd9eL2nuTmUk+rAhSVCk10PGGFbqCCTwbau9D0d2W3DITiXl4z8VCqppGkMPQ==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.6.tgz", + "integrity": "sha512-qRvPP8dO7IBqJz8LaQ7/Lw2oo/geiDUPAMx/L+CQCkR9sN622O30XCH7RSyUmilyCSyjxyhJ7cEtd3hmwPwvhw==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, "@types/tar": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/tar/-/tar-4.0.0.tgz", @@ -2311,6 +2362,11 @@ "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=", "dev": true }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + }, "cookies": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.3.tgz", @@ -3406,6 +3462,11 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -5742,6 +5803,40 @@ } } }, + "koa-websocket": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-websocket/-/koa-websocket-5.0.1.tgz", + "integrity": "sha512-NRfUdC1fGH9P45lOEygdPxdrJZ1aHoB9Kcn06WST/g4NtqIggH+ZzfATMeDpF0unjsvwVzxjNJoHIRIP9wY7gg==", + "requires": { + "co": "^4.4.0", + "debug": "^3.1.0", + "koa-compose": "^4.0.0", + "ws": "^5.2.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, "kuler": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", @@ -6301,6 +6396,11 @@ "to-regex": "^3.0.2" } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, "mime-db": { "version": "1.37.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz", @@ -10087,6 +10187,47 @@ "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=", "dev": true }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "supertest": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.3.0.tgz", + "integrity": "sha512-dMQSzYdaZRSANH5LL8kX3UpgK9G1LRh/jnggs/TI0W2Sz7rkMx9Y48uia3K9NgcaWEV28tYkBnXE4tiFC77ygQ==", + "requires": { + "methods": "^1.1.2", + "superagent": "^3.8.3" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/garden-service/package.json b/garden-service/package.json index a1beec5006..2db97c5db7 100644 --- a/garden-service/package.json +++ b/garden-service/package.json @@ -23,7 +23,6 @@ ], "dependencies": { "@drubin/client-node": "0.7.1-rc1", - "@types/koa-bodyparser": "^4.2.1", "ansi-escapes": "^3.1.0", "async-exit-hook": "^2.0.1", "async-lock": "^1.1.3", @@ -59,6 +58,7 @@ "koa": "^2.6.2", "koa-bodyparser": "^4.2.1", "koa-router": "^7.4.0", + "koa-websocket": "^5.0.1", "lodash": "^4.17.11", "log-symbols": "^2.2.0", "moment": "^2.22.2", @@ -69,6 +69,7 @@ "split": "^1.0.1", "string-width": "^2.1.1", "strip-ansi": "^5.0.0", + "supertest": "^3.3.0", "sywac": "^1.2.1", "tar": "^4.4.6", "terminal-link": "^1.1.0", @@ -102,7 +103,9 @@ "@types/json-stringify-safe": "^5.0.0", "@types/klaw": "^2.1.1", "@types/koa": "^2.0.47", + "@types/koa-bodyparser": "^4.2.1", "@types/koa-router": "^7.0.35", + "@types/koa-websocket": "^5.0.3", "@types/lodash": "^4.14.117", "@types/log-symbols": "^2.0.0", "@types/log-update": "^2.0.0", @@ -115,6 +118,7 @@ "@types/path-is-inside": "^1.0.0", "@types/prettyjson": "0.0.28", "@types/string-width": "^2.0.0", + "@types/supertest": "^2.0.6", "@types/tar": "^4.0.0", "@types/uniqid": "^4.1.2", "@types/unzip": "^0.1.1", diff --git a/garden-service/src/cli/cli.ts b/garden-service/src/cli/cli.ts index 6ffcd9e55b..60fd919383 100644 --- a/garden-service/src/cli/cli.ts +++ b/garden-service/src/cli/cli.ts @@ -11,7 +11,6 @@ import { intersection, merge, range } from "lodash" import { resolve } from "path" import { safeDump } from "js-yaml" import { coreCommands } from "../commands/commands" - import { DeepPrimitiveMap } from "../config/common" import { getEnumKeys, shutdown, sleep } from "../util/util" import { @@ -24,8 +23,7 @@ import { StringParameter, } from "../commands/base" import { GardenError, InternalError, PluginError, toGardenError } from "../exceptions" -import { ContextOpts, Garden } from "../garden" - +import { Garden, GardenOpts } from "../garden" import { getLogger, Logger, LoggerType } from "../logger/logger" import { LogLevel } from "../logger/log-node" import { BasicTerminalWriter } from "../logger/writers/basic-terminal-writer" @@ -268,7 +266,7 @@ export class GardenCli { // entries (i.e. print new lines). const log = logger.placeholder() - const contextOpts: ContextOpts = { env, log } + const contextOpts: GardenOpts = { environmentName: env, log } if (command.noProject) { contextOpts.config = MOCK_CONFIG } diff --git a/garden-service/src/commands/build.ts b/garden-service/src/commands/build.ts index dffc2b2f30..a6bc4d351a 100644 --- a/garden-service/src/commands/build.ts +++ b/garden-service/src/commands/build.ts @@ -22,7 +22,7 @@ import { Module } from "../types/module" import { logHeader } from "../logger/util" const buildArguments = { - module: new StringsParameter({ + modules: new StringsParameter({ help: "Specify module(s) to build. Use comma as a separator to specify multiple modules.", }), } @@ -66,7 +66,8 @@ export class BuildCommand extends Command { { args, opts, garden, log }: CommandParams, ): Promise> { await garden.clearBuilds() - const modules = await garden.getModules(args.module) + + const modules = await garden.getModules(args.modules) const dependencyGraph = await garden.getDependencyGraph() const moduleNames = modules.map(m => m.name) diff --git a/garden-service/src/commands/get/get-config.ts b/garden-service/src/commands/get/get-config.ts index f376ff359d..002a521ef0 100644 --- a/garden-service/src/commands/get/get-config.ts +++ b/garden-service/src/commands/get/get-config.ts @@ -8,43 +8,15 @@ import * as yaml from "js-yaml" import { highlightYaml } from "../../util/util" -import { Provider } from "../../config/project" -import { PrimitiveMap } from "../../config/common" -import { Module } from "../../types/module" import { Command, CommandResult, CommandParams } from "../base" - -interface ConfigOutput { - environmentName: string - providers: Provider[] - variables: PrimitiveMap - modules: Module[] -} +import { ConfigDump } from "../../garden" export class GetConfigCommand extends Command { name = "config" help = "Outputs the fully resolved configuration for this project and environment." - async action({ garden, log }: CommandParams): Promise> { - const modules = await garden.getModules() - - // Remove circular references and superfluous keys. - for (const module of modules) { - delete module._ConfigType - - for (const service of module.services) { - delete service.module - } - for (const task of module.tasks) { - delete task.module - } - } - - const config: ConfigOutput = { - environmentName: garden.environment.name, - providers: garden.environment.providers, - variables: garden.environment.variables, - modules, - } + async action({ garden, log }: CommandParams): Promise> { + const config = await garden.dumpConfig() const yamlConfig = yaml.safeDump(config, { noRefs: true, skipInvalid: true }) diff --git a/garden-service/src/commands/serve.ts b/garden-service/src/commands/serve.ts index 8e03147ddf..aa6b1d05c3 100644 --- a/garden-service/src/commands/serve.ts +++ b/garden-service/src/commands/serve.ts @@ -11,7 +11,7 @@ import { LoggerType } from "../logger/logger" import { IntegerParameter } from "./base" import { Command, CommandResult, CommandParams } from "./base" import { sleep } from "../util/util" -import { startServer } from "../server" +import { startServer } from "../server/server" const serveArgs = {} diff --git a/garden-service/src/garden.ts b/garden-service/src/garden.ts index 01812d228d..3a2a65b266 100644 --- a/garden-service/src/garden.ts +++ b/garden-service/src/garden.ts @@ -131,9 +131,9 @@ export type ModuleActionMap = { } } -export interface ContextOpts { +export interface GardenOpts { config?: GardenConfig, - env?: string, + environmentName?: string, log?: LogEntry, plugins?: Plugins, } @@ -169,7 +169,7 @@ export class Garden { variables: PrimitiveMap, public readonly projectSources: SourceConfig[] = [], public readonly buildDir: BuildDir, - log?: LogEntry, + public readonly opts: GardenOpts, ) { // make sure we're on a supported platform const currentPlatform = platform() @@ -184,7 +184,7 @@ export class Garden { } this.modulesScanned = false - this.log = log || getLogger().placeholder() + this.log = opts.log || getLogger().placeholder() // TODO: Support other VCS options. this.vcs = new GitHandler(this.projectRoot) this.localConfigStore = new LocalConfigStore(this.projectRoot) @@ -212,9 +212,10 @@ export class Garden { } static async factory( - this: T, currentDirectory: string, { env, config, log, plugins = {} }: ContextOpts = {}, + this: T, currentDirectory: string, opts: GardenOpts = {}, ): Promise> { let parsedConfig: GardenConfig + let { environmentName, config, plugins = {} } = opts if (config) { parsedConfig = validate(config, configSchema, { context: "root configuration" }) @@ -248,12 +249,12 @@ export class Garden { sources: projectSources, } = parsedConfig.project! - if (!env) { - env = defaultEnvironment + if (!environmentName) { + environmentName = defaultEnvironment } - const parts = env.split(".") - const environmentName = parts[0] + const parts = environmentName.split(".") + environmentName = parts[0] const namespace = parts.slice(1).join(".") || DEFAULT_NAMESPACE const environmentConfig = findByName(environments, environmentName) @@ -261,7 +262,7 @@ export class Garden { if (!environmentConfig) { throw new ParameterError(`Project ${projectName} does not specify environment ${environmentName}`, { projectName, - env, + environmentName, definedEnvironments: getNames(environments), }) } @@ -269,7 +270,7 @@ export class Garden { if (!environmentConfig.providers || environmentConfig.providers.length === 0) { throw new ConfigurationError(`Environment '${environmentName}' does not specify any providers`, { projectName, - env, + environmentName, environmentConfig, }) } @@ -300,7 +301,7 @@ export class Garden { variables, projectSources, buildDir, - log, + opts, ) as InstanceType // Register plugins @@ -1088,5 +1089,35 @@ export class Garden { } } + public async dumpConfig(): Promise { + const modules = await this.getModules() + + // Remove circular references and superfluous keys. + for (const module of modules) { + delete module._ConfigType + + for (const service of module.services) { + delete service.module + } + for (const task of module.tasks) { + delete task.module + } + } + + return { + environmentName: this.environment.name, + providers: this.environment.providers, + variables: this.environment.variables, + modules: sortBy(modules, "name"), + } + } + //endregion } + +export interface ConfigDump { + environmentName: string + providers: Provider[] + variables: PrimitiveMap + modules: Module[] +} diff --git a/garden-service/src/plugins/kubernetes/system.ts b/garden-service/src/plugins/kubernetes/system.ts index 4c9f191927..53ad297fa7 100644 --- a/garden-service/src/plugins/kubernetes/system.ts +++ b/garden-service/src/plugins/kubernetes/system.ts @@ -22,7 +22,7 @@ export function isSystemGarden(provider: KubernetesProvider): boolean { export async function getSystemGarden(provider: KubernetesProvider): Promise { return Garden.factory(systemProjectPath, { - env: "default", + environmentName: "default", config: { version: "0", dirname: "system", diff --git a/garden-service/src/plugins/openfaas/openfaas.ts b/garden-service/src/plugins/openfaas/openfaas.ts index 2d5bda90f4..c023f342b6 100644 --- a/garden-service/src/plugins/openfaas/openfaas.ts +++ b/garden-service/src/plugins/openfaas/openfaas.ts @@ -434,7 +434,7 @@ export async function getOpenFaasGarden(ctx: PluginContext): Promise { // TODO: allow passing variables/parameters here to be parsed as part of the garden.yml project config // (this would allow us to use a garden.yml for the project config, instead of speccing it here) return Garden.factory(systemProjectPath, { - env: "default", + environmentName: "default", config: { version: "0", dirname: "system", diff --git a/garden-service/src/process.ts b/garden-service/src/process.ts index e446d8bb9d..04c1809aa8 100644 --- a/garden-service/src/process.ts +++ b/garden-service/src/process.ts @@ -19,7 +19,7 @@ import { registerCleanupFunction } from "./util/util" import { isModuleLinked } from "./util/ext-source-util" import { Garden } from "./garden" import { LogEntry } from "./logger/log-entry" -import { startServer } from "./server" +import { startServer } from "./server/server" export type ProcessHandler = (module: Module) => Promise diff --git a/garden-service/src/server.ts b/garden-service/src/server/commands.ts similarity index 52% rename from garden-service/src/server.ts rename to garden-service/src/server/commands.ts index e8e621ed8f..191f851713 100644 --- a/garden-service/src/server.ts +++ b/garden-service/src/server/commands.ts @@ -6,99 +6,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import chalk from "chalk" -import Koa = require("koa") -import Router = require("koa-router") -import bodyParser = require("koa-bodyparser") -import dedent = require("dedent") import Joi = require("joi") -import getPort = require("get-port") -import { Command, Parameters } from "./commands/base" -import { validate } from "./config/common" -import { coreCommands } from "./commands/commands" +import Koa = require("koa") +import { Command, Parameters } from "../commands/base" +import { validate } from "../config/common" +import { coreCommands } from "../commands/commands" import { mapValues, omitBy } from "lodash" -import { Garden } from "./garden" -import { LogLevel } from "./logger/log-node" - -/** - * Start an HTTP server that exposes commands and events for the given Garden instance. - * - * NOTE: - * If `port` is not specified, a random free port is chosen. This is done so that a process can always create its - * own server, but we won't need that functionality once we run a shared service across commands. - */ -export async function startServer(garden: Garden, port?: number) { - // prepare request-command map - const commands = await prepareCommands() - - const app = new Koa() - const http = new Router() - - /** - * HTTP API endpoint (POST /api) - * - * We don't expose a different route per command, but rather accept a JSON object via POST on /api - * with a `command` key. The API wouldn't be RESTful in any meaningful sense anyway, and this - * means we can keep a consistent format across mechanisms. - */ - http.post("/api", async (ctx) => { - // TODO: set response code when errors are in result object? - const result = await resolveRequest(ctx, garden, commands, ctx.request.body) - - ctx.response.body = result - }) - - /** - * Dashboard endpoint (GET /) - * - * TODO: flesh this out, just a placeholder - */ - http.get("/", async (ctx) => { - const status = await resolveRequest(ctx, garden, commands, { - command: "get.status", - }) - - ctx.response.body = dedent` - - -

Project status

-
-      ${JSON.stringify(status.result, null, 4)}
-        
- - - ` - }) - - app.use(bodyParser()) - app.use(http.routes()) - app.use(http.allowedMethods()) +import { Garden } from "../garden" +import { LogLevel } from "../logger/log-node" - // TODO: implement WebSocket endpoint - // const ws = new Router() - // ws.get("/ws", async (ctx) => { - - // }) - // app.use(ws.routes()) - // app.use(ws.allowedMethods()) - - // TODO: remove this once we stop running a server per CLI command - if (!port) { - port = await getPort() - } - - // TODO: secure the server - app.listen(port) - - const url = `http://localhost:${port}` - - garden.log.info({ - emoji: "sunflower", - msg: chalk.cyan("Garden dashboard and API server running on ") + url, - }) -} - -interface CommandMap { +export interface CommandMap { [key: string]: { command: Command, requestSchema: Joi.ObjectSchema, @@ -114,6 +31,7 @@ const baseRequestSchema = Joi.object() .example("get.status"), parameters: Joi.object() .keys({}) + .unknown(true) .default(() => ({}), "{}") .description("The parameters for the command."), }) @@ -121,7 +39,9 @@ const baseRequestSchema = Joi.object() /** * Validate and map a request body to a Command, execute its action, and return its result. */ -async function resolveRequest(ctx: Koa.Context, garden: Garden, commands: CommandMap, request: any) { +export async function resolveRequest( + ctx: Koa.Context, garden: Garden, commands: CommandMap, request: any, +) { // Perform basic validation and find command. try { request = validate(request, baseRequestSchema, { context: "API request" }) @@ -147,19 +67,19 @@ async function resolveRequest(ctx: Koa.Context, garden: Garden, commands: Comman // TODO: Creating a new Garden instance is not ideal, // need to revisit once we've refactored the TaskGraph and config resolution. - const cmdGarden = await Garden.factory(garden.projectRoot, { log: garden.log }) + const cmdGarden = await Garden.factory(garden.projectRoot, garden.opts) // We generally don't want actions to log anything in the server. - const log = garden.log.placeholder(LogLevel.silly) + const cmdLog = garden.log.placeholder(LogLevel.silly) const cmdArgs = mapParams(ctx, request.parameters, command.arguments) const cmdOpts = mapParams(ctx, request.parameters, command.options) - return command.action({ garden: cmdGarden, log, args: cmdArgs, opts: cmdOpts }) + return command.action({ garden: cmdGarden, log: cmdLog, args: cmdArgs, opts: cmdOpts }) // TODO: validate result schema } -async function prepareCommands(): Promise { +export async function prepareCommands(): Promise { const commands: CommandMap = {} function addCommand(command: Command) { @@ -169,7 +89,8 @@ async function prepareCommands(): Promise { .keys({ ...paramsToJoi(command.arguments), ...paramsToJoi(command.options), - }), + }) + .unknown(false), }) commands[command.getKey()] = { @@ -212,7 +133,7 @@ function mapParams(ctx: Koa.Context, values: object, params?: Parameters) { return {} } - return mapValues(params, (p, key) => { + const output = mapValues(params, (p, key) => { if (p.cliOnly) { return p.defaultValue } @@ -225,4 +146,6 @@ function mapParams(ctx: Koa.Context, values: object, params?: Parameters) { } return result.value }) + + return output } diff --git a/garden-service/src/server/server.ts b/garden-service/src/server/server.ts new file mode 100644 index 0000000000..9970eab27d --- /dev/null +++ b/garden-service/src/server/server.ts @@ -0,0 +1,103 @@ +/* + * 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 chalk from "chalk" +import Koa = require("koa") +import Router = require("koa-router") +import websockify = require("koa-websocket") +import bodyParser = require("koa-bodyparser") +import dedent = require("dedent") +import getPort = require("get-port") +import { Garden } from "../garden" +import { addWebsocketEndpoint } from "./websocket" +import { prepareCommands, resolveRequest } from "./commands" + +/** + * Start an HTTP server that exposes commands and events for the given Garden instance. + * + * Please look at the tests for usage examples. + * + * NOTE: + * If `port` is not specified, a random free port is chosen. This is done so that a process can always create its + * own server, but we won't need that functionality once we run a shared service across commands. + */ +export async function startServer(garden: Garden, port?: number) { + const app = await createApp(garden) + + // TODO: remove this once we stop running a server per CLI command + if (!port) { + port = await getPort() + } + + // TODO: secure the server + const server = app.listen(port) + + const url = `http://localhost:${port}` + + garden.log.info({ + emoji: "sunflower", + msg: chalk.cyan("Garden dashboard and API server running on ") + url, + }) + + return server +} + +export async function createApp(garden: Garden) { + const log = garden.log.placeholder() + + // prepare request-command map + const commands = await prepareCommands() + + const app = websockify(new Koa()) + const http = new Router() + + /** + * HTTP API endpoint (POST /api) + * + * We don't expose a different route per command, but rather accept a JSON object via POST on /api + * with a `command` key. The API wouldn't be RESTful in any meaningful sense anyway, and this + * means we can keep a consistent format across mechanisms. + */ + http.post("/api", async (ctx) => { + // TODO: set response code when errors are in result object? + const result = await resolveRequest(ctx, garden, commands, ctx.request.body) + + ctx.status = 200 + ctx.response.body = result + }) + + /** + * Dashboard endpoint (GET /) + * + * TODO: flesh this out, just a placeholder + */ + http.get("/", async (ctx) => { + const status = await resolveRequest(ctx, garden, commands, { + command: "get.status", + }) + + ctx.response.body = dedent` + + +

Project status

+
+      ${JSON.stringify(status.result, null, 4)}
+        
+ + + ` + }) + + app.use(bodyParser()) + app.use(http.routes()) + app.use(http.allowedMethods()) + + addWebsocketEndpoint(app, garden, log, commands) + + return app +} diff --git a/garden-service/src/server/websocket.ts b/garden-service/src/server/websocket.ts new file mode 100644 index 0000000000..6fc66d1d87 --- /dev/null +++ b/garden-service/src/server/websocket.ts @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import Joi = require("joi") +import Router = require("koa-router") +import websockify = require("koa-websocket") +import { Garden } from "../garden" +import { CommandResult } from "../commands/base" +import { EventName, Events } from "../events" +import { ValueOf } from "../util/util" +import { resolveRequest, CommandMap } from "./commands" +import { omit } from "lodash" +import { LogEntry } from "../logger/log-entry" +import { toGardenError, GardenError } from "../exceptions" + +/** + * Add the /ws endpoint to the Koa app. Every event emitted to the event bus is forwarded to open + * Websocket connections, and clients can send commands over the socket and receive results on the + * same connection. + */ +export function addWebsocketEndpoint(app: websockify.App, garden: Garden, log: LogEntry, commands: CommandMap) { + const ws = new Router() + + ws.get("/ws", async (ctx) => { + // Helper to make JSON messages, make them type-safe, and to log errors. + const send = (type: T, payload: ServerWebsocketMessages[T]) => { + ctx.websocket.send(JSON.stringify({ type, ...payload }), (err) => { + if (err) { + const error = toGardenError(err) + log.error({ error }) + } + }) + } + + // Pipe everything from the event bus to the socket. + const eventListener = (name, payload) => send("event", { name, payload }) + garden.events.onAny(eventListener) + + // Make sure we clean up listeners when connections end. + // TODO: detect broken connections - https://github.com/websockets/ws#how-to-detect-and-close-broken-connections + ctx.websocket.on("close", () => { + garden.events.offAny(eventListener) + }) + + // Respond to commands. + ctx.websocket.on("message", (msg) => { + let request + + try { + request = JSON.parse(msg.toString()) + } catch { + return send("error", { message: "Could not parse message as JSON" }) + } + + const requestId = request.id + + try { + Joi.attempt(requestId, Joi.string().uuid().required()) + } catch { + return send("error", { message: "Message should contain an `id` field with a UUID value" }) + } + + try { + Joi.attempt(request.type, Joi.string().required()) + } catch { + return send("error", { message: "Message should contain a type field" }) + } + + if (request.type === "command") { + resolveRequest(ctx, garden, commands, omit(request, ["id", "type"])) + .then(result => { + send("commandResult", { requestId, result: result.result, errors: result.errors }) + }) + .catch(err => { + send("error", { requestId, message: err.message }) + }) + } else { + return send("error", { requestId, message: `Unsupported request type: ${request.type}` }) + } + }) + }) + + app.ws.use(ws.routes()) + app.ws.use(ws.allowedMethods()) +} + +interface ServerWebsocketMessages { + commandResult: { + requestId: string, + result: CommandResult, + errors?: GardenError[], + } + error: { + requestId?: string, + message: string, + } + event: { + name: EventName, + payload: ValueOf, + } +} + +type ServerWebsocketMessageType = keyof ServerWebsocketMessages diff --git a/garden-service/src/util/util.ts b/garden-service/src/util/util.ts index 9301b79417..fa0ffeb4ad 100644 --- a/garden-service/src/util/util.ts +++ b/garden-service/src/util/util.ts @@ -250,6 +250,38 @@ export function splitFirst(s: string, delimiter: string) { return [parts[0], parts.slice(1).join(delimiter)] } +/** + * Recursively process all values in the given input, + * walking through all object keys _and array items_. + */ +export function deepMap( + value: T | Iterable, fn: (value: any, key: string | number) => any, +): U | Iterable { + if (isArray(value)) { + return value.map(fn) + } else if (isPlainObject(value)) { + return mapValues(value, v => deepMap(v, fn)) + } else { + return value + } +} + +/** + * Recursively filter all keys and values in the given input, + * walking through all object keys _and array items_. + */ +export function deepFilter( + value: T | Iterable, fn: (value: any, key: string | number) => boolean, +): U | Iterable { + if (isArray(value)) { + return >value.filter(fn).map(v => deepFilter(v, fn)) + } else if (isPlainObject(value)) { + return mapValues(pickBy(value, fn), v => deepFilter(v, fn)) + } else { + return value + } +} + /** * Recursively resolves all promises in the given input, * walking through all object keys and array items. @@ -292,6 +324,14 @@ export function omitUndefined(o: object) { return pickBy(o, (v: any) => v !== undefined) } +/** + * Recursively go through an object or array and strip all keys with undefined values, as well as undefined + * values from arrays. Note: Also iterates through arrays recursively. + */ +export function deepOmitUndefined(obj: object) { + return deepFilter(obj, v => v !== undefined) +} + export function serializeObject(o: any): string { return Buffer.from(Cryo.stringify(o)).toString("base64") } diff --git a/garden-service/test/src/commands/build.ts b/garden-service/test/src/commands/build.ts index c900849d4e..87db286233 100644 --- a/garden-service/test/src/commands/build.ts +++ b/garden-service/test/src/commands/build.ts @@ -12,7 +12,7 @@ describe("commands.build", () => { const { result } = await command.action({ garden, log, - args: { module: undefined }, + args: { modules: undefined }, opts: { watch: false, force: true }, }) @@ -31,7 +31,7 @@ describe("commands.build", () => { const { result } = await command.action({ garden, log, - args: { module: ["module-b"] }, + args: { modules: ["module-b"] }, opts: { watch: false, force: true }, }) diff --git a/garden-service/test/src/commands/get/get-config.ts b/garden-service/test/src/commands/get/get-config.ts index cf31b2d7fe..f85f03f2f4 100644 --- a/garden-service/test/src/commands/get/get-config.ts +++ b/garden-service/test/src/commands/get/get-config.ts @@ -2,6 +2,7 @@ import { expect } from "chai" import { makeTestGardenA } from "../../../helpers" import { GetConfigCommand } from "../../../../src/commands/get/get-config" import { isSubset } from "../../../../src/util/is-subset" +import { sortBy } from "lodash" describe("GetConfigCommand", () => { const pluginName = "test-plugin" @@ -23,7 +24,7 @@ describe("GetConfigCommand", () => { environmentName: garden.environment.name, providers: garden.environment.providers, variables: garden.environment.variables, - modules: await garden.getModules(), + modules: sortBy(await garden.getModules(), "name"), } expect(isSubset(config, res.result)).to.be.true diff --git a/garden-service/test/src/garden.ts b/garden-service/test/src/garden.ts index 9409efa6fc..b6762f8787 100644 --- a/garden-service/test/src/garden.ts +++ b/garden-service/test/src/garden.ts @@ -89,15 +89,15 @@ describe("Garden", () => { }) it("should throw if the specified environment isn't configured", async () => { - await expectError(async () => Garden.factory(projectRootA, { env: "bla" }), "parameter") + await expectError(async () => Garden.factory(projectRootA, { environmentName: "bla" }), "parameter") }) it("should throw if namespace starts with 'garden-'", async () => { - await expectError(async () => Garden.factory(projectRootA, { env: "garden-bla" }), "parameter") + await expectError(async () => Garden.factory(projectRootA, { environmentName: "garden-bla" }), "parameter") }) it("should throw if no provider is configured for the environment", async () => { - await expectError(async () => Garden.factory(projectRootA, { env: "other" }), "configuration") + await expectError(async () => Garden.factory(projectRootA, { environmentName: "other" }), "configuration") }) it("should throw if plugin module exports invalid name", async () => { diff --git a/garden-service/test/src/server/server.ts b/garden-service/test/src/server/server.ts new file mode 100644 index 0000000000..770983340e --- /dev/null +++ b/garden-service/test/src/server/server.ts @@ -0,0 +1,198 @@ +/* + * 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 { makeTestGardenA } from "../../helpers" +import { startServer } from "../../../src/server/server" +import { Server } from "http" +import { Garden } from "../../../src/garden" +import { expect } from "chai" +import { deepOmitUndefined } from "../../../src/util/util" +import * as uuid from "uuid" +import request = require("supertest") +import getPort = require("get-port") +import WebSocket = require("ws") + +describe("startServer", () => { + let garden: Garden + let server: Server + let port: number + + before(async () => { + port = await getPort() + garden = await makeTestGardenA() + server = await startServer(garden, port) + }) + + after(async () => { + server.close() + }) + + describe("GET /", () => { + // TODO: test dashboard endpoint + }) + + describe("POST /api", () => { + it("should 400 on non-JSON body", async () => { + await request(server).post("/api").send("foo").expect(400) + }) + + it("should 400 on invalid payload", async () => { + await request(server).post("/api").send({ foo: "bar" }).expect(400) + }) + + it("should 404 on invalid command", async () => { + await request(server).post("/api").send({ command: "foo" }).expect(404) + }) + + it("should execute a command and return its results", async () => { + const res = await request(server).post("/api").send({ command: "get.config" }).expect(200) + const config = await garden.dumpConfig() + expect(res.body.result).to.eql(deepOmitUndefined(config)) + }) + + it("should correctly map arguments and options to commands", async () => { + const res = await request(server).post("/api").send({ + command: "build", + parameters: { + modules: ["module-a"], + force: true, + }, + }).expect(200) + + expect(res.body.result).to.eql({ + "build.module-a": { + dependencyResults: {}, + description: "building module-a", + output: { + buildLog: "A", + fresh: true, + }, + type: "build", + }, + }) + }) + }) + + describe("/ws", () => { + let ws: WebSocket + + beforeEach((done) => { + ws = new WebSocket(`ws://localhost:${port}/ws`) + ws.on("open", () => { + done() + }) + ws.on("error", done) + }) + + afterEach(() => { + ws.close() + }) + + const onMessage = (cb: (req: object) => void) => { + ws.on("message", msg => cb(JSON.parse(msg.toString()))) + } + + it("should emit events from the event bus", (done) => { + onMessage((req) => { + expect(req).to.eql({ type: "event", name: "_test", payload: "foo" }) + done() + }) + garden.events.emit("_test", "foo") + }) + + it("should send error when a request is not valid JSON", (done) => { + onMessage((req) => { + expect(req).to.eql({ type: "error", message: "Could not parse message as JSON" }) + done() + }) + ws.send("ijdgkasdghlasdkghals") + }) + + it("should error when a request is missing an ID", (done) => { + onMessage((req) => { + expect(req).to.eql({ type: "error", message: "Message should contain an `id` field with a UUID value" }) + done() + }) + ws.send(JSON.stringify({ type: "command" })) + }) + + it("should error when a request has an invalid ID", (done) => { + onMessage((req) => { + expect(req).to.eql({ type: "error", message: "Message should contain an `id` field with a UUID value" }) + done() + }) + ws.send(JSON.stringify({ type: "command", id: "ksdhgalsdkjghalsjkg" })) + }) + + it("should error when a request has an invalid type", (done) => { + const id = uuid.v4() + onMessage((req) => { + expect(req).to.eql({ + type: "error", + requestId: id, + message: "Unsupported request type: foo", + }) + done() + }) + ws.send(JSON.stringify({ type: "foo", id })) + }) + + it("should execute a command and return its results", (done) => { + const id = uuid.v4() + + garden.dumpConfig() + .then(config => { + onMessage((req) => { + expect(req).to.eql({ + type: "commandResult", + requestId: id, + result: deepOmitUndefined(config), + }) + done() + }) + ws.send(JSON.stringify({ + type: "command", + id, + command: "get.config", + })) + }) + .catch(done) + }) + + it("should correctly map arguments and options to commands", (done) => { + const id = uuid.v4() + onMessage((req) => { + expect(req).to.eql({ + type: "commandResult", + requestId: id, + result: { + "build.module-a": { + dependencyResults: {}, + description: "building module-a", + output: { + buildLog: "A", + fresh: true, + }, + type: "build", + }, + }, + }) + done() + }) + ws.send(JSON.stringify({ + type: "command", + id, + command: "build", + parameters: { + modules: ["module-a"], + force: true, + }, + })) + }) + }) +}) diff --git a/garden-service/test/src/util/util.ts b/garden-service/test/src/util/util.ts index 7235d8c6fa..fda0a810c9 100644 --- a/garden-service/test/src/util/util.ts +++ b/garden-service/test/src/util/util.ts @@ -1,7 +1,15 @@ import { expect } from "chai" import { join } from "path" import { getDataDir } from "../../helpers" -import { scanDirectory, getChildDirNames, toCygwinPath, pickKeys, getEnvVarName } from "../../../src/util/util" +import { + scanDirectory, + getChildDirNames, + toCygwinPath, + pickKeys, + getEnvVarName, + deepOmitUndefined, + deepFilter, +} from "../../../src/util/util" import { expectError } from "../../helpers" describe("util", () => { @@ -83,4 +91,86 @@ describe("util", () => { }) }) }) + + describe("deepFilter", () => { + const fn = v => v !== 99 + + it("should filter keys in a simple object", () => { + const obj = { + a: 1, + b: 2, + c: 99, + } + expect(deepFilter(obj, fn)).to.eql({ a: 1, b: 2 }) + }) + + it("should filter keys in a nested object", () => { + const obj = { + a: 1, + b: 2, + c: { d: 3, e: 99 }, + } + expect(deepFilter(obj, fn)).to.eql({ a: 1, b: 2, c: { d: 3 } }) + }) + + it("should filter values in lists", () => { + const obj = { + a: 1, + b: 2, + c: [3, 99], + } + expect(deepFilter(obj, fn)).to.eql({ a: 1, b: 2, c: [3] }) + }) + + it("should filter keys in objects in lists", () => { + const obj = { + a: 1, + b: 2, + c: [ + { d: 3, e: 99 }, + ], + } + expect(deepFilter(obj, fn)).to.eql({ a: 1, b: 2, c: [{ d: 3 }] }) + }) + }) + + describe("deepOmitUndefined", () => { + it("should omit keys with undefined values in a simple object", () => { + const obj = { + a: 1, + b: 2, + c: undefined, + } + expect(deepOmitUndefined(obj)).to.eql({ a: 1, b: 2 }) + }) + + it("should omit keys with undefined values in a nested object", () => { + const obj = { + a: 1, + b: 2, + c: { d: 3, e: undefined }, + } + expect(deepOmitUndefined(obj)).to.eql({ a: 1, b: 2, c: { d: 3 } }) + }) + + it("should omit undefined values in lists", () => { + const obj = { + a: 1, + b: 2, + c: [3, undefined], + } + expect(deepOmitUndefined(obj)).to.eql({ a: 1, b: 2, c: [3] }) + }) + + it("should omit undefined values in objects in lists", () => { + const obj = { + a: 1, + b: 2, + c: [ + { d: 3, e: undefined }, + ], + } + expect(deepOmitUndefined(obj)).to.eql({ a: 1, b: 2, c: [{ d: 3 }] }) + }) + }) })