feat: add confirmed server cleanup execution
This commit is contained in:
+103
-11
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, opendirSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
@@ -13,6 +13,7 @@ type GcItemKind =
|
||||
| "journal-vacuum"
|
||||
| "docker-build-cache-prune"
|
||||
| "tmp-path-delete"
|
||||
| "stale-tmp-path-delete"
|
||||
| "browser-cache-delete"
|
||||
| "tool-cache-delete"
|
||||
| "vscode-server-delete"
|
||||
@@ -33,6 +34,7 @@ interface GcOptions {
|
||||
buildCacheAll: boolean;
|
||||
tmp: boolean;
|
||||
tmpMinAgeHours: number;
|
||||
staleTmp: boolean;
|
||||
browserCache: boolean;
|
||||
toolCaches: boolean;
|
||||
vscodeStaleServers: boolean;
|
||||
@@ -169,6 +171,7 @@ const DEFAULT_OPTIONS: GcOptions = {
|
||||
buildCacheAll: false,
|
||||
tmp: true,
|
||||
tmpMinAgeHours: 24,
|
||||
staleTmp: false,
|
||||
browserCache: false,
|
||||
toolCaches: false,
|
||||
vscodeStaleServers: false,
|
||||
@@ -225,6 +228,17 @@ const TMP_EXACT_PROTECT = new Set([
|
||||
"/tmp/tmux-0",
|
||||
]);
|
||||
|
||||
const STALE_TMP_PROTECTED_PREFIXES = [
|
||||
".X",
|
||||
".ICE",
|
||||
".font-unix",
|
||||
".Test-unix",
|
||||
"systemd-private-",
|
||||
"tmux-",
|
||||
"ssh-",
|
||||
"vscode-",
|
||||
];
|
||||
|
||||
const TOOL_CACHE_ALLOWLIST = [
|
||||
{
|
||||
id: "npm-cacache",
|
||||
@@ -281,6 +295,9 @@ const TOOL_CACHE_ALLOWLIST = [
|
||||
const VSCODE_SERVER_ROOT = "/root/.vscode-server/cli/servers";
|
||||
const VSCODE_EXTENSION_ROOT = "/root/.vscode-server/extensions";
|
||||
const BAIDU_STAGING_RELATIVE_ROOT = [".state", "baidu-netdisk", "staging"];
|
||||
const DEFAULT_PATH_SIZE_TIMEOUT_MS = 5_000;
|
||||
const STALE_TMP_PATH_SIZE_TIMEOUT_MS = 1_500;
|
||||
const STALE_TMP_MAX_CANDIDATES = 1_000;
|
||||
|
||||
export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "plan", ...rest] = args;
|
||||
@@ -374,6 +391,10 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
|
||||
if (options.tmp) {
|
||||
candidates.push(...collectTmpCandidates(options, observedAt));
|
||||
}
|
||||
if (options.staleTmp) {
|
||||
const alreadySelected = new Set(candidates.map((candidate) => candidate.path).filter((path): path is string => path !== undefined));
|
||||
candidates.push(...collectStaleTmpCandidates(options, observedAt, alreadySelected));
|
||||
}
|
||||
if (options.browserCache) {
|
||||
const item = collectBrowserCacheCandidate();
|
||||
if (item !== null) candidates.push(item);
|
||||
@@ -459,7 +480,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
|
||||
notes: [
|
||||
"gc run only executes listed one-time cleanup actions after --confirm.",
|
||||
options.full ? "Full candidate output requested." : `Default output is capped to ${options.limit} candidates; use --full or --limit N for broader disclosure.`,
|
||||
"Tool caches, stale VS Code server versions and stale VS Code extension versions are opt-in and require explicit include flags.",
|
||||
"Tool caches, stale /tmp direct children, stale VS Code server versions and stale VS Code extension versions are opt-in and require explicit include flags.",
|
||||
"Baidu Netdisk staging cleanup is opt-in and only selects old PGDATA backup tarballs under server-data/unidesk-pg-data.",
|
||||
"Database event retention is diagnostic-only in this command; cleanups for oa_events require a backup and a separate schema/retention change.",
|
||||
"Docker image cleanup stays under server cleanup plan; gc does not run docker system prune or docker image prune.",
|
||||
@@ -540,6 +561,10 @@ function parseGcOptions(args: string[]): GcOptions {
|
||||
options.buildCacheAll = true;
|
||||
} else if (arg === "--tmp-min-age-hours") {
|
||||
options.tmpMinAgeHours = parseNonNegativeNumber(arg, args[++index]);
|
||||
} else if (arg === "--include-stale-tmp") {
|
||||
options.staleTmp = true;
|
||||
} else if (arg === "--no-stale-tmp") {
|
||||
options.staleTmp = false;
|
||||
} else if (arg === "--include-browser-cache") {
|
||||
options.browserCache = true;
|
||||
} else if (arg === "--no-browser-cache") {
|
||||
@@ -705,6 +730,7 @@ function publicOptions(options: GcOptions): Record<string, unknown> {
|
||||
buildCacheAll: options.buildCacheAll,
|
||||
tmp: options.tmp,
|
||||
tmpMinAgeHours: options.tmpMinAgeHours,
|
||||
staleTmp: options.staleTmp,
|
||||
browserCache: options.browserCache,
|
||||
toolCaches: options.toolCaches,
|
||||
vscodeStaleServers: options.vscodeStaleServers,
|
||||
@@ -834,7 +860,7 @@ function collectTmpCandidates(options: GcOptions, observedAt: string): GcCandida
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoffMs) continue;
|
||||
const sizeBytes = safePathSize(path);
|
||||
const sizeBytes = safePathSize(path, STALE_TMP_PATH_SIZE_TIMEOUT_MS);
|
||||
if (sizeBytes <= 0) continue;
|
||||
result.push({
|
||||
id: `tmp:${path}`,
|
||||
@@ -850,6 +876,52 @@ function collectTmpCandidates(options: GcOptions, observedAt: string): GcCandida
|
||||
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
}
|
||||
|
||||
function collectStaleTmpCandidates(options: GcOptions, observedAt: string, alreadySelected: Set<string>): GcCandidate[] {
|
||||
const root = "/tmp";
|
||||
if (!existsSync(root)) return [];
|
||||
const cutoffMs = new Date(observedAt).getTime() - options.tmpMinAgeHours * 60 * 60 * 1000;
|
||||
const candidateLimit = Math.min(options.limit, STALE_TMP_MAX_CANDIDATES);
|
||||
const result: GcCandidate[] = [];
|
||||
const dir = opendirSync(root);
|
||||
try {
|
||||
let entry;
|
||||
while ((entry = dir.readSync()) !== null && result.length < candidateLimit) {
|
||||
const name = entry.name;
|
||||
const path = join(root, name);
|
||||
if (alreadySelected.has(path)) continue;
|
||||
if (TMP_EXACT_PROTECT.has(path)) continue;
|
||||
if (isStaleTmpProtectedName(name)) continue;
|
||||
if (!entry.isDirectory() && !entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoffMs) continue;
|
||||
const sizeBytes = safePathSize(path, STALE_TMP_PATH_SIZE_TIMEOUT_MS);
|
||||
if (sizeBytes <= 0) continue;
|
||||
result.push({
|
||||
id: `stale-tmp:${path}`,
|
||||
kind: "stale-tmp-path-delete",
|
||||
risk: "medium",
|
||||
description: `Delete one bounded direct /tmp child older than ${options.tmpMinAgeHours} hours`,
|
||||
path,
|
||||
sizeBytes,
|
||||
estimatedReclaimBytes: sizeBytes,
|
||||
action: { op: "rm-recursive", allowlist: "tmp-direct-stale", minAgeHours: options.tmpMinAgeHours, boundedByLimit: candidateLimit },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dir.closeSync();
|
||||
}
|
||||
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
}
|
||||
|
||||
function isStaleTmpProtectedName(name: string): boolean {
|
||||
return STALE_TMP_PROTECTED_PREFIXES.some((prefix) => name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function collectBrowserCacheCandidate(): GcCandidate | null {
|
||||
const path = rootPath(".state", "playwright-browsers");
|
||||
if (!existsSync(path)) return null;
|
||||
@@ -1361,6 +1433,12 @@ function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaim
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "stale-tmp-path-delete" && candidate.path !== undefined) {
|
||||
assertStaleTmpCandidatePath(candidate.path);
|
||||
const before = safePathSize(candidate.path);
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "browser-cache-delete" && candidate.path !== undefined) {
|
||||
const expected = rootPath(".state", "playwright-browsers");
|
||||
if (resolve(candidate.path) !== resolve(expected)) throw new Error(`refusing to remove unexpected browser cache path: ${candidate.path}`);
|
||||
@@ -1447,6 +1525,16 @@ function assertTmpCandidatePath(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertStaleTmpCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
if (!resolved.startsWith("/tmp/")) throw new Error(`refusing to remove non-/tmp path: ${path}`);
|
||||
if (TMP_EXACT_PROTECT.has(resolved)) throw new Error(`refusing to remove protected tmp path: ${path}`);
|
||||
const relativePath = resolved.slice("/tmp/".length);
|
||||
if (relativePath.length === 0 || relativePath.includes("/")) throw new Error(`refusing to remove nested tmp path: ${path}`);
|
||||
const name = basename(resolved);
|
||||
if (isStaleTmpProtectedName(name)) throw new Error(`refusing to remove protected stale tmp path: ${path}`);
|
||||
}
|
||||
|
||||
function assertToolCacheCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
const allowed = TOOL_CACHE_ALLOWLIST.some((item) => resolve(item.path) === resolved);
|
||||
@@ -1560,19 +1648,23 @@ function collectFiles(root: string): Array<{ path: string; sizeBytes: number; mt
|
||||
return result;
|
||||
}
|
||||
|
||||
function safePathSize(path: string): number {
|
||||
function safePathSize(path: string, timeoutMs = DEFAULT_PATH_SIZE_TIMEOUT_MS): number {
|
||||
return pathSizeFromDu(path, timeoutMs) ?? 0;
|
||||
}
|
||||
|
||||
function pathSizeFromDu(path: string, timeoutMs: number): number | null {
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isFile() || stat.isSymbolicLink()) return stat.size;
|
||||
if (!stat.isDirectory()) return 0;
|
||||
let total = 0;
|
||||
for (const entry of readdirSync(path)) {
|
||||
total += safePathSize(join(path, entry));
|
||||
}
|
||||
return total;
|
||||
if (!stat.isDirectory()) return null;
|
||||
} catch {
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
const result = command(["du", "-sb", "--one-file-system", "--", path], timeoutMs);
|
||||
if (result.exitCode !== 0 || result.timedOut) return null;
|
||||
const rawSize = result.stdout.trim().split(/\s+/u)[0] ?? "";
|
||||
const sizeBytes = Number(rawSize);
|
||||
return Number.isFinite(sizeBytes) && sizeBytes >= 0 ? sizeBytes : null;
|
||||
}
|
||||
|
||||
function safeFileSize(path: string): number {
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface DockerCleanupInventory {
|
||||
export interface ServerCleanupPlanOptions {
|
||||
minAgeHours: number;
|
||||
limit: number;
|
||||
confirm: boolean;
|
||||
includeHighRisk: boolean;
|
||||
}
|
||||
|
||||
export interface CleanupImageSummary {
|
||||
@@ -92,6 +94,15 @@ export interface CleanupCommandReview {
|
||||
reviewChecklist: string[];
|
||||
}
|
||||
|
||||
interface DiskSnapshot {
|
||||
filesystem: string;
|
||||
sizeBytes: number;
|
||||
usedBytes: number;
|
||||
availableBytes: number;
|
||||
usePercent: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
export interface ServerCleanupPlan {
|
||||
ok: boolean;
|
||||
dryRun: true;
|
||||
@@ -107,7 +118,7 @@ export interface ServerCleanupPlan {
|
||||
dockerVolumesTouched: false;
|
||||
dataDirectoriesTouched: false;
|
||||
databaseCleanupIncluded: false;
|
||||
liveCleanupImplemented: false;
|
||||
liveCleanupImplemented: boolean;
|
||||
note: string;
|
||||
};
|
||||
inventory: {
|
||||
@@ -147,6 +158,49 @@ export interface ServerCleanupPlan {
|
||||
prohibitedCommands: string[];
|
||||
}
|
||||
|
||||
interface ServerCleanupRun {
|
||||
ok: boolean;
|
||||
dryRun: false;
|
||||
mutation: true;
|
||||
action: "server cleanup run";
|
||||
scope: "docker-images-only";
|
||||
observedAt: string;
|
||||
options: ServerCleanupPlanOptions;
|
||||
diskBefore: DiskSnapshot | null;
|
||||
diskAfter: DiskSnapshot | null;
|
||||
summary: {
|
||||
plannedCandidateCount: number;
|
||||
attemptedCount: number;
|
||||
succeededCount: number;
|
||||
failedCount: number;
|
||||
skippedHighRiskCount: number;
|
||||
estimatedReclaimBytes: number;
|
||||
actualDiskReclaimBytes: number | null;
|
||||
};
|
||||
results: Array<{
|
||||
imageId: string;
|
||||
shortId: string;
|
||||
repoTags: string[];
|
||||
repoDigests: string[];
|
||||
risk: Exclude<RiskLevel, "blocked">;
|
||||
estimatedReclaimBytes: number;
|
||||
command: string[];
|
||||
status: "succeeded" | "failed" | "skipped";
|
||||
reason?: string;
|
||||
exitCode?: number | null;
|
||||
stdoutTail?: string;
|
||||
stderrTail?: string;
|
||||
}>;
|
||||
policy: {
|
||||
deletionExecuted: true;
|
||||
dockerPruneUsed: false;
|
||||
dockerVolumesTouched: false;
|
||||
dataDirectoriesTouched: false;
|
||||
databaseCleanupIncluded: false;
|
||||
highRiskRequiresIncludeFlag: true;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeployServiceCommit {
|
||||
environment: string;
|
||||
serviceId: string;
|
||||
@@ -156,6 +210,8 @@ interface DeployServiceCommit {
|
||||
const defaultOptions: ServerCleanupPlanOptions = {
|
||||
minAgeHours: 24,
|
||||
limit: 200,
|
||||
confirm: false,
|
||||
includeHighRisk: false,
|
||||
};
|
||||
|
||||
export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOptions {
|
||||
@@ -174,8 +230,16 @@ export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOpti
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--limit must be a positive integer");
|
||||
options.limit = Math.min(value, 1000);
|
||||
index += 1;
|
||||
} else if (arg === "--confirm") {
|
||||
options.confirm = true;
|
||||
} else if (arg === "--dry-run") {
|
||||
options.confirm = false;
|
||||
} else if (arg === "--include-high-risk") {
|
||||
options.includeHighRisk = true;
|
||||
} else if (arg === "--no-high-risk") {
|
||||
options.includeHighRisk = false;
|
||||
} else {
|
||||
throw new Error(`unknown server cleanup plan option: ${arg}`);
|
||||
throw new Error(`unknown server cleanup option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
@@ -186,14 +250,31 @@ export async function runServerCleanupCommand(config: UniDeskConfig, args: strin
|
||||
if (action === "plan" || action === "dry-run") {
|
||||
return serverCleanupPlan(config, parseServerCleanupOptions(rest));
|
||||
}
|
||||
if (action === "run") {
|
||||
const options = parseServerCleanupOptions(rest);
|
||||
if (!options.confirm) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "server-cleanup-run-requires-confirm",
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
next: {
|
||||
plan: `bun scripts/cli.ts server cleanup plan --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
|
||||
confirm: `bun scripts/cli.ts server cleanup run --confirm --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
|
||||
},
|
||||
policy: "server cleanup run removes only listed stale Docker images; high-risk images require --include-high-risk.",
|
||||
};
|
||||
}
|
||||
return serverCleanupRun(config, options);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-server-cleanup-action",
|
||||
action,
|
||||
supportedActions: ["plan"],
|
||||
supportedActions: ["plan", "run"],
|
||||
dryRunOnly: true,
|
||||
mutation: false,
|
||||
policy: "This task implements only server cleanup plan. Real image deletion is intentionally not implemented and must require a future explicit approval parameter.",
|
||||
policy: "server cleanup run requires --confirm and removes only stale Docker images selected by the same plan classifier.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,8 +388,8 @@ export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, option
|
||||
dockerVolumesTouched: false,
|
||||
dataDirectoriesTouched: false,
|
||||
databaseCleanupIncluded: false,
|
||||
liveCleanupImplemented: false,
|
||||
note: "This command only inventories Docker images and builds a dry-run review plan. It never runs docker image rm, docker prune, docker volume rm, rm, or database cleanup.",
|
||||
liveCleanupImplemented: true,
|
||||
note: "This command inventories Docker images and builds a dry-run review plan. Confirmed execution is available through server cleanup run --confirm; it never runs docker prune, docker volume rm, rm, or database cleanup.",
|
||||
},
|
||||
inventory: {
|
||||
dockerAvailable: inventory.collection.dockerAvailable,
|
||||
@@ -369,6 +450,91 @@ export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, option
|
||||
};
|
||||
}
|
||||
|
||||
export function serverCleanupRun(config: UniDeskConfig, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupRun {
|
||||
const diskBefore = rootDiskSnapshot();
|
||||
const plan = serverCleanupPlan(config, options);
|
||||
const selected = plan.candidateStaleImages;
|
||||
const results: ServerCleanupRun["results"] = [];
|
||||
|
||||
for (const image of selected) {
|
||||
const command = image.commandsToReview[0] ?? imageRemoveCommand({
|
||||
id: image.id,
|
||||
repoTags: image.repoTags,
|
||||
repoDigests: image.repoDigests,
|
||||
sizeBytes: image.sizeBytes,
|
||||
createdAt: image.createdAt,
|
||||
labels: {},
|
||||
});
|
||||
if (image.risk === "high" && !options.includeHighRisk) {
|
||||
results.push({
|
||||
imageId: image.id,
|
||||
shortId: image.shortId,
|
||||
repoTags: image.repoTags,
|
||||
repoDigests: image.repoDigests,
|
||||
risk: image.risk,
|
||||
estimatedReclaimBytes: image.sizeBytes,
|
||||
command,
|
||||
status: "skipped",
|
||||
reason: "high-risk-requires-include-high-risk",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const remove = runCommand(command, repoRoot, { timeoutMs: 60_000 });
|
||||
const presentAfterRemove = remove.exitCode === 0 ? false : dockerImagePresent(image.id);
|
||||
const succeeded = remove.exitCode === 0 || presentAfterRemove === false;
|
||||
results.push({
|
||||
imageId: image.id,
|
||||
shortId: image.shortId,
|
||||
repoTags: image.repoTags,
|
||||
repoDigests: image.repoDigests,
|
||||
risk: image.risk,
|
||||
estimatedReclaimBytes: image.sizeBytes,
|
||||
command,
|
||||
status: succeeded ? "succeeded" : "failed",
|
||||
reason: remove.exitCode !== 0 && presentAfterRemove === false ? "image-absent-after-remove" : undefined,
|
||||
exitCode: remove.exitCode,
|
||||
stdoutTail: tailText(remove.stdout, 4000),
|
||||
stderrTail: tailText(remove.stderr, 4000),
|
||||
});
|
||||
}
|
||||
|
||||
const diskAfter = rootDiskSnapshot();
|
||||
const failedCount = results.filter((item) => item.status === "failed").length;
|
||||
const succeededCount = results.filter((item) => item.status === "succeeded").length;
|
||||
const skippedHighRiskCount = results.filter((item) => item.status === "skipped").length;
|
||||
const attempted = results.filter((item) => item.status !== "skipped");
|
||||
const actualDiskReclaimBytes = diskBefore !== null && diskAfter !== null ? diskAfter.availableBytes - diskBefore.availableBytes : null;
|
||||
return {
|
||||
ok: plan.ok && failedCount === 0,
|
||||
dryRun: false,
|
||||
mutation: true,
|
||||
action: "server cleanup run",
|
||||
scope: "docker-images-only",
|
||||
observedAt: new Date().toISOString(),
|
||||
options,
|
||||
diskBefore,
|
||||
diskAfter,
|
||||
summary: {
|
||||
plannedCandidateCount: selected.length,
|
||||
attemptedCount: attempted.length,
|
||||
succeededCount,
|
||||
failedCount,
|
||||
skippedHighRiskCount,
|
||||
estimatedReclaimBytes: attempted.reduce((sum, item) => sum + item.estimatedReclaimBytes, 0),
|
||||
actualDiskReclaimBytes,
|
||||
},
|
||||
results,
|
||||
policy: {
|
||||
deletionExecuted: true,
|
||||
dockerPruneUsed: false,
|
||||
dockerVolumesTouched: false,
|
||||
dataDirectoriesTouched: false,
|
||||
databaseCleanupIncluded: false,
|
||||
highRiskRequiresIncludeFlag: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectDockerCleanupInventory(config: UniDeskConfig): DockerCleanupInventory {
|
||||
const observedAt = new Date().toISOString();
|
||||
const desired = collectDesiredImagePolicy(config);
|
||||
@@ -829,6 +995,35 @@ function commandError(command: string[], message: string, exitCode: number | nul
|
||||
return { command, message, exitCode, stderrTail: stderr.slice(-1200) };
|
||||
}
|
||||
|
||||
function dockerImagePresent(imageId: string): boolean | null {
|
||||
const inspect = runCommand(["docker", "image", "inspect", imageId], repoRoot, { timeoutMs: 15_000 });
|
||||
if (inspect.exitCode === 0) return true;
|
||||
const text = `${inspect.stdout}\n${inspect.stderr}`;
|
||||
if (/no such image|no such object/i.test(text)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function rootDiskSnapshot(): DiskSnapshot | null {
|
||||
const result = runCommand(["df", "-B1", "-P", "/"], repoRoot, { timeoutMs: 5000 });
|
||||
if (result.exitCode !== 0) return null;
|
||||
const line = result.stdout.trim().split(/\r?\n/u)[1];
|
||||
if (!line) return null;
|
||||
const parts = line.trim().split(/\s+/u);
|
||||
if (parts.length < 6) return null;
|
||||
return {
|
||||
filesystem: parts[0] ?? "",
|
||||
sizeBytes: Number(parts[1]),
|
||||
usedBytes: Number(parts[2]),
|
||||
availableBytes: Number(parts[3]),
|
||||
usePercent: Number((parts[4] ?? "0").replace("%", "")),
|
||||
mount: parts[5] ?? "/",
|
||||
};
|
||||
}
|
||||
|
||||
function tailText(value: string, maxChars: number): string {
|
||||
return value.length <= maxChars ? value : value.slice(-maxChars);
|
||||
}
|
||||
|
||||
function shortContainerId(id: string): string {
|
||||
return id.slice(0, 12);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user