Skip to content

Commit

Permalink
Refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
cleve-fauna committed Dec 10, 2024
1 parent 0d53ed2 commit 9aa14aa
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 317 deletions.
314 changes: 2 additions & 312 deletions src/commands/local.mjs
Original file line number Diff line number Diff line change
@@ -1,324 +1,14 @@
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<void>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<void>} 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<void>}
*/
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
* @returns {Promise<void>} 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({
const dockerClient = container.resolve("dockerClient");
await dockerClient.ensureContainerRunning({
imageName: argv.image,
containerName: argv.name,
hostPort: argv.hostPort,
Expand Down
2 changes: 2 additions & 0 deletions src/config/setup-container.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)),
Expand Down
9 changes: 9 additions & 0 deletions src/config/setup-test-container.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions src/lib/auth/oauth-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 9aa14aa

Please sign in to comment.