diff --git a/src/app/api/local/redis/redisRuntime.ts b/src/app/api/local/redis/redisRuntime.ts new file mode 100644 index 0000000000..5417b3db8f --- /dev/null +++ b/src/app/api/local/redis/redisRuntime.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +export const REDIS_CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis"; + +export const RUNTIME_PREFERENCE = ["podman", "docker"] as const; + +type ExecFileAsync = ( + file: string, + args: readonly string[], + options: { timeout: number } +) => Promise<{ stdout: string; stderr: string }>; + +const execFileAsync = promisify(execFile) as ExecFileAsync; + +export async function detectRedisContainerRuntime( + runCommand: ExecFileAsync = execFileAsync +): Promise { + for (const candidate of RUNTIME_PREFERENCE) { + try { + await runCommand(candidate, ["--version"], { timeout: 3000 }); + return candidate; + } catch { + // try next + } + } + return null; +} + +export function redisRuntimeUnavailableResponse() { + return NextResponse.json( + { ok: false, error: "No container runtime (podman or docker) found on PATH" }, + { status: 503 } + ); +} + +export async function runRedisRuntimeCommand( + runtime: string, + args: readonly string[], + timeout: number, + runCommand: ExecFileAsync = execFileAsync +) { + const { stdout, stderr } = await runCommand(runtime, args, { timeout }); + return { stdout: stdout.trim(), stderr: stderr.trim() }; +} diff --git a/src/app/api/local/redis/start/route.ts b/src/app/api/local/redis/start/route.ts index 5bf4d5e9cb..671d255f8d 100644 --- a/src/app/api/local/redis/start/route.ts +++ b/src/app/api/local/redis/start/route.ts @@ -1,42 +1,27 @@ import { NextResponse } from "next/server"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { isLocalRequestAllowed } from "@/lib/security/localEndpoints"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -const execFileAsync = promisify(execFile); +import { + REDIS_CONTAINER_NAME, + detectRedisContainerRuntime, + redisRuntimeUnavailableResponse, + runRedisRuntimeCommand, +} from "../redisRuntime"; -const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis"; const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379"; const IMAGE = process.env.OMNIROUTE_REDIS_IMAGE || "docker.io/redis:7-alpine"; -const RUNTIME_PREFERENCE = ["podman", "docker"]; - -async function detectRuntime(): Promise { - for (const candidate of RUNTIME_PREFERENCE) { - try { - await execFileAsync(candidate, ["--version"], { timeout: 3000 }); - return candidate; - } catch { - // try next - } - } - return null; -} - export async function POST() { const guard = isLocalRequestAllowed(); if (!guard.allowed) { return NextResponse.json({ error: guard.reason }, { status: 403 }); } - const runtime = await detectRuntime(); + const runtime = await detectRedisContainerRuntime(); if (!runtime) { - return NextResponse.json( - { ok: false, error: "No container runtime (podman or docker) found on PATH" }, - { status: 503 } - ); + return redisRuntimeUnavailableResponse(); } try { @@ -48,15 +33,22 @@ export async function POST() { "run", "-d", "--name", - CONTAINER_NAME, + REDIS_CONTAINER_NAME, "-p", `${HOST_PORT}:6379`, "--restart", "unless-stopped", IMAGE, ]; - const { stdout, stderr } = await execFileAsync(runtime, args, { timeout: 30_000 }); - return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, port: HOST_PORT, stdout: stdout.trim(), stderr: stderr.trim() }); + const { stdout, stderr } = await runRedisRuntimeCommand(runtime, args, 30_000); + return NextResponse.json({ + ok: true, + runtime, + name: REDIS_CONTAINER_NAME, + port: HOST_PORT, + stdout, + stderr, + }); } catch (err) { // Hard Rule #12: never put a raw execFile error (command line + paths) in the body. return NextResponse.json( @@ -64,4 +56,4 @@ export async function POST() { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/api/local/redis/stop/route.ts b/src/app/api/local/redis/stop/route.ts index 6f7f73c59f..4ce9fd224a 100644 --- a/src/app/api/local/redis/stop/route.ts +++ b/src/app/api/local/redis/stop/route.ts @@ -1,27 +1,14 @@ import { NextResponse } from "next/server"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { isLocalRequestAllowed } from "@/lib/security/localEndpoints"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -const execFileAsync = promisify(execFile); - -const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis"; - -const RUNTIME_PREFERENCE = ["podman", "docker"]; - -async function detectRuntime(): Promise { - for (const candidate of RUNTIME_PREFERENCE) { - try { - await execFileAsync(candidate, ["--version"], { timeout: 3000 }); - return candidate; - } catch { - // try next - } - } - return null; -} +import { + REDIS_CONTAINER_NAME, + detectRedisContainerRuntime, + redisRuntimeUnavailableResponse, + runRedisRuntimeCommand, +} from "../redisRuntime"; export async function POST() { const guard = isLocalRequestAllowed(); @@ -29,17 +16,24 @@ export async function POST() { return NextResponse.json({ error: guard.reason }, { status: 403 }); } - const runtime = await detectRuntime(); + const runtime = await detectRedisContainerRuntime(); if (!runtime) { - return NextResponse.json( - { ok: false, error: "No container runtime (podman or docker) found on PATH" }, - { status: 503 } - ); + return redisRuntimeUnavailableResponse(); } try { - const { stdout, stderr } = await execFileAsync(runtime, ["stop", CONTAINER_NAME], { timeout: 15_000 }); - return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, stdout: stdout.trim(), stderr: stderr.trim() }); + const { stdout, stderr } = await runRedisRuntimeCommand( + runtime, + ["stop", REDIS_CONTAINER_NAME], + 15_000 + ); + return NextResponse.json({ + ok: true, + runtime, + name: REDIS_CONTAINER_NAME, + stdout, + stderr, + }); } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err); // exit code != 0 from `stop` typically means "not running" — surface that as ok=false but don't 500 @@ -52,4 +46,4 @@ export async function POST() { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/tests/unit/local-redis-runtime.test.ts b/tests/unit/local-redis-runtime.test.ts new file mode 100644 index 0000000000..7898495a79 --- /dev/null +++ b/tests/unit/local-redis-runtime.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + REDIS_CONTAINER_NAME, + detectRedisContainerRuntime, + redisRuntimeUnavailableResponse, + runRedisRuntimeCommand, +} from "../../src/app/api/local/redis/redisRuntime.ts"; + +test("detectRedisContainerRuntime returns the first available runtime", async () => { + const calls: string[] = []; + const runtime = await detectRedisContainerRuntime(async (file) => { + calls.push(file); + if (file === "podman") { + throw new Error("missing"); + } + return { stdout: "Docker version 1\n", stderr: "" }; + }); + + assert.equal(runtime, "docker"); + assert.deepEqual(calls, ["podman", "docker"]); +}); + +test("detectRedisContainerRuntime returns null when no runtime is available", async () => { + const runtime = await detectRedisContainerRuntime(async () => { + throw new Error("missing"); + }); + + assert.equal(runtime, null); +}); + +test("runRedisRuntimeCommand trims command output", async () => { + const result = await runRedisRuntimeCommand( + "docker", + ["stop", REDIS_CONTAINER_NAME], + 15_000, + async () => ({ + stdout: " stopped \n", + stderr: " warning \n", + }) + ); + + assert.deepEqual(result, { stdout: "stopped", stderr: "warning" }); +}); + +test("redisRuntimeUnavailableResponse preserves the route error shape", async () => { + const response = redisRuntimeUnavailableResponse(); + + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { + ok: false, + error: "No container runtime (podman or docker) found on PATH", + }); +});