From ccd24eefcf195c86626d6465376a7cd749a0e160 Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 09:47:44 -0500 Subject: [PATCH 01/10] Tests for health checks. --- src/commands/local.mjs | 19 +++++++++-- src/lib/docker-containers.mjs | 32 +++++++++++------- test/local.mjs | 64 ++++++++++++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 5a3f7c84..7de9ef36 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -1,4 +1,5 @@ import { ensureContainerRunning } from "../lib/docker-containers.mjs"; +//import { CommandError } from "../lib/errors.mjs"; /** * Starts the local Fauna container @@ -13,6 +14,8 @@ async function startLocal(argv) { hostPort: argv.hostPort, containerPort: argv.containerPort, pull: argv.pull, + interval: argv.interval, + maxAttempts: argv.maxAttempts, }); } @@ -26,13 +29,25 @@ function buildLocalCommand(yargs) { containerPort: { describe: "The port inside the container Fauna listens on.", type: "number", - default: "8443", + 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", + default: 8443, + }, + interval: { + describe: + "The interval (in milliseconds) between health check attempts. Determines how often the CLI checks if the Fauna container is ready.", + type: "number", + default: 10000, + }, + maxAttempts: { + describe: + "The maximum number of health check attempts before declaring the start Fauna continer process as failed.", + type: "number", + default: 100, }, name: { describe: "The name to give the container", diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 41f3485a..806bdd17 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -10,6 +10,9 @@ const IMAGE_NAME = "fauna/faunadb:latest"; * @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 + * @param {number | undefined} interval The interval (in milliseconds) between health check attempts. + * @param {number | undefined} maxAttempts The maximum number of health check attempts before + * declaring the start Fauna continer process as failed. * @returns {Promise} */ export async function ensureContainerRunning({ @@ -17,6 +20,8 @@ export async function ensureContainerRunning({ hostPort, containerPort, pull, + interval, + maxAttempts, }) { const logger = container.resolve("logger"); if (pull) { @@ -34,6 +39,8 @@ export async function ensureContainerRunning({ await waitForHealthCheck({ url: `http://localhost:${hostPort}`, logStream, + interval, + maxAttempts, }); logger.stderr( `[ContainerReady] Container '${containerName}' is up and healthy.`, @@ -155,7 +162,7 @@ async function createContainer({ name: containerName, HostConfig: { PortBindings: { - [`${containerPort}/tcp`]: [{ HostPort: hostPort }], + [`${containerPort}/tcp`]: [{ HostPort: `${hostPort}` }], }, AutoRemove: true, }, @@ -274,7 +281,7 @@ async function createLogStream({ dockerContainer, containerName }) { * 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 {number} interval The interval 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. @@ -282,7 +289,7 @@ async function createLogStream({ dockerContainer, containerName }) { async function waitForHealthCheck({ url, maxAttempts = 100, - delay = 10000, + interval = 10000, logStream, }) { const logger = container.resolve("logger"); @@ -290,7 +297,7 @@ async function waitForHealthCheck({ logger.stderr(`[HealthCheck] Waiting for Fauna to be ready at ${url}...`); let attemptCounter = 0; - + let errorMessage = ""; while (attemptCounter < maxAttempts) { try { /* eslint-disable-next-line no-await-in-loop */ @@ -303,23 +310,24 @@ async function waitForHealthCheck({ 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...`, - ); + errorMessage = `with HTTP status: '${response.status}'`; + } catch (e) { + errorMessage = `with error: ${e.message}`; } - + logger.stderr( + `[HealthCheck] Fauna is not yet ready. Attempt ${attemptCounter + 1}/${maxAttempts} failed ${errorMessage}. Retrying in ${interval / 1000} seconds...`, + ); attemptCounter++; /* eslint-disable-next-line no-await-in-loop */ await new Promise((resolve) => { - setTimeout(resolve, delay); + setTimeout(resolve, interval); }); } 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.`, + throw new CommandError( + `[HealthCheck] Fauna at ${url} is not ready after ${maxAttempts} attempts. Consider increasing --interval or --maxAttempts.`, ); } diff --git a/test/local.mjs b/test/local.mjs index 9046ef4c..a0aa4fed 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -7,7 +7,7 @@ import { run } from "../src/cli.mjs"; import { setupTestContainer } from "../src/config/setup-test-container.mjs"; import { f } from "./helpers.mjs"; -describe("ensureContainerRunning", () => { +describe.only("ensureContainerRunning", () => { let container, fetch, logger, @@ -28,8 +28,6 @@ describe("ensureContainerRunning", () => { 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) => { @@ -73,6 +71,40 @@ describe("ensureContainerRunning", () => { ); }); + it("Throws an error if the health check fails", async () => { + process.env.FAUNA_LOCAL_HEALTH_CHECK_INTERVAL_MS = "1"; + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + docker.listContainers + .onCall(0) + .resolves([{ State: "created", Names: ["/faunadb"] }]); + logsStub.callsFake(async () => ({ + on: () => {}, + destroy: () => {}, + })); + docker.getContainer.onCall(0).returns({ + logs: logsStub, + start: startStub, + unpause: unpauseStub, + }); + fetch.onCall(0).rejects(); + fetch.resolves(f({}, 503)); // fail from http + try { + await run("local --interval 0 --maxAttempts 3", container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + const written = stderrStream.getWritten(); + expect(written).to.contain("with HTTP status: '503'"); + expect(written).to.contain("with error:"); + expect(written).to.contain( + "[HealthCheck] Fauna at http://localhost:8443 is not ready after 3 attempts. Consider increasing --interval or --maxAttempts.", + ); + expect(written).not.to.contain("An unexpected"); + expect(written).not.to.contain("fauna local"); // help text + }); + it("exits if a container cannot be started", async () => { docker.pull.onCall(0).resolves(); docker.modem.followProgress.callsFake((stream, onFinished) => { @@ -102,6 +134,30 @@ describe("ensureContainerRunning", () => { expect(written).not.to.contain("An unexpected"); }); + it.skip("throws an error if interval is less than 0", async () => { + try { + await run("local --interval -1", container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + const written = stderrStream.getWritten(); + expect(written).to.contain( + "--interval must be greater than or equal to 0.", + ); + expect(written).to.contain("fauna local"); // help text + expect(written).not.to.contain("An unexpected"); + }); + + it.skip("throws an error if maxAttempts is less than 1", async () => { + try { + await run("local --maxAttempts 0", container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + const written = stderrStream.getWritten(); + expect(written).to.contain("--maxAttempts must be greater than 0."); + expect(written).to.contain("fauna local"); // help text + expect(written).not.to.contain("An unexpected"); + }); + [ { state: "paused", @@ -180,7 +236,7 @@ describe("ensureContainerRunning", () => { } expect(docker.pull).to.have.been.calledWith("fauna/faunadb:latest"); expect(docker.modem.followProgress).to.have.been.calledWith( - sinon.matchAny, + sinon.match.any, sinon.match.func, ); expect(docker.listContainers).to.have.been.calledWith({ From c3685954052d45845c61fb7b1d3f81edaec9068e Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 09:50:32 -0500 Subject: [PATCH 02/10] Argument checks --- src/commands/local.mjs | 76 ++++++++++++++++++++++++------------------ test/local.mjs | 4 +-- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 7de9ef36..0a3bcc32 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -1,5 +1,5 @@ import { ensureContainerRunning } from "../lib/docker-containers.mjs"; -//import { CommandError } from "../lib/errors.mjs"; +import { CommandError } from "../lib/errors.mjs"; /** * Starts the local Fauna container @@ -25,41 +25,51 @@ async function startLocal(argv) { * @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: + 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, - }, - interval: { - describe: + type: "number", + default: 8443, + }, + interval: { + describe: "The interval (in milliseconds) between health check attempts. Determines how often the CLI checks if the Fauna container is ready.", - type: "number", - default: 10000, - }, - maxAttempts: { - describe: + type: "number", + default: 10000, + }, + maxAttempts: { + describe: "The maximum number of health check attempts before declaring the start Fauna continer process as failed.", - type: "number", - default: 100, - }, - 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, - }, - }); + type: "number", + default: 100, + }, + 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, + }, + }) + .check((argv) => { + if (argv.maxAttempts < 1) { + throw new CommandError("--maxAttempts must be greater than 0.", { hideHelp: false }); + } + if (argv.interval < 0) { + throw new CommandError("--interval must be greater than or equal to 0.", { hideHelp: false }); + } + return true; + }); } export default { diff --git a/test/local.mjs b/test/local.mjs index a0aa4fed..240c743d 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -134,7 +134,7 @@ describe.only("ensureContainerRunning", () => { expect(written).not.to.contain("An unexpected"); }); - it.skip("throws an error if interval is less than 0", async () => { + it("throws an error if interval is less than 0", async () => { try { await run("local --interval -1", container); throw new Error("Expected an error to be thrown."); @@ -147,7 +147,7 @@ describe.only("ensureContainerRunning", () => { expect(written).not.to.contain("An unexpected"); }); - it.skip("throws an error if maxAttempts is less than 1", async () => { + it("throws an error if maxAttempts is less than 1", async () => { try { await run("local --maxAttempts 0", container); throw new Error("Expected an error to be thrown."); From 6e7f6f712959d3470d2b005cc229748d754eef6f Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 10:20:37 -0500 Subject: [PATCH 03/10] Skip pull test --- src/commands/local.mjs | 15 ++++++++++----- test/local.mjs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 0a3bcc32..95b9cfab 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -34,19 +34,19 @@ function buildLocalCommand(yargs) { }, hostPort: { describe: - "The port on the host machine mapped to the container's port. This is the port you'll connect to Fauna on.", + "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, }, interval: { describe: - "The interval (in milliseconds) between health check attempts. Determines how often the CLI checks if the Fauna container is ready.", + "The interval (in milliseconds) between health check attempts. Determines how often the CLI checks if the Fauna container is ready.", type: "number", default: 10000, }, maxAttempts: { describe: - "The maximum number of health check attempts before declaring the start Fauna continer process as failed.", + "The maximum number of health check attempts before declaring the start Fauna continer process as failed.", type: "number", default: 100, }, @@ -63,10 +63,15 @@ function buildLocalCommand(yargs) { }) .check((argv) => { if (argv.maxAttempts < 1) { - throw new CommandError("--maxAttempts must be greater than 0.", { hideHelp: false }); + throw new CommandError("--maxAttempts must be greater than 0.", { + hideHelp: false, + }); } if (argv.interval < 0) { - throw new CommandError("--interval must be greater than or equal to 0.", { hideHelp: false }); + throw new CommandError( + "--interval must be greater than or equal to 0.", + { hideHelp: false }, + ); } return true; }); diff --git a/test/local.mjs b/test/local.mjs index 240c743d..26ccb319 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -71,6 +71,29 @@ describe.only("ensureContainerRunning", () => { ); }); + it("Skips pull if --pull is false.", async () => { + 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 --pull false", container); + expect(docker.pull).not.to.have.been.called; + expect(docker.modem.followProgress).not.to.have.been.called; + expect(startStub).to.have.been.called; + expect(logsStub).to.have.been.called; + expect(docker.createContainer).to.have.been.called; + expect(logger.stderr).to.have.been.calledWith( + "[ContainerReady] Container 'faunadb' is up and healthy.", + ); + }); + it("Throws an error if the health check fails", async () => { process.env.FAUNA_LOCAL_HEALTH_CHECK_INTERVAL_MS = "1"; docker.pull.onCall(0).resolves(); From b722465b5cf8037563721677e4b68851ae57d08f Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 10:44:03 -0500 Subject: [PATCH 04/10] Tests for pull failing --- src/lib/docker-containers.mjs | 7 ++----- src/lib/errors.mjs | 5 ++++- test/local.mjs | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 806bdd17..282bd1ca 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -1,5 +1,5 @@ import { container } from "../cli.mjs"; -import { CommandError } from "./errors.mjs"; +import { CommandError, SUPPORT_MESSAGE } from "./errors.mjs"; const IMAGE_NAME = "fauna/faunadb:latest"; @@ -91,10 +91,7 @@ async function pullImage(imageName) { ); }); } catch (error) { - logger.stderr( - `[PullImage] Error pulling image ${imageName}: ${error.message}`, - ); - throw error; + throw new CommandError(`[PullImage] Failed to pull image '${imageName}': ${error.message}. ${SUPPORT_MESSAGE}`, { cause: error }); } } diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs index 339f9fc3..5f566a88 100644 --- a/src/lib/errors.mjs +++ b/src/lib/errors.mjs @@ -4,7 +4,8 @@ import util from "util"; import { container } from "../cli.mjs"; -const BUG_REPORT_MESSAGE = `If you believe this is a bug, please report this issue on GitHub: https://github.com/fauna/fauna-shell/issues`; +const BUG_REPORT_MESSAGE = "If you believe this is a bug, please report this issue on GitHub: https://github.com/fauna/fauna-shell/issues"; +export const SUPPORT_MESSAGE = "If this issue persists contact support: https://support.fauna.com/hc/en-us/requests/new"; /* * These are the error message prefixes that yargs throws during @@ -112,6 +113,8 @@ export const handleParseYargsError = async ( logger.debug(`unknown error thrown: ${e.name}`, "error"); logger.debug(util.inspect(e, true, 2, false), "error"); } else { + logger.debug(`known error thrown: ${e.name}`, "error"); + logger.debug(util.inspect(e, true, 2, false), "error"); // Otherwise, just use the error message subMessage = hasAnsi(e.message) ? e.message : chalk.red(e.message); } diff --git a/test/local.mjs b/test/local.mjs index 26ccb319..d68cc50c 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -7,7 +7,7 @@ import { run } from "../src/cli.mjs"; import { setupTestContainer } from "../src/config/setup-test-container.mjs"; import { f } from "./helpers.mjs"; -describe.only("ensureContainerRunning", () => { +describe("ensureContainerRunning", () => { let container, fetch, logger, @@ -94,8 +94,39 @@ describe.only("ensureContainerRunning", () => { ); }); + it("Fails start with a prompt to contact Fauna if pull fails.", async () => { + docker.pull.onCall(0).rejects(new Error("Remote repository not found")); + 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, + }); + try { + await run("local", container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + expect(docker.pull).to.have.been.called; + expect(docker.modem.followProgress).not.to.have.been.called; + expect(startStub).not.to.have.been.called; + expect(logsStub).not.to.have.been.called; + expect(docker.createContainer).not.to.have.been.called; + const written = stderrStream.getWritten(); + expect(written).to.contain( + `[PullImage] Failed to pull image 'fauna/faunadb:latest': Remote repository \ +not found. If this issue persists contact support: \ +https://support.fauna.com/hc/en-us/requests/new`, + ); + expect(written).not.to.contain("An unexpected"); + expect(written).not.to.contain("fauna local"); // help text + }); + it("Throws an error if the health check fails", async () => { - process.env.FAUNA_LOCAL_HEALTH_CHECK_INTERVAL_MS = "1"; docker.pull.onCall(0).resolves(); docker.modem.followProgress.callsFake((stream, onFinished) => { onFinished(); From 177964b17bed5302cfcaff93c93e060cfa41ea5c Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 10:46:25 -0500 Subject: [PATCH 05/10] Tests for pull failing --- src/lib/docker-containers.mjs | 5 ++++- src/lib/errors.mjs | 6 ++++-- test/local.mjs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 282bd1ca..51416086 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -91,7 +91,10 @@ async function pullImage(imageName) { ); }); } catch (error) { - throw new CommandError(`[PullImage] Failed to pull image '${imageName}': ${error.message}. ${SUPPORT_MESSAGE}`, { cause: error }); + throw new CommandError( + `[PullImage] Failed to pull image '${imageName}': ${error.message}. ${SUPPORT_MESSAGE}`, + { cause: error }, + ); } } diff --git a/src/lib/errors.mjs b/src/lib/errors.mjs index 5f566a88..61bab418 100644 --- a/src/lib/errors.mjs +++ b/src/lib/errors.mjs @@ -4,8 +4,10 @@ import util from "util"; import { container } from "../cli.mjs"; -const BUG_REPORT_MESSAGE = "If you believe this is a bug, please report this issue on GitHub: https://github.com/fauna/fauna-shell/issues"; -export const SUPPORT_MESSAGE = "If this issue persists contact support: https://support.fauna.com/hc/en-us/requests/new"; +const BUG_REPORT_MESSAGE = + "If you believe this is a bug, please report this issue on GitHub: https://github.com/fauna/fauna-shell/issues"; +export const SUPPORT_MESSAGE = + "If this issue persists contact support: https://support.fauna.com/hc/en-us/requests/new"; /* * These are the error message prefixes that yargs throws during diff --git a/test/local.mjs b/test/local.mjs index d68cc50c..53c61558 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -71,6 +71,38 @@ describe("ensureContainerRunning", () => { ); }); + it("The user can control the hostPort and containerPort", 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 --hostPort 10 --containerPort 11", container); + expect(docker.createContainer).to.have.been.calledWith({ + Image: "fauna/faunadb:latest", + name: "faunadb", + HostConfig: { + PortBindings: { + "11/tcp": [{ HostPort: "10" }], + }, + AutoRemove: true, + }, + ExposedPorts: { + "11/tcp": {}, + }, + }); + }); + it("Skips pull if --pull is false.", async () => { docker.listContainers.onCall(0).resolves([]); fetch.onCall(0).resolves(f({})); // fast succeed the health check From 692fca7cc1ec45a2580658fdd81ad62bc2dad7c9 Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 14:51:16 -0500 Subject: [PATCH 06/10] Support for hostIp --- src/commands/local.mjs | 6 ++++ src/config/setup-container.mjs | 2 ++ src/config/setup-test-container.mjs | 3 ++ src/lib/docker-containers.mjs | 56 ++++++++++++++++++++++++++--- test/local.mjs | 47 +++++++++++++++++++----- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 95b9cfab..fb6c763b 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -11,6 +11,7 @@ async function startLocal(argv) { await ensureContainerRunning({ imageName: argv.image, containerName: argv.name, + hostIp: argv.hostIp, hostPort: argv.hostPort, containerPort: argv.containerPort, pull: argv.pull, @@ -38,6 +39,11 @@ function buildLocalCommand(yargs) { type: "number", default: 8443, }, + hostIp: { + describe: `The IP address to bind the container's exposed port on the host.`, + type: "string", + default: "0.0.0.0", + }, interval: { describe: "The interval (in milliseconds) between health check attempts. Determines how often the CLI checks if the Fauna container is ready.", diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index 2ec113e8..e9d14a60 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import * as fsp from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { exit } from "node:process"; @@ -60,6 +61,7 @@ export const injectables = { fetch: awilix.asValue(fetchWrapper), fs: awilix.asValue(fs), fsp: awilix.asValue(fsp), + net: awilix.asValue(net), dirname: awilix.asValue(path.dirname), normalize: awilix.asValue(path.normalize), homedir: awilix.asValue(os.homedir), diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index 95f26151..426863bf 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import net from "node:net"; import path from "node:path"; import { PassThrough } from "node:stream"; @@ -43,6 +44,7 @@ export function setupTestContainer() { const thingsToManuallyMock = automock(container); const customfs = stub({ ...fs }); + const customNet = stub({ ...net }); // this is a mock used by the default profile behavior customfs.readdirSync.withArgs(process.cwd()).returns([]); @@ -58,6 +60,7 @@ export function setupTestContainer() { // real implementation parseYargs: awilix.asValue(spy(parseYargs)), fs: awilix.asValue(customfs), + net: awilix.asValue(customNet), homedir: awilix.asValue( stub().returns(path.join(__dirname, "../../test/test-homedir")), ), diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 51416086..e1777e6b 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -7,6 +7,7 @@ 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 {string} hostIp The IP address to bind the container's exposed port on the host. * @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 @@ -17,6 +18,7 @@ const IMAGE_NAME = "fauna/faunadb:latest"; */ export async function ensureContainerRunning({ containerName, + hostIp, hostPort, containerPort, pull, @@ -30,6 +32,7 @@ export async function ensureContainerRunning({ const logStream = await startContainer({ imageName: IMAGE_NAME, containerName, + hostIp, hostPort, containerPort, }); @@ -37,7 +40,7 @@ export async function ensureContainerRunning({ `[StartContainer] Container '${containerName}' started. Monitoring HealthCheck for readiness.`, ); await waitForHealthCheck({ - url: `http://localhost:${hostPort}`, + url: `http://${hostIp}:${hostPort}`, logStream, interval, maxAttempts, @@ -134,18 +137,45 @@ function writePullProgress(layers, numLines) { 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}'...`, - ); + logger.stderr(`[FindContainer] Looking for container '${containerName}'...`); const filters = JSON.stringify({ name: [containerName] }); const containers = await docker.listContainers({ all: true, filters }); return containers.length > 0 ? containers[0] : null; } +/** + * Checks if a port is occupied. + * @param {number} hostPort The port to check + * @param {string} hostIp The IP address to bind the container's exposed port on the host. + * @returns {Promise} a promise that resolves to true if the port is occupied, false otherwise. + */ +async function isPortOccupied({ hostPort, hostIp }) { + const net = container.resolve("net"); + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve(true); // Port is occupied + } else { + reject(err); // Some other error occurred + } + }); + + server.on("listening", () => { + server.close(() => { + resolve(false); // Port is free + }); + }); + + server.listen(hostPort, hostIp); + }); +} + /** * 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 {string} hostIp The IP address to bind the container's exposed port on the host. * @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 @@ -153,16 +183,29 @@ async function findContainer(containerName) { async function createContainer({ imageName, containerName, + hostIp, hostPort, containerPort, }) { const docker = container.resolve("docker"); + if (await isPortOccupied({ hostIp, hostPort })) { + throw new CommandError( + `The hostPort '${hostPort}' on IP '${hostIp}' is already occupied. \ +Please pass a --hostPort other than '${hostPort}'.`, + { hideHelp: false }, + ); + } const dockerContainer = await docker.createContainer({ Image: imageName, name: containerName, HostConfig: { PortBindings: { - [`${containerPort}/tcp`]: [{ HostPort: `${hostPort}` }], + [`${containerPort}/tcp`]: [ + { + HostPort: `${hostPort}`, + HostIp: hostIp, + }, + ], }, AutoRemove: true, }, @@ -177,6 +220,7 @@ async function createContainer({ * 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 {string} hostIp The IP address to bind the container's exposed port on the host. * @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 @@ -184,6 +228,7 @@ async function createContainer({ async function startContainer({ imageName, containerName, + hostIp, hostPort, containerPort, }) { @@ -226,6 +271,7 @@ async function startContainer({ const dockerContainer = await createContainer({ imageName, containerName, + hostIp, hostPort, containerPort, }); diff --git a/test/local.mjs b/test/local.mjs index 53c61558..879e6d01 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -7,13 +7,14 @@ import { run } from "../src/cli.mjs"; import { setupTestContainer } from "../src/config/setup-test-container.mjs"; import { f } from "./helpers.mjs"; -describe("ensureContainerRunning", () => { +describe.only("ensureContainerRunning", () => { let container, fetch, logger, stderrStream, docker, logsStub, + serverMock, startStub, unpauseStub; @@ -26,6 +27,21 @@ describe("ensureContainerRunning", () => { logsStub = stub(); startStub = stub(); unpauseStub = stub(); + // Requested port is free + serverMock = { + close: sinon.stub(), + once: sinon.stub(), + on: sinon.stub(), + listen: sinon.stub(), + }; + serverMock.on.withArgs("listening").callsFake((event, callback) => { + callback(); + }); + serverMock.close.callsFake((callback) => { + if (callback) callback(); + }); + const net = container.resolve("net"); + net.createServer.returns(serverMock); }); it("Creates and starts a container when none exists", async () => { @@ -58,7 +74,12 @@ describe("ensureContainerRunning", () => { name: "faunadb", HostConfig: { PortBindings: { - "8443/tcp": [{ HostPort: "8443" }], + "8443/tcp": [ + { + HostPort: "8443", + HostIp: "0.0.0.0", + }, + ], }, AutoRemove: true, }, @@ -71,7 +92,7 @@ describe("ensureContainerRunning", () => { ); }); - it("The user can control the hostPort and containerPort", async () => { + it("The user can control the hostIp, hostPort, containerPort, and name", async () => { docker.pull.onCall(0).resolves(); docker.modem.followProgress.callsFake((stream, onFinished) => { onFinished(); @@ -87,13 +108,21 @@ describe("ensureContainerRunning", () => { logs: logsStub, unpause: unpauseStub, }); - await run("local --hostPort 10 --containerPort 11", container); + await run( + "local --hostPort 10 --containerPort 11 --name Taco --hostIp 127.0.0.1", + container, + ); expect(docker.createContainer).to.have.been.calledWith({ Image: "fauna/faunadb:latest", - name: "faunadb", + name: "Taco", HostConfig: { PortBindings: { - "11/tcp": [{ HostPort: "10" }], + "11/tcp": [ + { + HostPort: "10", + HostIp: "127.0.0.1", + }, + ], }, AutoRemove: true, }, @@ -185,7 +214,7 @@ https://support.fauna.com/hc/en-us/requests/new`, expect(written).to.contain("with HTTP status: '503'"); expect(written).to.contain("with error:"); expect(written).to.contain( - "[HealthCheck] Fauna at http://localhost:8443 is not ready after 3 attempts. Consider increasing --interval or --maxAttempts.", + "[HealthCheck] Fauna at http://0.0.0.0:8443 is not ready after 3 attempts. Consider increasing --interval or --maxAttempts.", ); expect(written).not.to.contain("An unexpected"); expect(written).not.to.contain("fauna local"); // help text @@ -341,10 +370,10 @@ https://support.fauna.com/hc/en-us/requests/new`, "[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...", + "[HealthCheck] Waiting for Fauna to be ready at http://0.0.0.0:8443...", ); expect(logger.stderr).to.have.been.calledWith( - "[HealthCheck] Fauna is ready at http://localhost:8443", + "[HealthCheck] Fauna is ready at http://0.0.0.0:8443", ); expect(logger.stderr).to.have.been.calledWith( "[ContainerReady] Container 'faunadb' is up and healthy.", From a2ad9d96092c0846b19320b96b34e13924b49922 Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 17:17:51 -0500 Subject: [PATCH 07/10] Port occupied tests --- src/lib/docker-containers.mjs | 5 +-- test/local.mjs | 60 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index e1777e6b..5994014c 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -188,9 +188,10 @@ async function createContainer({ containerPort, }) { const docker = container.resolve("docker"); - if (await isPortOccupied({ hostIp, hostPort })) { + const occupied = await isPortOccupied({ hostIp, hostPort }); + if (occupied) { throw new CommandError( - `The hostPort '${hostPort}' on IP '${hostIp}' is already occupied. \ + `[StartContainer] The hostPort '${hostPort}' on IP '${hostIp}' is already occupied. \ Please pass a --hostPort other than '${hostPort}'.`, { hideHelp: false }, ); diff --git a/test/local.mjs b/test/local.mjs index 879e6d01..3f0d53d4 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -15,10 +15,12 @@ describe.only("ensureContainerRunning", () => { docker, logsStub, serverMock, + simulateError, startStub, unpauseStub; beforeEach(async () => { + simulateError = false; container = await setupTestContainer(); logger = container.resolve("logger"); stderrStream = container.resolve("stderrStream"); @@ -34,9 +36,39 @@ describe.only("ensureContainerRunning", () => { on: sinon.stub(), listen: sinon.stub(), }; + serverMock.listen.callsFake(() => { + if (simulateError) { + // Trigger the error callback + const errorCallback = serverMock.once.withArgs("error").args[0]?.[1]; + if (errorCallback) { + const error = new Error("Foo"); + error.code = "EADDRINUSE"; + errorCallback(error); + } + } else { + // Trigger the listening callback + const listeningCallback = + serverMock.on.withArgs("listening").args[0]?.[1]; + if (listeningCallback) { + listeningCallback(); + } + } + }); + serverMock.on.withArgs("listening").callsFake((event, callback) => { - callback(); + if (simulateError) { + // Trigger the error callback + const errorCallback = serverMock.once.withArgs("error").args[0]?.[1]; + if (errorCallback) { + const error = new Error("Foo"); + error.code = "EADDRINUSE"; + errorCallback(error); + } + } else { + callback(); + } }); + serverMock.close.callsFake((callback) => { if (callback) callback(); }); @@ -44,6 +76,32 @@ describe.only("ensureContainerRunning", () => { net.createServer.returns(serverMock); }); + it("Shows a clear error to the user if something is already running on the desired port.", async () => { + simulateError = true; + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + docker.listContainers.onCall(0).resolves([]); + try { + // Run the actual command + await run("local", container); + throw new Error("Expected an error to be thrown."); + } catch (_) { + // Expected error, no action needed + } + + const written = stderrStream.getWritten(); + + // Assertions + expect(written).to.contain( + "[StartContainer] The hostPort '8443' on IP '0.0.0.0' is already occupied. \ +Please pass a --hostPort other than '8443'.", + ); + expect(written).to.contain("fauna local"); + expect(written).not.to.contain("An unexpected"); + }); + it("Creates and starts a container when none exists", async () => { docker.pull.onCall(0).resolves(); docker.modem.followProgress.callsFake((stream, onFinished) => { From 14cb289bbaf68a77e396630fbfa446f895735909 Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Thu, 12 Dec 2024 17:23:36 -0500 Subject: [PATCH 08/10] Remove only --- test/local.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/local.mjs b/test/local.mjs index 3f0d53d4..b5c9ac29 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -7,7 +7,7 @@ import { run } from "../src/cli.mjs"; import { setupTestContainer } from "../src/config/setup-test-container.mjs"; import { f } from "./helpers.mjs"; -describe.only("ensureContainerRunning", () => { +describe("ensureContainerRunning", () => { let container, fetch, logger, From 320df05d916635d5ec309eee207f728aa5826dec Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Fri, 13 Dec 2024 08:46:18 -0500 Subject: [PATCH 09/10] Fail gracefully if the container is already present but on a different port. --- src/lib/docker-containers.mjs | 67 +++++++++++++++++++++------------ test/local.mjs | 71 ++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 33 deletions(-) diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 5994014c..a11694f8 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -5,15 +5,14 @@ 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 {string} hostIp The IP address to bind the container's exposed port on the host. - * @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 - * @param {number | undefined} interval The interval (in milliseconds) between health check attempts. - * @param {number | undefined} maxAttempts The maximum number of health check attempts before - * declaring the start Fauna continer process as failed. + * @param {Object} options The options object + * @param {string} options.containerName The name of the container to start + * @param {string} options.hostIp The IP address to bind the container's exposed port on the host + * @param {number} options.hostPort The port on the host machine mapped to the container's port + * @param {number} options.containerPort The port inside the container Fauna listens on + * @param {boolean} options.pull Whether to pull the latest image + * @param {number} [options.interval] The interval (in milliseconds) between health check attempts + * @param {number} [options.maxAttempts] The maximum number of health check attempts before declaring the start Fauna continer process as failed * @returns {Promise} */ export async function ensureContainerRunning({ @@ -127,20 +126,37 @@ function writePullProgress(layers, numLines) { /** * Finds a container by name - * @param {string} containerName The name of the container to find + * @param {Object} options The options object + * @param {string} options.containerName The name of the container to find + * @param {number} options.hostPort The port to check * @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) { +async function findContainer({ containerName, hostPort }) { const docker = container.resolve("docker"); const logger = container.resolve("logger"); // Dependency injection for logger logger.stderr(`[FindContainer] Looking for container '${containerName}'...`); const filters = JSON.stringify({ name: [containerName] }); const containers = await docker.listContainers({ all: true, filters }); - return containers.length > 0 ? containers[0] : null; + if (containers.length === 0) { + return null; + } + const result = containers[0]; + const diffPort = result.Ports.find( + (c) => c.PublicPort !== undefined && c.PublicPort !== hostPort, + ); + if (diffPort) { + throw new CommandError( + `[FindContainer] Container '${containerName}' is already \ +in use on hostPort '${diffPort.PublicPort}'. Please use a new name via \ +arguments --name --hostPort ${hostPort} to start the container.`, + { hideHelp: false }, + ); + } + return result; } /** @@ -173,11 +189,12 @@ async function isPortOccupied({ hostPort, hostIp }) { /** * 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 {string} hostIp The IP address to bind the container's exposed port on the host. - * @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 {Object} options The options object + * @param {string} options.imageName The name of the image to create the container from + * @param {string} options.containerName The name of the container to start + * @param {string} options.hostIp The IP address to bind the container's exposed port on the host + * @param {number} options.hostPort The port on the host machine mapped to the container's port + * @param {number} options.containerPort The port inside the container Fauna listens on * @returns {Promise} The container object */ async function createContainer({ @@ -235,7 +252,7 @@ async function startContainer({ }) { const docker = container.resolve("docker"); const logger = container.resolve("logger"); - const existingContainer = await findContainer(containerName); + const existingContainer = await findContainer({ containerName, hostPort }); let logStream = undefined; if (existingContainer) { const dockerContainer = docker.getContainer(existingContainer.Id); @@ -287,8 +304,9 @@ async function startContainer({ /** * Creates a log stream for the container - * @param {Object} dockerContainer The container object - * @param {string} containerName The name of the container + * @param {Object} options The options object + * @param {Object} options.dockerContainer The container object + * @param {string} options.containerName The name of the container * @returns {Promise} The log stream */ async function createLogStream({ dockerContainer, containerName }) { @@ -326,10 +344,11 @@ async function createLogStream({ dockerContainer, containerName }) { /** * 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} interval The interval between attempts in milliseconds - * @param {Object} logStream The log stream to destroy when the container is ready + * @param {Object} options The options object + * @param {string} options.url The url to check + * @param {number} [options.maxAttempts=100] The maximum number of attempts to check + * @param {number} [options.interval=10000] The interval between attempts in milliseconds + * @param {Object} options.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. */ diff --git a/test/local.mjs b/test/local.mjs index b5c9ac29..72f22a04 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -41,6 +41,7 @@ describe("ensureContainerRunning", () => { // Trigger the error callback const errorCallback = serverMock.once.withArgs("error").args[0]?.[1]; if (errorCallback) { + /** @type {Error & {code?: string}} */ const error = new Error("Foo"); error.code = "EADDRINUSE"; errorCallback(error); @@ -60,6 +61,7 @@ describe("ensureContainerRunning", () => { // Trigger the error callback const errorCallback = serverMock.once.withArgs("error").args[0]?.[1]; if (errorCallback) { + /** @type {Error & {code?: string}} */ const error = new Error("Foo"); error.code = "EADDRINUSE"; errorCallback(error); @@ -250,9 +252,13 @@ https://support.fauna.com/hc/en-us/requests/new`, docker.modem.followProgress.callsFake((stream, onFinished) => { onFinished(); }); - docker.listContainers - .onCall(0) - .resolves([{ State: "created", Names: ["/faunadb"] }]); + docker.listContainers.onCall(0).resolves([ + { + State: "created", + Names: ["/faunadb"], + Ports: [{ PublicPort: 8443 }], + }, + ]); logsStub.callsFake(async () => ({ on: () => {}, destroy: () => {}, @@ -283,9 +289,13 @@ https://support.fauna.com/hc/en-us/requests/new`, docker.modem.followProgress.callsFake((stream, onFinished) => { onFinished(); }); - docker.listContainers - .onCall(0) - .resolves([{ State: "dead", Names: ["/faunadb"] }]); + docker.listContainers.onCall(0).resolves([ + { + State: "dead", + Names: ["/faunadb"], + Ports: [{ PublicPort: 8443 }], + }, + ]); fetch.onCall(0).resolves(f({})); // fast succeed the health check logsStub.callsFake(async () => ({ on: () => {}, @@ -389,9 +399,13 @@ https://support.fauna.com/hc/en-us/requests/new`, docker.modem.followProgress.callsFake((stream, onFinished) => { onFinished(); }); - docker.listContainers - .onCall(0) - .resolves([{ State: test.state, Names: ["/faunadb"] }]); + docker.listContainers.onCall(0).resolves([ + { + State: test.state, + Names: ["/faunadb"], + Ports: [{ PublicPort: 8443, Type: "tcp" }], + }, + ]); fetch.onCall(0).resolves(f({})); // fast succeed the health check logsStub.callsFake(async () => ({ on: () => {}, @@ -438,4 +452,43 @@ https://support.fauna.com/hc/en-us/requests/new`, ); }); }); + + it("should throw if container exists with same name but different port", async () => { + const desiredPort = 8443; + docker.pull.onCall(0).resolves(); + docker.modem.followProgress.callsFake((stream, onFinished) => { + onFinished(); + }); + // Mock existing container with different port + docker.listContainers.onCall(0).resolves([ + { + Id: "mock-container-id", + Names: ["/faunadb"], + State: "running", + Ports: [ + { PublicPort: 9999, Type: "tcp" }, // Different port than desired + ], + }, + ]); + + try { + await run(`local --hostPort ${desiredPort}`, container); + throw new Error("Expected an error to be thrown."); + } catch (_) {} + expect(docker.listContainers).to.have.been.calledWith({ + all: true, + filters: JSON.stringify({ name: ["faunadb"] }), + }); + expect(startStub).not.to.have.been.called; + expect(unpauseStub).not.to.have.been.called; + expect(logsStub).not.to.have.been.called; + const written = stderrStream.getWritten(); + expect(written).to.contain( + `[FindContainer] Container 'faunadb' is already in use on hostPort '9999'. \ +Please use a new name via arguments --name --hostPort ${desiredPort} \ +to start the container.`, + ); + expect(written).not.to.contain("An unexpected"); + expect(written).to.contain("fauna local"); // help text + }); }); From 5790c3f6f5d522841924d7215d047f36fffa3264 Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Fri, 13 Dec 2024 09:01:41 -0500 Subject: [PATCH 10/10] Doc fixes --- src/lib/docker-containers.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index a11694f8..17bb7d0a 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -161,8 +161,9 @@ arguments --name --hostPort ${hostPort} to start the container.`, /** * Checks if a port is occupied. - * @param {number} hostPort The port to check - * @param {string} hostIp The IP address to bind the container's exposed port on the host. + * @param {Object} options The options object + * @param {number} options.hostPort The port to check + * @param {string} options.hostIp The IP address to bind the container's exposed port on the host. * @returns {Promise} a promise that resolves to true if the port is occupied, false otherwise. */ async function isPortOccupied({ hostPort, hostIp }) { @@ -236,11 +237,12 @@ Please pass a --hostPort other than '${hostPort}'.`, /** * 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 {string} hostIp The IP address to bind the container's exposed port on the host. - * @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 {Object} options The options object + * @param {string} options.imageName The name of the image to create the container from + * @param {string} options.containerName The name of the container to start + * @param {string} options.hostIp The IP address to bind the container's exposed port on the host. + * @param {number} options.hostPort The port on the host machine mapped to the container's port + * @param {number} options.containerPort The port inside the container Fauna listens on * @returns {Promise} The log stream */ async function startContainer({