Files
2026-07-13 13:39:12 +08:00

294 lines
8.9 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";
const RUNTIME_PREFERENCE = ["podman", "docker"];
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("-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 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", `${port}:6379`,
"-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://127.0.0.1:${port}`);
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
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;
}