From 9aa14aaf65621c79caec275d4be8e20847024b7e Mon Sep 17 00:00:00 2001 From: Cleve Stuart Date: Tue, 10 Dec 2024 12:36:40 -0500 Subject: [PATCH] Refactor --- src/commands/local.mjs | 314 +-------------------------- src/config/setup-container.mjs | 2 + src/config/setup-test-container.mjs | 9 + src/lib/auth/oauth-client.mjs | 10 +- src/lib/docker-client.mjs | 322 ++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 317 deletions(-) create mode 100644 src/lib/docker-client.mjs diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 8a93ad13..497fca31 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -1,316 +1,5 @@ -import Docker from "dockerode"; - import { container } from "../cli.mjs"; -const DOCKER = new Docker(); - -/** - * 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 logger = container.resolve("logger"); // Dependency injection for logger - logger.stderr(`[PullImage] Pulling the latest version of ${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] Pull complete."); - 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 logger = container.resolve("logger"); // Dependency injection for logger - logger.stderr( - `[GetContainerState] Checking state for container '${containerName}'...`, - ); - const containers = await DOCKER.listContainers({ all: true }); - return containers.find((container) => - container.Names.includes(`/${containerName}`), - ); -} - -/** - * 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 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 logger = container.resolve("logger"); - const existingContainer = await findContainer(containerName); - let dockerContainer = undefined; - let logStream = undefined; - if (existingContainer) { - dockerContainer = DOCKER.getContainer(existingContainer.Id); - if (existingContainer.State === "paused") { - logger.stderr( - `[StartContainer] Container '${containerName}' exists but is paused. Unpausing it...`, - ); - await dockerContainer.unpause(); - logStream = await createLogStream({ dockerContainer, containerName }); - } else if (existingContainer.State === "created") { - logger.stderr( - `[StartContainer] Container '${containerName}' is created but not started. Starting it...`, - ); - await dockerContainer.start(); - logStream = await createLogStream({ dockerContainer, containerName }); - } else { - logger.stderr( - `[StartContainer] Container '${containerName}' is already running.`, - ); - } - } else { - logger.stderr(`[StartContainer] Starting container '${containerName}'...`); - 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"); - 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.`, - ); -} - -/** - * 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} - */ -async function ensureContainerRunning({ - imageName, - containerName, - hostPort, - containerPort, - pull, -}) { - const logger = container.resolve("logger"); - try { - if (pull) { - await pullImage(imageName); - } - const logStream = await startContainer({ - imageName, - containerName, - hostPort, - containerPort, - }); - logger.stderr( - `[StartContainer] Container '${containerName}' started. Monitoring HealthCheck for readiness.`, - ); - await waitForHealthCheck({ - url: `http://localhost:${hostPort}`, - logStream, - }); - logger.stderr( - `[ConatinerReady] Container '${containerName}' is up and healthy`, - ); - } catch (error) { - logger.stderr(`[StartContainer] Error: ${error.message}`); - throw error; - } -} - /** * Starts the local Fauna container * @param {import('yargs').Arguments} argv The arguments from yargs @@ -318,7 +7,8 @@ async function ensureContainerRunning({ * It will reject if the container is not ready after the maximum number of attempts. */ async function startLocal(argv) { - await ensureContainerRunning({ + const dockerClient = container.resolve("dockerClient"); + await dockerClient.ensureContainerRunning({ imageName: argv.image, containerName: argv.name, hostPort: argv.hostPort, diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index 55822bc5..2de19eb4 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -18,6 +18,7 @@ import { makeAccountRequest } from "../lib/account.mjs"; import { Credentials } from "../lib/auth/credentials.mjs"; import OAuthClient from "../lib/auth/oauth-client.mjs"; import { makeRetryableFaunaRequest } from "../lib/db.mjs"; +import DockerClient from "../lib/docker-client.mjs"; import * as faunaV10 from "../lib/fauna.mjs"; import { formatError, runQueryFromString } from "../lib/fauna-client.mjs"; import * as faunaV4 from "../lib/faunadb.mjs"; @@ -71,6 +72,7 @@ export const injectables = { parseYargs: awilix.asValue(parseYargs), logger: awilix.asFunction(buildLogger, { lifetime: Lifetime.SINGLETON }), oauthClient: awilix.asClass(OAuthClient, { lifetime: Lifetime.SCOPED }), + dockerClient: awilix.asClass(DockerClient, { lifetime: Lifetime.SINGLETON }), makeAccountRequest: awilix.asValue(makeAccountRequest), makeFaunaRequest: awilix.asValue(makeRetryableFaunaRequest), errorHandler: awilix.asValue((_error, exitCode) => exit(exitCode)), diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index 349644a4..50c28d63 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()), + dockerClient: awilix.asValue(() => ({ + start: stub(), + waitForHealthCheck: stub(), + createContainer: stub(), + findContainer: stub(), + writePullProgress: stub(), + pullImage: stub(), + createLogStream: 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-client.mjs b/src/lib/docker-client.mjs new file mode 100644 index 00000000..19728e86 --- /dev/null +++ b/src/lib/docker-client.mjs @@ -0,0 +1,322 @@ +import Docker from "dockerode"; + +import { container } from "../cli.mjs"; + +class DockerClient { + constructor() { + this.docker = new Docker(); + } + + /** + * 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} + */ + async ensureContainerRunning({ + imageName, + containerName, + hostPort, + containerPort, + pull, + }) { + const logger = container.resolve("logger"); + try { + if (pull) { + await this.pullImage(imageName); + } + const logStream = await this.startContainer({ + imageName, + containerName, + hostPort, + containerPort, + }); + logger.stderr( + `[StartContainer] Container '${containerName}' started. Monitoring HealthCheck for readiness.`, + ); + await DockerClient.waitForHealthCheck({ + url: `http://localhost:${hostPort}`, + logStream, + }); + logger.stderr( + `[ContainerReady] Container '${containerName}' is up and healthy`, + ); + } catch (error) { + logger.stderr(`[StartContainer] Error: ${error.message}`); + throw error; + } + } + /** + * 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 pullImage(imageName) { + const logger = container.resolve("logger"); // Dependency injection for logger + logger.stderr( + `[PullImage] Pulling the latest version of ${imageName}...\n`, + ); + + try { + const stream = await this.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) => { + this.docker.modem.followProgress( + stream, + (err, output) => { + DockerClient.writePullProgress(layers, numLines); + if (err) { + reject(err); + } else { + // Move to the reserved space for completion message + logger.stderr("[PullImage] Pull complete."); + 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 = DockerClient.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. + */ + static 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 findContainer(containerName) { + const logger = container.resolve("logger"); // Dependency injection for logger + logger.stderr( + `[GetContainerState] Checking state for container '${containerName}'...`, + ); + const containers = await this.docker.listContainers({ all: true }); + return containers.find((container) => + container.Names.includes(`/${containerName}`), + ); + } + + /** + * 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 createContainer({ imageName, containerName, hostPort, containerPort }) { + const dockerContainer = await this.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 startContainer({ imageName, containerName, hostPort, containerPort }) { + const logger = container.resolve("logger"); + const existingContainer = await this.findContainer(containerName); + let logStream = undefined; + if (existingContainer) { + const dockerContainer = this.docker.getContainer(existingContainer.Id); + if (existingContainer.State === "paused") { + logger.stderr( + `[StartContainer] Container '${containerName}' exists but is paused. Unpausing it...`, + ); + await dockerContainer.unpause(); + logStream = await this.createLogStream({ + dockerContainer, + containerName, + }); + } else if (existingContainer.State === "created") { + logger.stderr( + `[StartContainer] Container '${containerName}' is created but not started. Starting it...`, + ); + await dockerContainer.start(); + logStream = await this.createLogStream({ + dockerContainer, + containerName, + }); + } else { + logger.stderr( + `[StartContainer] Container '${containerName}' is already running.`, + ); + } + } else { + logger.stderr( + `[StartContainer] Starting container '${containerName}'...`, + ); + const dockerContainer = await this.createContainer({ + imageName, + containerName, + hostPort, + containerPort, + }); + await dockerContainer.start(); + logStream = await this.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 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 this.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. + */ + static async waitForHealthCheck({ + url, + maxAttempts = 100, + delay = 10000, + logStream, + }) { + const logger = container.resolve("logger"); + 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.`, + ); + } +} + +export default DockerClient;