Files
OmniRoute/bin/cli/commands/redis.mjs
Chedrian07 fd62cb1152 fix(docker): publish the Redis sidecar on loopback instead of 0.0.0.0 (#9286)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
2026-08-05 21:45:56 -03:00

351 lines
10 KiB
JavaScript

import { spawn } from "node:child_process";
import { promisify } from "node:util";
import { execFile as execFileCb } from "node:child_process";
import { t } from "../i18n.mjs";
const execFile = promisify(execFileCb);
const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
const DEFAULT_NAME = "omniroute-redis";
const DEFAULT_PORT = "6379";
const DEFAULT_VOLUME = "omniroute-redis-data";
// The launcher starts Redis without AUTH unless --password is given, so the
// published port stays on loopback. `-p 6379:6379` would bind 0.0.0.0 and hand
// the whole LAN an unauthenticated Redis.
const DEFAULT_BIND = "127.0.0.1";
const RUNTIME_PREFERENCE = ["podman", "docker"];
/**
* Build the `-p` publish spec for the Redis container.
* Always host-qualified so the runtime never falls back to 0.0.0.0.
*/
export function buildRedisPublishSpec(bind = DEFAULT_BIND, port = DEFAULT_PORT) {
const host = String(bind || DEFAULT_BIND).trim() || DEFAULT_BIND;
const hostPort = String(port || DEFAULT_PORT).trim() || DEFAULT_PORT;
// Bracket IPv6 literals (e.g. ::1) so `host:port:port` stays unambiguous.
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
return `${normalizedHost}:${hostPort}:6379`;
}
async function detectRuntime() {
for (const candidate of RUNTIME_PREFERENCE) {
try {
await execFile(candidate, ["--version"], { timeout: 3000 });
return candidate;
} catch {
// try next candidate
}
}
return null;
}
async function containerExists(runtime, name) {
try {
const { stdout } = await execFile(runtime, [
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.Names}}",
]);
return stdout.trim() === name;
} catch {
return false;
}
}
async function containerRunning(runtime, name) {
try {
const { stdout } = await execFile(runtime, [
"ps",
"--filter",
`name=^${name}$`,
"--format",
"{{.Names}}",
]);
return stdout.trim() === name;
} catch {
return false;
}
}
async function pingRedis(port) {
// Minimal TCP probe via /dev/tcp — works in bash/zsh but Node has no
// native equivalent, so spawn a short-lived `redis-cli` if available,
// otherwise fall back to a raw socket connect.
return new Promise((resolve) => {
import("node:net").then(({ createConnection }) => {
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
const timeout = setTimeout(() => {
socket.destroy();
resolve(false);
}, 1500);
socket.once("connect", () => {
clearTimeout(timeout);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timeout);
resolve(false);
});
});
});
}
function colorize(text, code) {
if (process.stdout.isTTY === false) return text;
return `\x1b[${code}m${text}\x1b[0m`;
}
function info(msg) {
console.log(colorize("•", "36") + " " + msg);
}
function success(msg) {
console.log(colorize("✓", "32") + " " + msg);
}
function warn(msg) {
console.error(colorize("!", "33") + " " + msg);
}
function fail(msg) {
console.error(colorize("✗", "31") + " " + msg);
}
export function registerRedis(program) {
const redis = program
.command("redis")
.description(
t("redis.description") ||
"Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
);
redis
.command("up")
.description("Start the local Redis container")
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
.option(
"-b, --bind <host>",
"Host interface to publish on (use 0.0.0.0 only together with --password)",
DEFAULT_BIND
)
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
.option("--no-pull", "Skip pulling the image if it is missing")
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.option("--password <password>", "Set a Redis password (AUTH)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisUpCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
redis
.command("down")
.description("Stop and remove the local Redis container")
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("--keep-data", "Keep the named volume for next start")
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisDownCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
redis
.command("status")
.description("Show status of the local Redis container")
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-p, --port <port>", "Host port", DEFAULT_PORT)
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
}
async function pickRuntime(forced) {
if (forced) {
try {
await execFile(forced, ["--version"], { timeout: 3000 });
return forced;
} catch (err) {
fail(`Forced runtime '${forced}' not available: ${err.message}`);
return null;
}
}
const detected = await detectRuntime();
if (!detected) {
fail("Neither podman nor docker found on PATH. Install one or pass --runtime.");
return null;
}
return detected;
}
export async function runRedisUpCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
const port = opts.port || DEFAULT_PORT;
const bind = opts.bind || DEFAULT_BIND;
const image = opts.image || DEFAULT_IMAGE;
const exists = await containerExists(runtime, name);
const running = exists && (await containerRunning(runtime, name));
if (running) {
success(`Container '${name}' is already running on port ${port}.`);
return 0;
}
if (exists && !opts.pull) {
info(`Starting existing container '${name}'…`);
try {
await execFile(runtime, ["start", name]);
success(`Container '${name}' started on port ${port}.`);
return 0;
} catch (err) {
fail(`Failed to start existing container: ${err.message}`);
return 1;
}
}
if (!opts.pull) {
info(`Checking if image '${image}' is present locally…`);
let present = false;
try {
const { stdout } = await execFile(runtime, [
"images",
"--format",
"{{.Repository}}:{{.Tag}}",
]);
present = stdout.split("\n").some((line) => line.trim() === image);
} catch {
// ignore — fall through to pull
}
if (!present) {
info(`Image not found locally — pulling '${image}'…`);
try {
await execFile(runtime, ["pull", image]);
} catch (err) {
fail(`Failed to pull image: ${err.message}`);
return 1;
}
}
}
const args = [
"run",
"-d",
"--name",
name,
"--restart",
"unless-stopped",
"-p",
buildRedisPublishSpec(bind, port),
"-v",
`${DEFAULT_VOLUME}:/data`,
];
if (opts.password) {
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
}
args.push(image, "redis-server", "--appendonly", "yes");
if (opts.password) args.push("--requirepass", opts.password);
info(`Launching ${runtime} run ${args.join(" ")}`);
try {
await execFile(runtime, args);
success(`Container '${name}' is now running on redis://${bind}:${port}`);
info(`Set OMNIROUTE_REDIS_URL=redis://${bind}:${port} in your .env to wire OmniRoute to it.`);
if (bind !== DEFAULT_BIND && !opts.password) {
info(
`Warning: '${bind}' publishes Redis beyond loopback without AUTH. Re-run with --password <secret>.`
);
}
return 0;
} catch (err) {
fail(`Failed to launch container: ${err.message}`);
return 1;
}
}
export async function runRedisDownCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
if (!(await containerExists(runtime, name))) {
info(`Container '${name}' does not exist — nothing to do.`);
return 0;
}
try {
await execFile(runtime, ["rm", "-f", name]);
success(`Removed container '${name}'.`);
} catch (err) {
fail(`Failed to remove container: ${err.message}`);
return 1;
}
if (!opts.keepData) {
try {
await execFile(runtime, ["volume", "rm", DEFAULT_VOLUME]);
success(`Removed volume '${DEFAULT_VOLUME}'.`);
} catch (err) {
warn(`Could not remove volume '${DEFAULT_VOLUME}': ${err.message}`);
}
}
return 0;
}
export async function runRedisStatusCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
const port = opts.port || DEFAULT_PORT;
const exists = await containerExists(runtime, name);
if (!exists) {
console.log(
JSON.stringify(
{ runtime, name, port, exists: false, running: false, reachable: false },
null,
2
)
);
return 0;
}
const running = await containerRunning(runtime, name);
const reachable = running ? await pingRedis(port) : false;
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ runtime, name, port, exists, running, reachable }, null, 2));
return 0;
}
console.log(`\n\x1b[1m\x1b[36mRedis (${runtime})\x1b[0m\n`);
console.log(` Container: ${name}`);
console.log(` Exists: ${exists ? "yes" : "no"}`);
console.log(` Running: ${running ? "yes" : "no"}`);
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
if (running && !reachable) {
warn(
"Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"
);
}
if (!running) {
info(`Run 'omniroute redis up' to launch it.`);
}
return 0;
}