From 18017705d042c97e8ebb4770b9890ae05ce1ffbf Mon Sep 17 00:00:00 2001 From: Cleve Stuart <90649124+cleve-fauna@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:39:08 -0800 Subject: [PATCH] Command to start a local container (#491) * RFC local conatiner commands * Log progress * Use stderrStream * Remove -q * No jumps when writing output * Fix build * Improvements for states * Marke awaits in loop OK * Refactor * Refactor * Refactor * Initial tests * Initial tests * Initial tests * Fix stuff * More tests * More tests * Move test file to be for local command. Remove ability to pick the image * Apply suggestions from code review Co-authored-by: echo-bravo-yahoo --------- Co-authored-by: echo-bravo-yahoo --- package-lock.json | 260 +++++++++++++++++++++- package.json | 3 +- src/cli.mjs | 2 + src/commands/local.mjs | 55 +++++ src/config/setup-container.mjs | 9 + src/config/setup-test-container.mjs | 9 + src/lib/auth/oauth-client.mjs | 10 +- src/lib/docker-containers.mjs | 325 ++++++++++++++++++++++++++++ test/helpers.mjs | 7 +- test/local.mjs | 212 ++++++++++++++++++ test/schema/diff.mjs | 20 +- test/schema/status.mjs | 2 +- 12 files changed, 898 insertions(+), 16 deletions(-) create mode 100644 src/commands/local.mjs create mode 100644 src/lib/docker-containers.mjs create mode 100644 test/local.mjs diff --git a/package-lock.json b/package-lock.json index 834c2444..55d077e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@shikijs/cli": "^1.24.0", "awilix": "^12.0.2", "chalk": "^5.3.0", + "dockerode": "^4.0.2", "eslint": "^9.12.0", "esprima": "^4.0.1", "fauna": "^2.4.0", @@ -61,6 +62,12 @@ "node": ">=20.0.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, "node_modules/@cloudcmd/stub": { "version": "2.3.4", "dev": true, @@ -928,6 +935,15 @@ "node": ">=6" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -976,6 +992,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -987,6 +1012,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/boxen": { "version": "5.1.2", "license": "MIT", @@ -1058,6 +1094,39 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "license": "MIT", @@ -1194,6 +1263,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-boxes": { "version": "2.2.1", "license": "MIT", @@ -1306,6 +1381,20 @@ "url": "https://github.com/yeoman/configstore?sponsor=1" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-fetch": { "version": "3.1.8", "license": "MIT", @@ -1452,6 +1541,35 @@ "node": ">= 8.3" } }, + "node_modules/docker-modem": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "docker-modem": "^5.0.3", + "tar-fs": "~2.0.1" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "license": "MIT", @@ -1482,6 +1600,15 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/esbuild": { "version": "0.24.0", "dev": true, @@ -1892,6 +2019,12 @@ "node": ">=0.10.0" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "dev": true, @@ -2122,6 +2255,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "license": "MIT", @@ -2161,7 +2314,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -2707,6 +2859,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mocha": { "version": "10.7.3", "dev": true, @@ -2870,6 +3028,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT" @@ -2946,7 +3111,6 @@ }, "node_modules/once": { "version": "1.4.0", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3176,6 +3340,16 @@ "version": "1.2.4", "license": "ISC" }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "license": "MIT", @@ -3247,6 +3421,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -3374,7 +3562,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "dev": true, "funding": [ { "type": "github", @@ -3509,6 +3696,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", @@ -3597,6 +3816,34 @@ "node": ">=8" } }, + "node_modules/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.0.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/text-table": { "version": "0.2.0", "license": "MIT" @@ -3647,6 +3894,12 @@ "version": "2.7.0", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "license": "MIT", @@ -3996,7 +4249,6 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "dev": true, "license": "ISC" }, "node_modules/xdg-basedir": { diff --git a/package.json b/package.json index 16fc5d5f..72b56f87 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@shikijs/cli": "^1.24.0", "awilix": "^12.0.2", "chalk": "^5.3.0", + "dockerode": "^4.0.2", "eslint": "^9.12.0", "esprima": "^4.0.1", "fauna": "^2.4.0", @@ -79,7 +80,7 @@ "pretest:ci": "npm run build:app", "test:ci": "mocha --forbid-only --recursive ./test --require ./test/mocha-root-hooks.mjs --reporter mocha-multi-reporters --reporter-options configFile=./test/config/reporter.json", "build": "npm run build:app && npm run build:sea", - "build:app": "esbuild --bundle ./src/user-entrypoint.mjs --platform=node --outfile=./dist/cli.cjs --format=cjs --inject:./sea/import-meta-url.js --define:import.meta.url=importMetaUrl --define:process.env.NODE_ENV=\\\"production\\\"", + "build:app": "esbuild --loader:.node=file --bundle ./src/user-entrypoint.mjs --platform=node --outfile=./dist/cli.cjs --format=cjs --inject:./sea/import-meta-url.js --define:import.meta.url=importMetaUrl --define:process.env.NODE_ENV=\\\"production\\\"", "build:sea": "node ./sea/build.cjs", "format": "prettier -w --log-level silent .", "format:check": "prettier -c .", diff --git a/src/cli.mjs b/src/cli.mjs index e44c537d..a4d70b4a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -4,6 +4,7 @@ import chalk from "chalk"; import yargs from "yargs"; import databaseCommand from "./commands/database/database.mjs"; +import localCommand from "./commands/local.mjs"; import loginCommand from "./commands/login.mjs"; import queryCommand from "./commands/query.mjs"; import schemaCommand from "./commands/schema/schema.mjs"; @@ -108,6 +109,7 @@ function buildYargs(argvInput) { .command(loginCommand) .command(schemaCommand) .command(databaseCommand) + .command(localCommand) .demandCommand() .strictCommands(true) .options({ diff --git a/src/commands/local.mjs b/src/commands/local.mjs new file mode 100644 index 00000000..5a3f7c84 --- /dev/null +++ b/src/commands/local.mjs @@ -0,0 +1,55 @@ +import { ensureContainerRunning } from "../lib/docker-containers.mjs"; + +/** + * Starts the local Fauna container + * @param {import('yargs').Arguments} argv The arguments from yargs + * @returns {Promise} a promise that resolves when the container is ready. + * It will reject if the container is not ready after the maximum number of attempts. + */ +async function startLocal(argv) { + await ensureContainerRunning({ + imageName: argv.image, + containerName: argv.name, + hostPort: argv.hostPort, + containerPort: argv.containerPort, + pull: argv.pull, + }); +} + +/** + * Builds the yargs command for the local command + * @param {import('yargs').Argv} yargs The yargs instance + * @returns {import('yargs').Argv} The yargs instance + */ +function buildLocalCommand(yargs) { + return yargs.options({ + containerPort: { + describe: "The port inside the container Fauna listens on.", + type: "number", + default: "8443", + }, + hostPort: { + describe: + "The port on the host machine mapped to the container's port. This is the port you'll connect to Fauna on.", + type: "number", + default: "8443", + }, + name: { + describe: "The name to give the container", + type: "string", + default: "faunadb", + }, + pull: { + describe: "Pull the latest image before starting the container.", + type: "boolean", + default: true, + }, + }); +} + +export default { + command: "local", + describe: "Start a local Fauna container", + builder: buildLocalCommand, + handler: startLocal, +}; diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index 0da49749..2ec113e8 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -7,6 +7,7 @@ import { exit } from "node:process"; import { confirm } from "@inquirer/prompts"; import * as awilix from "awilix"; import { Lifetime } from "awilix"; +import Docker from "dockerode"; import fauna from "fauna"; import faunadb from "faunadb"; import open from "open"; @@ -74,6 +75,14 @@ export const injectables = { // generic lib (homemade utilities) parseYargs: awilix.asValue(parseYargs), logger: awilix.asFunction(buildLogger, { lifetime: Lifetime.SINGLETON }), + docker: awilix.asFunction( + () => { + const dockerInstance = new Docker(); // Create instance + // If Docker requires additional async setup, perform it here and return a promise + return dockerInstance; + }, + { lifetime: Lifetime.SINGLETON }, + ), oauthClient: awilix.asClass(OAuthClient, { lifetime: Lifetime.SCOPED }), makeAccountRequest: awilix.asValue(makeAccountRequest), makeFaunaRequest: awilix.asValue(makeRetryableFaunaRequest), diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index 51cbaf24..95f26151 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -81,6 +81,15 @@ export function setupTestContainer() { getSession: stub(), })), oauthClient: awilix.asFunction(stub()), + docker: awilix.asValue({ + createContainer: stub(), + getContainer: stub(), + listContainers: stub(), + modem: { + followProgress: stub(), + }, + pull: stub(), + }), credentials: awilix.asClass(stub()).singleton(), errorHandler: awilix.asValue((error, exitCode) => { error.code = exitCode; diff --git a/src/lib/auth/oauth-client.mjs b/src/lib/auth/oauth-client.mjs index 6bc5ad19..5c986f5c 100644 --- a/src/lib/auth/oauth-client.mjs +++ b/src/lib/auth/oauth-client.mjs @@ -6,10 +6,10 @@ import { container } from "../../cli.mjs"; import SuccessPage from "./successPage.mjs"; // Default to prod client id and secret -const clientId = process.env.FAUNA_CLIENT_ID ?? "Aq4_G0mOtm_F1fK3PuzE0k-i9F0"; +const CLIENT_ID = process.env.FAUNA_CLIENT_ID ?? "Aq4_G0mOtm_F1fK3PuzE0k-i9F0"; // Native public clients are not confidential. The client secret is not used beyond // client identification. https://datatracker.ietf.org/doc/html/rfc8252#section-8.5 -const clientSecret = +const CLIENT_SECRET = process.env.FAUNA_CLIENT_SECRET ?? "2W9eZYlyN5XwnpvaP3AwOfclrtAjTXncH6k-bdFq1ZV0hZMFPzRIfg"; const REDIRECT_URI = `http://127.0.0.1`; @@ -28,7 +28,7 @@ class OAuthClient { getOAuthParams() { return { - client_id: clientId, // eslint-disable-line camelcase + client_id: CLIENT_ID, // eslint-disable-line camelcase redirect_uri: `${REDIRECT_URI}:${this.port}`, // eslint-disable-line camelcase code_challenge: this.codeChallenge, // eslint-disable-line camelcase code_challenge_method: "S256", // eslint-disable-line camelcase @@ -40,8 +40,8 @@ class OAuthClient { getTokenParams() { return { - clientId, - clientSecret, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, authCode: this.authCode, redirectURI: `${REDIRECT_URI}:${this.port}`, codeVerifier: this.codeVerifier, diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs new file mode 100644 index 00000000..41f3485a --- /dev/null +++ b/src/lib/docker-containers.mjs @@ -0,0 +1,325 @@ +import { container } from "../cli.mjs"; +import { CommandError } from "./errors.mjs"; + +const IMAGE_NAME = "fauna/faunadb:latest"; + +/** + * Ensures the container is running + * @param {string} imageName The name of the image to create the container from + * @param {string} containerName The name of the container to start + * @param {number} hostPort The port on the host machine mapped to the container's port + * @param {number} containerPort The port inside the container Fauna listens on + * @param {boolean} pull Whether to pull the latest image + * @returns {Promise} + */ +export async function ensureContainerRunning({ + containerName, + hostPort, + containerPort, + pull, +}) { + const logger = container.resolve("logger"); + if (pull) { + await pullImage(IMAGE_NAME); + } + const logStream = await startContainer({ + imageName: IMAGE_NAME, + containerName, + hostPort, + containerPort, + }); + logger.stderr( + `[StartContainer] Container '${containerName}' started. Monitoring HealthCheck for readiness.`, + ); + await waitForHealthCheck({ + url: `http://localhost:${hostPort}`, + logStream, + }); + logger.stderr( + `[ContainerReady] Container '${containerName}' is up and healthy.`, + ); +} + +/** + * Pulls the latest version of the given image + * @param {string} imageName The name of the image to pull + * @returns {Promise} a promise that resolves when the image is pulled. It will + * reject if there is an error pulling the image. + */ +async function pullImage(imageName) { + const docker = container.resolve("docker"); + const logger = container.resolve("logger"); // Dependency injection for logger + logger.stderr(`[PullImage] Pulling image '${imageName}'...\n`); + + try { + const stream = await docker.pull(imageName); + const layers = {}; // To track progress by layer + let numLines = 0; // Tracks the number of lines being displayed + let lastUpdate = 0; + + return new Promise((resolve, reject) => { + docker.modem.followProgress( + stream, + (err, output) => { + writePullProgress(layers, numLines); + if (err) { + reject(err); + } else { + // Move to the reserved space for completion message + logger.stderr(`[PullImage] Image '${imageName}' pulled.`); + resolve(output); + } + }, + (event) => { + if (event.id) { + // Update specific layer progress + layers[event.id] = + `${event.id}: ${event.status} ${event.progress || ""}`; + } + if (Date.now() - lastUpdate > 100) { + numLines = writePullProgress(layers, numLines); + lastUpdate = Date.now(); + } + }, + ); + }); + } catch (error) { + logger.stderr( + `[PullImage] Error pulling image ${imageName}: ${error.message}`, + ); + throw error; + } +} + +/** + * Writes the progress of the image pull to stderr. + * It clears the lines that have already been written and updates them in place + * so that the progress is displayed in the same place with no "flicker". + * @param {Object} layers The layers of the image + * @param {number} numLines The number of lines to clear and update + * @returns {number} The number of lines written. Pass this value back into + * the next call to writePullProgress so that it can update the lines in place. + */ +function writePullProgress(layers, numLines) { + const logger = container.resolve("logger"); + const stderrStream = container.resolve("stderrStream"); + // Clear only the necessary lines and update them in place + stderrStream.write(`\x1B[${numLines}A`); + numLines = 0; + // clear the screen + stderrStream.write("\x1B[0J"); + Object.values(layers).forEach((line) => { + logger.stderr(line); + numLines++; + }); + return numLines; +} + +/** + * Finds a container by name + * @param {string} containerName The name of the container to find + * @returns {Promise} The container object if found, otherwise undefined. + * The container object has the following properties: + * - Id: The ID of the container + * - Names: The names of the container + * - State: The state of the container + */ +async function findContainer(containerName) { + const docker = container.resolve("docker"); + const logger = container.resolve("logger"); // Dependency injection for logger + logger.stderr( + `[GetContainerState] Checking state for container '${containerName}'...`, + ); + const filters = JSON.stringify({ name: [containerName] }); + const containers = await docker.listContainers({ all: true, filters }); + return containers.length > 0 ? containers[0] : null; +} + +/** + * Creates a container + * @param {string} imageName The name of the image to create the container from + * @param {string} containerName The name of the container to start + * @param {number} hostPort The port on the host machine mapped to the container's port + * @param {number} containerPort The port inside the container Fauna listens on + * @returns {Promise} The container object + */ +async function createContainer({ + imageName, + containerName, + hostPort, + containerPort, +}) { + const docker = container.resolve("docker"); + const dockerContainer = await docker.createContainer({ + Image: imageName, + name: containerName, + HostConfig: { + PortBindings: { + [`${containerPort}/tcp`]: [{ HostPort: hostPort }], + }, + AutoRemove: true, + }, + ExposedPorts: { + [`${containerPort}/tcp`]: {}, + }, + }); + return dockerContainer; +} + +/** + * Starts a container and returns a log stream if the container is not yet running. + * @param {string} imageName The name of the image to create the container from + * @param {string} containerName The name of the container to start + * @param {number} hostPort The port on the host machine mapped to the container's port + * @param {number} containerPort The port inside the container Fauna listens on + * @returns {Promise} The log stream + */ +async function startContainer({ + imageName, + containerName, + hostPort, + containerPort, +}) { + const docker = container.resolve("docker"); + const logger = container.resolve("logger"); + const existingContainer = await findContainer(containerName); + let logStream = undefined; + if (existingContainer) { + const dockerContainer = docker.getContainer(existingContainer.Id); + const state = existingContainer.State; + if (state === "paused") { + logger.stderr( + `[StartContainer] Container '${containerName}' exists but is paused. Unpausing it...`, + ); + await dockerContainer.unpause(); + logStream = await createLogStream({ + dockerContainer, + containerName, + }); + } else if (state === "created" || state === "exited") { + logger.stderr( + `[StartContainer] Container '${containerName}' exists in state '${existingContainer.State}'. Starting it...`, + ); + await dockerContainer.start(); + logStream = await createLogStream({ + dockerContainer, + containerName, + }); + } else if (state === "running") { + logger.stderr( + `[StartContainer] Container '${containerName}' is already running.`, + ); + } else { + throw new CommandError( + `[StartContainer] Container '${containerName}' already exists in state '${state}' and cannot be started.`, + ); + } + } else { + logger.stderr(`[StartContainer] Starting container '${containerName}'...`); + const dockerContainer = await createContainer({ + imageName, + containerName, + hostPort, + containerPort, + }); + await dockerContainer.start(); + logStream = await createLogStream({ + dockerContainer, + containerName, + }); + } + return logStream; +} + +/** + * Creates a log stream for the container + * @param {Object} dockerContainer The container object + * @param {string} containerName The name of the container + * @returns {Promise} The log stream + */ +async function createLogStream({ dockerContainer, containerName }) { + const logger = container.resolve("logger"); + let logStream = await dockerContainer.logs({ + stdout: true, + stderr: true, + follow: true, + tail: 100, // Get the last 100 lines and start tailing + }); + + // Pipe the logs to your logger + logStream.on("data", (chunk) => { + logger.stderr(`[StartContainer][${containerName}] ${chunk.toString()}`); + }); + + logStream.on("end", async () => { + logger.stderr( + `[StartContainer] Container '${containerName}' logs have finished.`, + ); + logStream = await createLogStream({ + dockerContainer, + containerName, + }); + }); + + logStream.on("error", (error) => { + logger.stderr( + `[StartContainer] Error tailing logs for container '${containerName}': ${error.message}`, + ); + }); + + return logStream; +} + +/** + * Waits for the container to be ready + * @param {string} url The url to check + * @param {number} maxAttempts The maximum number of attempts to check + * @param {number} delay The delay between attempts in milliseconds + * @param {Object} logStream The log stream to destroy when the container is ready + * @returns {Promise} a promise that resolves when the container is ready. + * It will reject if the container is not ready after the maximum number of attempts. + */ +async function waitForHealthCheck({ + url, + maxAttempts = 100, + delay = 10000, + logStream, +}) { + const logger = container.resolve("logger"); + const fetch = container.resolve("fetch"); + logger.stderr(`[HealthCheck] Waiting for Fauna to be ready at ${url}...`); + + let attemptCounter = 0; + + while (attemptCounter < maxAttempts) { + try { + /* eslint-disable-next-line no-await-in-loop */ + const response = await fetch(`${url}/ping`, { + method: "GET", + timeout: 1000, + }); + if (response.ok) { + logger.stderr(`[HealthCheck] Fauna is ready at ${url}`); + logStream?.destroy(); + return; + } + } catch (error) { + logger.stderr( + `[HealthCheck] Fauna is not yet ready. Attempt ${attemptCounter + 1}/${maxAttempts} failed: ${error.message}. Retrying in ${delay / 1000} seconds...`, + ); + } + + attemptCounter++; + /* eslint-disable-next-line no-await-in-loop */ + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + + logger.stderr( + `[HealthCheck] Max attempts reached. Service at ${url} did not respond.`, + ); + throw new Error( + `[HealthCheck] Fauna at ${url} is not ready after ${maxAttempts} attempts.`, + ); +} diff --git a/test/helpers.mjs b/test/helpers.mjs index fc176e39..35c98681 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -8,7 +8,12 @@ import sinon from "sinon"; // small helper for sinon to wrap your return value // in the shape fetch would return it from the network export function f(returnValue, status) { - return { json: async () => returnValue, status: status || 200 }; + return new Response(JSON.stringify(returnValue), { + status: status || 200, + headers: { + "Content-type": "application/json", + }, + }); } export const commonFetchParams = { diff --git a/test/local.mjs b/test/local.mjs new file mode 100644 index 00000000..9046ef4c --- /dev/null +++ b/test/local.mjs @@ -0,0 +1,212 @@ +//@ts-check + +import { expect } from "chai"; +import sinon, { stub } from "sinon"; + +import { run } from "../src/cli.mjs"; +import { setupTestContainer } from "../src/config/setup-test-container.mjs"; +import { f } from "./helpers.mjs"; + +describe("ensureContainerRunning", () => { + let container, + fetch, + logger, + stderrStream, + docker, + logsStub, + startStub, + unpauseStub; + + beforeEach(async () => { + container = await setupTestContainer(); + logger = container.resolve("logger"); + stderrStream = container.resolve("stderrStream"); + fetch = container.resolve("fetch"); + docker = container.resolve("docker"); + logsStub = stub(); + startStub = stub(); + unpauseStub = stub(); + }); + + it.skip("handles argv tweaks correctly", () => {}); + + it("Creates and starts a container when none exists", async () => { + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + docker.listContainers.onCall(0).resolves([]); + fetch.onCall(0).resolves(f({})); // fast succeed the health check + logsStub.callsFake(async () => ({ + on: () => {}, + destroy: () => {}, + })); + docker.createContainer.resolves({ + start: startStub, + logs: logsStub, + unpause: unpauseStub, + }); + await run("local", container); + expect(unpauseStub).not.to.have.been.called; + expect(startStub).to.have.been.called; + expect(logsStub).to.have.been.calledWith({ + stdout: true, + stderr: true, + follow: true, + tail: 100, + }); + expect(docker.createContainer).to.have.been.calledWith({ + Image: "fauna/faunadb:latest", + name: "faunadb", + HostConfig: { + PortBindings: { + "8443/tcp": [{ HostPort: "8443" }], + }, + AutoRemove: true, + }, + ExposedPorts: { + "8443/tcp": {}, + }, + }); + expect(logger.stderr).to.have.been.calledWith( + "[ContainerReady] Container 'faunadb' is up and healthy.", + ); + }); + + it("exits if a container cannot be started", async () => { + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + docker.listContainers + .onCall(0) + .resolves([{ State: "dead", Names: ["/faunadb"] }]); + fetch.onCall(0).resolves(f({})); // fast succeed the health check + logsStub.callsFake(async () => ({ + on: () => {}, + destroy: () => {}, + })); + docker.getContainer.onCall(0).returns({ + logs: logsStub, + start: startStub, + unpause: unpauseStub, + }); + try { + await run("local", container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + const written = stderrStream.getWritten(); + expect(written).to.contain( + "[StartContainer] Container 'faunadb' already exists in state 'dead' and cannot be started.", + ); + expect(written).not.to.contain("An unexpected"); + }); + + [ + { + state: "paused", + startMessage: `[StartContainer] Container 'faunadb' exists but is paused. Unpausing it...`, + expectCalls: () => { + expect(unpauseStub).to.have.been.called; + expect(startStub).not.to.have.been.called; + expect(logsStub).to.have.been.calledWith({ + stdout: true, + stderr: true, + follow: true, + tail: 100, + }); + }, + }, + { + state: "created", + startMessage: `[StartContainer] Container 'faunadb' exists in state 'created'. Starting it...`, + expectCalls: () => { + expect(unpauseStub).not.to.have.been.called; + expect(startStub).to.have.been.called; + expect(logsStub).to.have.been.calledWith({ + stdout: true, + stderr: true, + follow: true, + tail: 100, + }); + }, + }, + { + state: "exited", + startMessage: `[StartContainer] Container 'faunadb' exists in state 'exited'. Starting it...`, + expectCalls: () => { + expect(unpauseStub).not.to.have.been.called; + expect(startStub).to.have.been.called; + expect(logsStub).to.have.been.calledWith({ + stdout: true, + stderr: true, + follow: true, + tail: 100, + }); + }, + }, + { + state: "running", + startMessage: "[StartContainer] Container 'faunadb' is already running.", + expectCalls: () => { + expect(unpauseStub).not.to.have.been.called; + expect(startStub).not.to.have.been.called; + expect(logsStub).not.to.have.been.called; + }, + }, + ].forEach((test) => { + it(`Ensures a container in state '${test.state}' becomes running and available.`, async () => { + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + docker.listContainers + .onCall(0) + .resolves([{ State: test.state, Names: ["/faunadb"] }]); + fetch.onCall(0).resolves(f({})); // fast succeed the health check + logsStub.callsFake(async () => ({ + on: () => {}, + destroy: () => {}, + })); + docker.getContainer.onCall(0).returns({ + logs: logsStub, + start: startStub, + unpause: unpauseStub, + }); + try { + await run("local", container); + } catch (_) { + expect(test.state).to.equal("dead"); + } + expect(docker.pull).to.have.been.calledWith("fauna/faunadb:latest"); + expect(docker.modem.followProgress).to.have.been.calledWith( + sinon.matchAny, + sinon.match.func, + ); + expect(docker.listContainers).to.have.been.calledWith({ + all: true, + filters: JSON.stringify({ name: ["faunadb"] }), + }); + test.expectCalls(); + expect(logger.stderr).to.have.been.calledWith(test.startMessage); + expect(logger.stderr).to.have.been.calledWith( + `[PullImage] Pulling image 'fauna/faunadb:latest'...\n`, + ); + expect(logger.stderr).to.have.been.calledWith( + "[PullImage] Image 'fauna/faunadb:latest' pulled.", + ); + expect(logger.stderr).to.have.been.calledWith( + "[StartContainer] Container 'faunadb' started. Monitoring HealthCheck for readiness.", + ); + expect(logger.stderr).to.have.been.calledWith( + "[HealthCheck] Waiting for Fauna to be ready at http://localhost:8443...", + ); + expect(logger.stderr).to.have.been.calledWith( + "[HealthCheck] Fauna is ready at http://localhost:8443", + ); + expect(logger.stderr).to.have.been.calledWith( + "[ContainerReady] Container 'faunadb' is up and healthy.", + ); + }); + }); +}); diff --git a/test/schema/diff.mjs b/test/schema/diff.mjs index b21ff8b2..1b98be1a 100644 --- a/test/schema/diff.mjs +++ b/test/schema/diff.mjs @@ -31,7 +31,7 @@ describe("schema diff", function () { }); it("can display the diff between local and staged remote schema", async function () { - fetch.resolves( + fetch.callsFake(() => f({ version: 0, diff: colorDiffString, @@ -58,7 +58,7 @@ describe("schema diff", function () { }); it("can display the diff between local and active remote schema", async function () { - fetch.resolves( + fetch.callsFake(() => f({ version: 0, diff: colorDiffString, @@ -85,7 +85,7 @@ describe("schema diff", function () { }); it("can display the diff without color (terminal escape codes)", async function () { - fetch.resolves( + fetch.callsFake(() => f({ version: 0, diff: noColorDiffString, @@ -111,7 +111,7 @@ describe("schema diff", function () { }); it("displays useful output when a diff is empty", async function () { - fetch.resolves( + fetch.callsFake(() => f({ version: 0, diff: "", @@ -138,6 +138,12 @@ describe("schema diff", function () { }); it("can parse relative paths", async function () { + fetch.callsFake(() => + f({ + version: 0, + diff: "", + }), + ); await run( `schema diff --secret "secret" --dir /all/but/the/leaf/..`, container, @@ -147,6 +153,12 @@ describe("schema diff", function () { }); it("can parse home directory paths", async function () { + fetch.callsFake(() => + f({ + version: 0, + diff: "", + }), + ); const homedir = container.resolve("homedir"); homedir.returns("/Users/test-user"); diff --git a/test/schema/status.mjs b/test/schema/status.mjs index d3912923..b100f949 100644 --- a/test/schema/status.mjs +++ b/test/schema/status.mjs @@ -314,7 +314,7 @@ describe("schema status", function () { }); it("can fetch status without embedded colors (terminal escape codes)", async function () { - fetch.resolves( + fetch.callsFake(() => f({ version: 0, status: "none",