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.",