Files
pikasTech-unidesk/scripts/src/swap.ts
T

304 lines
10 KiB
TypeScript

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");
}