Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/app/api/local/redis/redisRuntime.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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() };
}
46 changes: 19 additions & 27 deletions src/app/api/local/redis/start/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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 {
Expand All @@ -48,20 +33,27 @@ 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(
{ ok: false, runtime, error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}
}
48 changes: 21 additions & 27 deletions src/app/api/local/redis/stop/route.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,39 @@
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<string | null> {
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();
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 {
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
Expand All @@ -52,4 +46,4 @@ export async function POST() {
{ status: 500 }
);
}
}
}
55 changes: 55 additions & 0 deletions tests/unit/local-redis-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
Loading