fix(cli): bound diagnostics and add swap management
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { runCommand } from "./command";
|
||||
import { repoRoot } from "./config";
|
||||
|
||||
const defaultSwapPath = "/swapfile";
|
||||
const defaultSwapSizeBytes = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
export interface SwapArea {
|
||||
filename: string;
|
||||
type: string;
|
||||
sizeBytes: number;
|
||||
usedBytes: number;
|
||||
priority: number | null;
|
||||
}
|
||||
|
||||
export interface SwapMemoryStatus {
|
||||
totalBytes: number;
|
||||
availableBytes: number | null;
|
||||
swapTotalBytes: number;
|
||||
swapFreeBytes: number;
|
||||
}
|
||||
|
||||
export interface SwapStatus {
|
||||
memory: SwapMemoryStatus;
|
||||
activeSwaps: SwapArea[];
|
||||
configuredPath: string;
|
||||
configuredPathExists: boolean;
|
||||
configuredPathMode: string | null;
|
||||
configuredPathSizeBytes: number | null;
|
||||
configuredPathActive: boolean;
|
||||
fstab: {
|
||||
path: string;
|
||||
writable: boolean;
|
||||
persisted: boolean;
|
||||
matchingLine: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface SwapEnsureResult {
|
||||
ok: boolean;
|
||||
status: "ok" | "degraded" | "failed";
|
||||
requested: {
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
before: SwapStatus;
|
||||
after: SwapStatus;
|
||||
actions: Array<{ action: string; ok: boolean; detail?: unknown }>;
|
||||
errors: Array<{ action: string; message: string; detail?: unknown }>;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function parseByteCount(value: string): number {
|
||||
const raw = value.trim();
|
||||
if (/^\d+$/u.test(raw)) return Number(raw);
|
||||
const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)([KMGTPE]?i?B?)$/iu);
|
||||
if (!match) return 0;
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const powers: Record<string, number> = {
|
||||
K: 1,
|
||||
KB: 1,
|
||||
KIB: 1,
|
||||
M: 2,
|
||||
MB: 2,
|
||||
MIB: 2,
|
||||
G: 3,
|
||||
GB: 3,
|
||||
GIB: 3,
|
||||
T: 4,
|
||||
TB: 4,
|
||||
TIB: 4,
|
||||
P: 5,
|
||||
PB: 5,
|
||||
PIB: 5,
|
||||
E: 6,
|
||||
EB: 6,
|
||||
EIB: 6,
|
||||
};
|
||||
return Math.round(amount * (1024 ** (powers[unit] ?? 0)));
|
||||
}
|
||||
|
||||
function parseMeminfo(): SwapMemoryStatus {
|
||||
const raw = readFileSync("/proc/meminfo", "utf8");
|
||||
const values = new Map<string, number>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const match = line.match(/^([^:]+):\s+(\d+)\s+kB/u);
|
||||
if (match) values.set(match[1], Number(match[2]) * 1024);
|
||||
}
|
||||
return {
|
||||
totalBytes: values.get("MemTotal") ?? 0,
|
||||
availableBytes: values.get("MemAvailable") ?? null,
|
||||
swapTotalBytes: values.get("SwapTotal") ?? 0,
|
||||
swapFreeBytes: values.get("SwapFree") ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSwaps(): SwapArea[] {
|
||||
if (!existsSync("/proc/swaps")) return [];
|
||||
const lines = readFileSync("/proc/swaps", "utf8").trim().split("\n").slice(1);
|
||||
return lines.map((line) => line.trim().split(/\s+/u)).filter((parts) => parts.length >= 5).map(([filename, type, sizeKiB, usedKiB, priority]) => ({
|
||||
filename,
|
||||
type,
|
||||
sizeBytes: Number(sizeKiB) * 1024,
|
||||
usedBytes: Number(usedKiB) * 1024,
|
||||
priority: Number.isFinite(Number(priority)) ? Number(priority) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function fileMode(path: string): string | null {
|
||||
if (!existsSync(path)) return null;
|
||||
return (statSync(path).mode & 0o777).toString(8).padStart(3, "0");
|
||||
}
|
||||
|
||||
function fstabStatus(path: string): SwapStatus["fstab"] {
|
||||
const fstabPath = "/etc/fstab";
|
||||
try {
|
||||
const raw = existsSync(fstabPath) ? readFileSync(fstabPath, "utf8") : "";
|
||||
let writable = false;
|
||||
try {
|
||||
accessSync(fstabPath, constants.W_OK);
|
||||
writable = true;
|
||||
} catch {
|
||||
writable = false;
|
||||
}
|
||||
const matchingLine = raw.split("\n").find((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) return false;
|
||||
const parts = trimmed.split(/\s+/u);
|
||||
return parts[0] === path && parts[2] === "swap";
|
||||
}) ?? null;
|
||||
return {
|
||||
path: fstabPath,
|
||||
writable,
|
||||
persisted: matchingLine !== null,
|
||||
matchingLine,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: fstabPath,
|
||||
writable: false,
|
||||
persisted: false,
|
||||
matchingLine: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function swapStatus(path = defaultSwapPath): SwapStatus {
|
||||
const memory = parseMeminfo();
|
||||
const activeSwaps = parseSwaps();
|
||||
const configuredPathExists = existsSync(path);
|
||||
const configuredPathSizeBytes = configuredPathExists ? statSync(path).size : null;
|
||||
const configuredPathActive = activeSwaps.some((swap) => swap.filename === path);
|
||||
const warning = memory.swapTotalBytes > 0 ? null : "swap is not active; low-memory main servers are at risk of global OOM during builds or diagnostics";
|
||||
return {
|
||||
memory,
|
||||
activeSwaps,
|
||||
configuredPath: path,
|
||||
configuredPathExists,
|
||||
configuredPathMode: fileMode(path),
|
||||
configuredPathSizeBytes,
|
||||
configuredPathActive,
|
||||
fstab: fstabStatus(path),
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
function pushAction(
|
||||
actions: SwapEnsureResult["actions"],
|
||||
errors: SwapEnsureResult["errors"],
|
||||
action: string,
|
||||
command: string[],
|
||||
): boolean {
|
||||
const result = runCommand(command, repoRoot, { timeoutMs: 120_000 });
|
||||
const ok = result.exitCode === 0;
|
||||
const detail = {
|
||||
command,
|
||||
exitCode: result.exitCode,
|
||||
stdoutTail: result.stdout.slice(-1200),
|
||||
stderrTail: result.stderr.slice(-1200),
|
||||
timedOut: result.timedOut,
|
||||
};
|
||||
actions.push({ action, ok, detail });
|
||||
if (!ok) {
|
||||
errors.push({
|
||||
action,
|
||||
message: result.stderr.trim() || result.stdout.trim() || `command failed with exit code ${result.exitCode}`,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function ensureFstabLine(path: string): { ok: boolean; action: string; detail: unknown } {
|
||||
const line = `${path} none swap sw 0 0`;
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
"touch /etc/fstab",
|
||||
`grep -Eq '^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[[:space:]]+[^[:space:]]+[[:space:]]+swap[[:space:]]' /etc/fstab || printf '%s\\n' ${shellQuote(line)} >> /etc/fstab`,
|
||||
].join("\n");
|
||||
const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: 30_000 });
|
||||
return {
|
||||
ok: result.exitCode === 0,
|
||||
action: "persist-fstab",
|
||||
detail: {
|
||||
command: ["bash", "-lc", script],
|
||||
exitCode: result.exitCode,
|
||||
stdoutTail: result.stdout.slice(-1200),
|
||||
stderrTail: result.stderr.slice(-1200),
|
||||
timedOut: result.timedOut,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseSizeOption(args: string[], defaultBytes: number): number {
|
||||
const index = args.indexOf("--size");
|
||||
const raw = index === -1 ? undefined : args[index + 1];
|
||||
if (raw === undefined) return defaultBytes;
|
||||
const bytes = parseByteCount(raw);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) throw new Error("--size must be a positive byte count such as 2GiB or 4096M");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parsePathOption(args: string[], defaultPath: string): string {
|
||||
const index = args.indexOf("--path");
|
||||
if (index === -1) return defaultPath;
|
||||
const raw = args[index + 1];
|
||||
if (raw === undefined || !raw.startsWith("/")) throw new Error("--path must be an absolute path");
|
||||
return raw;
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
export function runSwapCommand(args: string[]): unknown {
|
||||
const [action = "status"] = args;
|
||||
const path = parsePathOption(args, defaultSwapPath);
|
||||
if (action === "status") return swapStatus(path);
|
||||
if (action === "ensure") {
|
||||
const sizeBytes = parseSizeOption(args, defaultSwapSizeBytes);
|
||||
const dryRun = hasFlag(args, "--dry-run");
|
||||
const before = swapStatus(path);
|
||||
const actions: SwapEnsureResult["actions"] = [];
|
||||
const errors: SwapEnsureResult["errors"] = [];
|
||||
if (before.memory.swapTotalBytes > 0) {
|
||||
actions.push({ action: "noop-existing-swap", ok: true, detail: { activeSwaps: before.activeSwaps } });
|
||||
const after = swapStatus(path);
|
||||
return { ok: true, status: "ok", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult;
|
||||
}
|
||||
if (dryRun) {
|
||||
actions.push({ action: "dry-run", ok: true, detail: { wouldCreate: path, sizeBytes, wouldPersistFstab: true } });
|
||||
const after = swapStatus(path);
|
||||
return { ok: true, status: "degraded", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult;
|
||||
}
|
||||
if (!existsSync(path)) {
|
||||
const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024);
|
||||
const allocated = pushAction(actions, errors, "allocate-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]);
|
||||
if (!allocated) pushAction(actions, errors, "allocate-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]);
|
||||
} else {
|
||||
const existingBytes = statSync(path).size;
|
||||
if (existingBytes < sizeBytes) {
|
||||
const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024);
|
||||
const resized = pushAction(actions, errors, "resize-existing-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]);
|
||||
if (!resized) pushAction(actions, errors, "resize-existing-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]);
|
||||
} else {
|
||||
actions.push({ action: "reuse-existing-swapfile-path", ok: true, detail: { path, sizeBytes: existingBytes } });
|
||||
}
|
||||
}
|
||||
pushAction(actions, errors, "chmod-600", ["chmod", "600", path]);
|
||||
pushAction(actions, errors, "mkswap", ["mkswap", path]);
|
||||
pushAction(actions, errors, "swapon", ["swapon", path]);
|
||||
const persist = ensureFstabLine(path);
|
||||
actions.push({ action: persist.action, ok: persist.ok, detail: persist.detail });
|
||||
if (!persist.ok) {
|
||||
errors.push({
|
||||
action: persist.action,
|
||||
message: "swap is active but /etc/fstab could not be updated; rerun ensure as root or add the returned fstab line manually",
|
||||
detail: persist.detail,
|
||||
});
|
||||
}
|
||||
const after = swapStatus(path);
|
||||
const swapActive = after.memory.swapTotalBytes > 0;
|
||||
const status = swapActive && after.fstab.persisted ? "ok" : swapActive ? "degraded" : "failed";
|
||||
return {
|
||||
ok: status !== "failed",
|
||||
status,
|
||||
requested: { path, sizeBytes },
|
||||
before,
|
||||
after,
|
||||
actions,
|
||||
errors,
|
||||
} satisfies SwapEnsureResult;
|
||||
}
|
||||
throw new Error("server swap command must be one of: status, ensure");
|
||||
}
|
||||
Reference in New Issue
Block a user