chore: add low-risk disk anti-bloat policy
This commit is contained in:
@@ -222,7 +222,9 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_LOG_DIR: logRoot,
|
||||
UNIDESK_LOG_DAY: logDay,
|
||||
UNIDESK_LOG_PREFIX: logPrefix,
|
||||
UNIDESK_LOG_RETENTION_BYTES: runtimeSecret("UNIDESK_LOG_RETENTION_BYTES") || "1GiB",
|
||||
UNIDESK_LOG_RETENTION_BYTES: runtimeSecretWithDefault("UNIDESK_LOG_RETENTION_BYTES", "512MiB", "1GiB"),
|
||||
UNIDESK_DOCKER_LOG_MAX_SIZE: runtimeSecret("UNIDESK_DOCKER_LOG_MAX_SIZE") || "20m",
|
||||
UNIDESK_DOCKER_LOG_MAX_FILE: runtimeSecret("UNIDESK_DOCKER_LOG_MAX_FILE") || "3",
|
||||
UNIDESK_HOST_ROOT_SSH_DIR: process.env.UNIDESK_HOST_ROOT_SSH_DIR || "/root/.ssh",
|
||||
UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir,
|
||||
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
|
||||
|
||||
+190
-3
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { closeSync, existsSync, ftruncateSync, lstatSync, openSync, readdirSync, readSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
|
||||
import { closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, 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";
|
||||
@@ -31,6 +31,7 @@ interface GcOptions {
|
||||
browserCache: boolean;
|
||||
dbSummary: boolean;
|
||||
limit: number;
|
||||
resultLimit: number;
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,11 @@ interface DbTraceGcOptions {
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
interface GcPolicyOptions {
|
||||
dryRun: boolean;
|
||||
enableNow: boolean;
|
||||
}
|
||||
|
||||
interface DiskSnapshot {
|
||||
filesystem: string;
|
||||
sizeBytes: number;
|
||||
@@ -114,6 +120,9 @@ interface GcRunResult {
|
||||
failedCount: number;
|
||||
estimatedReclaimBytes: number;
|
||||
actualDiskReclaimBytes: number | null;
|
||||
resultCount: number;
|
||||
returnedResultCount: number;
|
||||
omittedResultCount: number;
|
||||
};
|
||||
results: Array<GcCandidate & { status: "succeeded" | "failed"; reclaimedBytes: number | null; error?: string; commandOutput?: unknown }>;
|
||||
protected: ProtectedGcItem[];
|
||||
@@ -136,6 +145,7 @@ const DEFAULT_OPTIONS: GcOptions = {
|
||||
browserCache: false,
|
||||
dbSummary: true,
|
||||
limit: 50,
|
||||
resultLimit: 50,
|
||||
full: false,
|
||||
};
|
||||
|
||||
@@ -173,6 +183,18 @@ const TMP_EXACT_PROTECT = new Set([
|
||||
|
||||
export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "plan", ...rest] = args;
|
||||
if (action === "policy") {
|
||||
const [subaction = "plan", ...policyArgs] = rest;
|
||||
const options = parseGcPolicyOptions(policyArgs);
|
||||
if (subaction === "plan" || subaction === "render" || subaction === "dry-run") return gcPolicyPlan(options);
|
||||
if (subaction === "install") return gcPolicyInstall(options);
|
||||
return {
|
||||
ok: false,
|
||||
error: "unsupported-gc-policy-action",
|
||||
action: subaction,
|
||||
supportedActions: ["plan", "render", "dry-run", "install"],
|
||||
};
|
||||
}
|
||||
if (action === "db-trace" || action === "db-trace-retention") {
|
||||
const [subaction = "plan", ...dbArgs] = rest;
|
||||
const options = parseDbTraceGcOptions(dbArgs);
|
||||
@@ -221,7 +243,7 @@ export async function runGcCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
ok: false,
|
||||
error: "unsupported-gc-action",
|
||||
action,
|
||||
supportedActions: ["plan", "run"],
|
||||
supportedActions: ["plan", "run", "db-trace", "policy"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,6 +346,7 @@ export function gcRun(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTION
|
||||
const failedCount = results.filter((item) => item.status === "failed").length;
|
||||
const estimatedReclaimBytes = plan.summary.returnedEstimatedReclaimBytes;
|
||||
const actualDiskReclaimBytes = diskBefore !== null && diskAfter !== null ? diskAfter.availableBytes - diskBefore.availableBytes : null;
|
||||
const returnedResults = returnedRunResults(results, options);
|
||||
|
||||
return {
|
||||
ok: failedCount === 0,
|
||||
@@ -341,8 +364,11 @@ export function gcRun(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTION
|
||||
failedCount,
|
||||
estimatedReclaimBytes,
|
||||
actualDiskReclaimBytes,
|
||||
resultCount: results.length,
|
||||
returnedResultCount: returnedResults.length,
|
||||
omittedResultCount: Math.max(0, results.length - returnedResults.length),
|
||||
},
|
||||
results,
|
||||
results: returnedResults,
|
||||
protected: plan.protected,
|
||||
};
|
||||
}
|
||||
@@ -389,6 +415,10 @@ function parseGcOptions(args: string[]): GcOptions {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--limit must be a positive integer");
|
||||
options.limit = Math.min(value, 5000);
|
||||
} else if (arg === "--result-limit") {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--result-limit must be a positive integer");
|
||||
options.resultLimit = Math.min(value, 5000);
|
||||
} else if (arg === "--full" || arg === "--raw") {
|
||||
options.full = true;
|
||||
} else if (arg === "--confirm" || arg === "--dry-run") {
|
||||
@@ -437,6 +467,18 @@ function parseDbTraceGcOptions(args: string[]): DbTraceGcOptions {
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseGcPolicyOptions(args: string[]): GcPolicyOptions {
|
||||
const options: GcPolicyOptions = {
|
||||
dryRun: args.includes("--dry-run"),
|
||||
enableNow: !args.includes("--no-enable-now"),
|
||||
};
|
||||
for (const arg of args) {
|
||||
if (arg === "--dry-run" || arg === "--no-enable-now") continue;
|
||||
throw new Error(`unknown gc policy option: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseNonNegativeNumber(name: string, raw: string | undefined): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
|
||||
@@ -481,6 +523,7 @@ function publicOptions(options: GcOptions): Record<string, unknown> {
|
||||
browserCache: options.browserCache,
|
||||
dbSummary: options.dbSummary,
|
||||
limit: options.limit,
|
||||
resultLimit: options.resultLimit,
|
||||
full: options.full,
|
||||
};
|
||||
}
|
||||
@@ -799,6 +842,143 @@ function gcDbTraceRun(options: DbTraceGcOptions): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function gcPolicyPlan(options: GcPolicyOptions): unknown {
|
||||
const files = gcPolicyFiles();
|
||||
return {
|
||||
ok: true,
|
||||
action: "gc policy plan",
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
observedAt: new Date().toISOString(),
|
||||
options,
|
||||
files,
|
||||
commands: {
|
||||
install: [
|
||||
["mkdir", "-p", "/etc/systemd/journald.conf.d", "/etc/systemd/system"],
|
||||
["systemctl", "daemon-reload"],
|
||||
["systemctl", "enable", "--now", "unidesk-gc.timer"],
|
||||
],
|
||||
journalRestart: ["systemctl", "restart", "systemd-journald"],
|
||||
},
|
||||
policy: {
|
||||
safeScope: [
|
||||
"systemd journal is capped at 512MiB",
|
||||
"daily timer runs file-log and allowlisted /tmp low-risk gc only",
|
||||
"timer does not touch PostgreSQL PGDATA, Docker images, Docker volumes or Baidu Netdisk staging",
|
||||
"timer output is redirected under .state/gc and capped by gc --result-limit",
|
||||
],
|
||||
manualDbRetention: "gc db-trace remains explicit maintenance and is not scheduled automatically.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gcPolicyInstall(options: GcPolicyOptions): unknown {
|
||||
const plan = gcPolicyPlan(options);
|
||||
if (options.dryRun) return plan;
|
||||
const files = gcPolicyFiles();
|
||||
mkdirSync("/etc/systemd/journald.conf.d", { recursive: true });
|
||||
mkdirSync("/etc/systemd/system", { recursive: true });
|
||||
mkdirSync(rootPath(".state", "gc"), { recursive: true });
|
||||
writeFileSync(files.journald.path, files.journald.content, "utf8");
|
||||
writeFileSync(files.service.path, files.service.content, "utf8");
|
||||
writeFileSync(files.timer.path, files.timer.content, "utf8");
|
||||
const daemonReload = command(["systemctl", "daemon-reload"], 15000);
|
||||
const enableTimer = options.enableNow ? command(["systemctl", "enable", "--now", "unidesk-gc.timer"], 15000) : null;
|
||||
const restartJournald = command(["systemctl", "restart", "systemd-journald"], 15000);
|
||||
const timerStatus = command(["systemctl", "is-enabled", "unidesk-gc.timer"], 5000);
|
||||
const activeStatus = command(["systemctl", "is-active", "unidesk-gc.timer"], 5000);
|
||||
const results = {
|
||||
daemonReload: boundedCommandOutput(daemonReload),
|
||||
enableTimer: enableTimer === null ? { skipped: true } : boundedCommandOutput(enableTimer),
|
||||
restartJournald: boundedCommandOutput(restartJournald),
|
||||
timerStatus: {
|
||||
enabled: timerStatus.stdout.trim(),
|
||||
active: activeStatus.stdout.trim(),
|
||||
},
|
||||
};
|
||||
const failed = daemonReload.exitCode !== 0
|
||||
|| restartJournald.exitCode !== 0
|
||||
|| (enableTimer !== null && enableTimer.exitCode !== 0);
|
||||
return {
|
||||
ok: !failed,
|
||||
action: "gc policy install",
|
||||
dryRun: false,
|
||||
mutation: true,
|
||||
observedAt: new Date().toISOString(),
|
||||
options,
|
||||
files,
|
||||
results,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function gcPolicyFiles(): Record<string, { path: string; content: string }> {
|
||||
const gcStateDir = rootPath(".state", "gc");
|
||||
const bunPath = bunExecutablePath();
|
||||
const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} scripts/cli.ts gc run --confirm --no-db-summary --no-build-cache --no-docker-logs --no-journal --limit 5000 --result-limit 25 > ${shellQuote(join(gcStateDir, "last-run.json"))} 2> ${shellQuote(join(gcStateDir, "last-run.stderr"))}`;
|
||||
return {
|
||||
journald: {
|
||||
path: "/etc/systemd/journald.conf.d/unidesk-gc.conf",
|
||||
content: [
|
||||
"[Journal]",
|
||||
"SystemMaxUse=512M",
|
||||
"RuntimeMaxUse=128M",
|
||||
"MaxRetentionSec=7day",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
service: {
|
||||
path: "/etc/systemd/system/unidesk-gc.service",
|
||||
content: [
|
||||
"[Unit]",
|
||||
"Description=UniDesk low-risk disk garbage collection",
|
||||
"Documentation=file:///root/unidesk/docs/reference/cli.md",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
`WorkingDirectory=${repoRoot}`,
|
||||
`ExecStart=/bin/bash -lc "${systemdDoubleQuoted(gcScript)}"`,
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
timer: {
|
||||
path: "/etc/systemd/system/unidesk-gc.timer",
|
||||
content: [
|
||||
"[Unit]",
|
||||
"Description=Daily UniDesk low-risk disk garbage collection",
|
||||
"",
|
||||
"[Timer]",
|
||||
"OnCalendar=*-*-* 03:20:00",
|
||||
"RandomizedDelaySec=20m",
|
||||
"Persistent=true",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bunExecutablePath(): string {
|
||||
const candidates = ["/usr/bin/bun", "/root/.bun/bin/bun", process.argv[0] ?? ""];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.length > 0 && existsSync(candidate)) return resolve(candidate);
|
||||
}
|
||||
return "bun";
|
||||
}
|
||||
|
||||
function systemdDoubleQuoted(value: string): string {
|
||||
return value
|
||||
.replace(/\\/gu, "\\\\")
|
||||
.replace(/"/gu, "\\\"")
|
||||
.replace(/%/gu, "%%");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return `'${value.replace(/'/gu, "''")}'`;
|
||||
}
|
||||
@@ -929,6 +1109,13 @@ function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCa
|
||||
};
|
||||
}
|
||||
|
||||
function returnedRunResults(results: GcRunResult["results"], options: GcOptions): GcRunResult["results"] {
|
||||
if (options.full) return results;
|
||||
const failed = results.filter((item) => item.status === "failed");
|
||||
const succeeded = results.filter((item) => item.status === "succeeded");
|
||||
return [...failed, ...succeeded].slice(0, options.resultLimit);
|
||||
}
|
||||
|
||||
function collectFiles(root: string): Array<{ path: string; sizeBytes: number; mtimeMs: number }> {
|
||||
const result: Array<{ path: string; sizeBytes: number; mtimeMs: number }> = [];
|
||||
const visit = (dir: string): void => {
|
||||
|
||||
+6
-2
@@ -18,7 +18,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]", description: "Inspect or idempotently create host swap for low-memory main-server operation." },
|
||||
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
|
||||
{ command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." },
|
||||
{ command: "gc plan|run|db-trace --confirm [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server disk relief for logs, journald, Docker build cache, allowlisted /tmp artifacts and explicit trace telemetry retention; plan is read-only and run requires --confirm." },
|
||||
{ command: "gc plan|run|db-trace|policy [--confirm] [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server disk relief and low-risk anti-bloat policy for logs, journald, allowlisted /tmp artifacts and explicit trace telemetry retention; plan is read-only and run requires --confirm." },
|
||||
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
|
||||
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
|
||||
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `G14:k3s` or `D601:win/c/test` only locates distributed targets." },
|
||||
@@ -263,7 +263,7 @@ function providerHelp(): unknown {
|
||||
|
||||
function gcHelp(): unknown {
|
||||
return {
|
||||
command: "gc plan|run|db-trace",
|
||||
command: "gc plan|run|db-trace|policy",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts gc plan",
|
||||
@@ -273,6 +273,8 @@ function gcHelp(): unknown {
|
||||
"bun scripts/cli.ts gc run --confirm --include-browser-cache",
|
||||
"bun scripts/cli.ts gc db-trace plan --before-date 2026-05-25",
|
||||
"bun scripts/cli.ts gc db-trace run --confirm --before-date 2026-05-25 --vacuum-full",
|
||||
"bun scripts/cli.ts gc policy plan",
|
||||
"bun scripts/cli.ts gc policy install",
|
||||
"bun scripts/cli.ts gc plan --full",
|
||||
],
|
||||
description: "Plan or execute bounded one-time main-server disk relief for file logs, Docker json logs, systemd journal, Docker BuildKit cache, allowlisted /tmp artifacts and explicitly scoped database trace telemetry retention.",
|
||||
@@ -292,10 +294,12 @@ function gcHelp(): unknown {
|
||||
"--build-cache-all": "prune all Docker builder cache without an until filter",
|
||||
"--tmp-min-age-hours N": "delete allowlisted /tmp artifacts older than N hours; default 24",
|
||||
"--limit N": "number of candidates returned and executed by run when --full is not set; default 50",
|
||||
"--result-limit N": "number of per-candidate run results returned when --full is not set; default 50",
|
||||
"--full|--raw": "return and run against all candidates rather than the default bounded page",
|
||||
"--include-browser-cache": "also remove repo-local .state/playwright-browsers cache",
|
||||
"db-trace --before-date YYYY-MM-DD": "plan or delete default trace telemetry event types before the date",
|
||||
"db-trace run --vacuum-full": "rewrite public.oa_events after deletion so df can reclaim disk; requires maintenance window",
|
||||
"policy plan|install": "render or install journald caps and a daily file-log plus allowlisted /tmp low-risk gc systemd timer",
|
||||
"--no-file-logs|--no-docker-logs|--no-journal|--no-build-cache|--no-tmp|--no-db-summary": "disable one collector",
|
||||
},
|
||||
reference: "docs/reference/cli.md",
|
||||
|
||||
Reference in New Issue
Block a user