feat: extend disk gc candidates
This commit is contained in:
@@ -77,6 +77,18 @@ bun scripts/cli.ts gc remote <providerId> [--target-use-percent N] [--dry-run|--
|
||||
|
||||
主 server 和 provider 磁盘高水位缓解。`plan` 只读输出候选、风险、估算收益和保护对象。`run` 必须 `--confirm`。`remote` 通过 SSH 透传执行远端 GC。
|
||||
|
||||
常用显式候选和目标口径:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts gc plan --target-use-percent 69 \
|
||||
--include-tool-caches \
|
||||
--include-vscode-stale-servers \
|
||||
--include-vscode-stale-extensions \
|
||||
--include-baidu-staging
|
||||
```
|
||||
|
||||
`--target-use-percent` 按 `df` 显示口径估算 shortfall。工具缓存、VS Code 历史 server/extension 版本、Baidu staging 旧 PGDATA tarball 均默认不启用;必须显式 include 后才进入候选,且执行时仍受 allowlist 路径断言保护。默认 GC 不触碰 PGDATA、Docker volumes/images、Codex sessions/auth state 或 Baidu staging 根目录。
|
||||
|
||||
---
|
||||
|
||||
## 服务重建
|
||||
|
||||
+424
-12
@@ -13,7 +13,11 @@ type GcItemKind =
|
||||
| "journal-vacuum"
|
||||
| "docker-build-cache-prune"
|
||||
| "tmp-path-delete"
|
||||
| "browser-cache-delete";
|
||||
| "browser-cache-delete"
|
||||
| "tool-cache-delete"
|
||||
| "vscode-server-delete"
|
||||
| "vscode-extension-delete"
|
||||
| "baidu-staging-file-delete";
|
||||
|
||||
interface GcOptions {
|
||||
fileLogs: boolean;
|
||||
@@ -30,10 +34,18 @@ interface GcOptions {
|
||||
tmp: boolean;
|
||||
tmpMinAgeHours: number;
|
||||
browserCache: boolean;
|
||||
toolCaches: boolean;
|
||||
vscodeStaleServers: boolean;
|
||||
vscodeKeepServers: number;
|
||||
vscodeStaleExtensions: boolean;
|
||||
vscodeKeepExtensionVersions: number;
|
||||
baiduStaging: boolean;
|
||||
baiduStagingKeepDays: number;
|
||||
dbSummary: boolean;
|
||||
limit: number;
|
||||
resultLimit: number;
|
||||
full: boolean;
|
||||
targetUsePercent: number | null;
|
||||
}
|
||||
|
||||
interface DbTraceGcOptions {
|
||||
@@ -93,6 +105,7 @@ interface GcPlan {
|
||||
returnedEstimatedReclaimBytes: number;
|
||||
returnedEstimatedReclaim: string;
|
||||
byKind: Record<string, { count: number; estimatedReclaimBytes: number; estimatedReclaim: string }>;
|
||||
target: GcTargetSummary | null;
|
||||
};
|
||||
candidates: GcCandidate[];
|
||||
protected: ProtectedGcItem[];
|
||||
@@ -129,6 +142,19 @@ interface GcRunResult {
|
||||
protected: ProtectedGcItem[];
|
||||
}
|
||||
|
||||
interface GcTargetSummary {
|
||||
targetUsePercent: number;
|
||||
currentUsePercent: number;
|
||||
basis: string;
|
||||
requiredReclaimBytes: number;
|
||||
requiredReclaim: string;
|
||||
estimatedAfterUsedBytes: number;
|
||||
estimatedAfterUsePercent: number;
|
||||
shortfallBytes: number;
|
||||
shortfall: string;
|
||||
safeStop: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: GcOptions = {
|
||||
fileLogs: true,
|
||||
fileLogKeepDays: 7,
|
||||
@@ -144,16 +170,25 @@ const DEFAULT_OPTIONS: GcOptions = {
|
||||
tmp: true,
|
||||
tmpMinAgeHours: 24,
|
||||
browserCache: false,
|
||||
toolCaches: false,
|
||||
vscodeStaleServers: false,
|
||||
vscodeKeepServers: 2,
|
||||
vscodeStaleExtensions: false,
|
||||
vscodeKeepExtensionVersions: 1,
|
||||
baiduStaging: false,
|
||||
baiduStagingKeepDays: 10,
|
||||
dbSummary: true,
|
||||
limit: 50,
|
||||
resultLimit: 50,
|
||||
full: false,
|
||||
targetUsePercent: null,
|
||||
};
|
||||
|
||||
const DEFAULT_DB_TRACE_TYPES = ["trace-stats-updated", "trace-step-created", "trace-stats-snapshot"];
|
||||
|
||||
const TMP_PREFIX_ALLOWLIST = [
|
||||
"hwlab-agent-",
|
||||
"hwlab-",
|
||||
"hwlab-cd-",
|
||||
"hwlab-cli-cicd-",
|
||||
"hwlab-codeagent-trace",
|
||||
@@ -162,16 +197,24 @@ const TMP_PREFIX_ALLOWLIST = [
|
||||
"hwlab-merge-",
|
||||
"hwlab-pr",
|
||||
"hwlab-refresh-",
|
||||
"hwpod-",
|
||||
"playwright-artifacts-",
|
||||
"playwright_chromiumdev_profile-",
|
||||
"sub2api",
|
||||
"trans-d601-pool-",
|
||||
"unidesk-",
|
||||
"unidesk-clean-",
|
||||
"unidesk-code-queue",
|
||||
"unidesk-hwlab-cd-",
|
||||
"unidesk-pr",
|
||||
"unidesk-tran-runner",
|
||||
"webterm-",
|
||||
"bunx-",
|
||||
"claude-",
|
||||
"codex-app-schema",
|
||||
"codex-app-ts",
|
||||
"codex-probe-",
|
||||
"mxcx-",
|
||||
"marked-",
|
||||
"node-compile-cache",
|
||||
];
|
||||
@@ -182,6 +225,63 @@ const TMP_EXACT_PROTECT = new Set([
|
||||
"/tmp/tmux-0",
|
||||
]);
|
||||
|
||||
const TOOL_CACHE_ALLOWLIST = [
|
||||
{
|
||||
id: "npm-cacache",
|
||||
path: "/root/.npm/_cacache",
|
||||
description: "Delete npm content-addressable package cache; npm can rebuild it.",
|
||||
},
|
||||
{
|
||||
id: "npm-npx",
|
||||
path: "/root/.npm/_npx",
|
||||
description: "Delete npx execution cache; npx can rebuild it.",
|
||||
},
|
||||
{
|
||||
id: "playwright-ms-cache",
|
||||
path: "/root/.cache/ms-playwright",
|
||||
description: "Delete user-level Playwright browser cache; browsers can be reinstalled by Playwright.",
|
||||
},
|
||||
{
|
||||
id: "go-build-cache",
|
||||
path: "/root/.cache/go-build",
|
||||
description: "Delete Go build cache; Go can rebuild it.",
|
||||
},
|
||||
{
|
||||
id: "go-module-cache",
|
||||
path: "/root/go/pkg/mod",
|
||||
description: "Delete Go module download cache; Go can re-download modules from project lock/module files.",
|
||||
},
|
||||
{
|
||||
id: "bun-install-cache",
|
||||
path: "/root/.bun/install/cache",
|
||||
description: "Delete Bun package install cache; Bun can re-download packages on demand.",
|
||||
},
|
||||
{
|
||||
id: "electron-cache",
|
||||
path: "/root/.cache/electron",
|
||||
description: "Delete Electron download cache; Electron tooling can re-download it.",
|
||||
},
|
||||
{
|
||||
id: "node-gyp-cache",
|
||||
path: "/root/.cache/node-gyp",
|
||||
description: "Delete node-gyp header cache; node-gyp can re-download it.",
|
||||
},
|
||||
{
|
||||
id: "typescript-cache",
|
||||
path: "/root/.cache/typescript",
|
||||
description: "Delete TypeScript cache; TypeScript can rebuild it.",
|
||||
},
|
||||
{
|
||||
id: "opencode-cache",
|
||||
path: "/root/.cache/opencode",
|
||||
description: "Delete OpenCode cache; OpenCode can rebuild it.",
|
||||
},
|
||||
];
|
||||
|
||||
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"];
|
||||
|
||||
export async function runGcCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "plan", ...rest] = args;
|
||||
if (action === "remote") {
|
||||
@@ -289,12 +389,49 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.toolCaches) {
|
||||
candidates.push(...collectToolCacheCandidates());
|
||||
} else {
|
||||
protectedItems.push(...collectProtectedToolCaches());
|
||||
}
|
||||
if (options.vscodeStaleServers) {
|
||||
candidates.push(...collectVscodeServerCandidates(options));
|
||||
} else {
|
||||
const vscodeSize = safePathSize(VSCODE_SERVER_ROOT);
|
||||
if (vscodeSize > 0) {
|
||||
protectedItems.push({
|
||||
kind: "vscode-server-cache",
|
||||
risk: "blocked",
|
||||
ref: VSCODE_SERVER_ROOT,
|
||||
sizeBytes: vscodeSize,
|
||||
reason: "VS Code server versions are not removed by default; rerun with --include-vscode-stale-servers to keep only recent server versions.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.vscodeStaleExtensions) {
|
||||
candidates.push(...collectVscodeExtensionCandidates(options));
|
||||
} else {
|
||||
const extensionSize = safePathSize(VSCODE_EXTENSION_ROOT);
|
||||
if (extensionSize > 0) {
|
||||
protectedItems.push({
|
||||
kind: "vscode-extension-cache",
|
||||
risk: "blocked",
|
||||
ref: VSCODE_EXTENSION_ROOT,
|
||||
sizeBytes: extensionSize,
|
||||
reason: "VS Code extension versions are not removed by default; rerun with --include-vscode-stale-extensions to keep only recent versions per extension.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.baiduStaging) {
|
||||
candidates.push(...collectBaiduStagingCandidates(options, observedAt));
|
||||
}
|
||||
|
||||
protectedItems.push(...collectProtectedStorage(config));
|
||||
protectedItems.push(...collectProtectedStorage(config, options));
|
||||
const databaseSummary = options.dbSummary ? collectDatabaseSummary() : { skipped: true, reason: "disabled-by-option" };
|
||||
const allCandidates = candidates.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
const visibleCandidates = options.full ? allCandidates : allCandidates.slice(0, options.limit);
|
||||
const summary = summarizeCandidates(allCandidates, visibleCandidates);
|
||||
const diskBefore = rootDiskSnapshot();
|
||||
const summary = summarizeCandidates(allCandidates, visibleCandidates, diskBefore, options);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -303,7 +440,7 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
|
||||
mutation: false,
|
||||
observedAt,
|
||||
options: publicOptions(options),
|
||||
diskBefore: rootDiskSnapshot(),
|
||||
diskBefore,
|
||||
summary,
|
||||
candidates: visibleCandidates,
|
||||
protected: protectedItems,
|
||||
@@ -314,13 +451,16 @@ export function gcPlan(config: UniDeskConfig, options: GcOptions = DEFAULT_OPTIO
|
||||
neverTouches: [
|
||||
"Docker volumes",
|
||||
"PostgreSQL PGDATA",
|
||||
"Baidu Netdisk staging/backups",
|
||||
"Baidu Netdisk staging root by default",
|
||||
"D601 registry storage",
|
||||
"Docker images used by containers",
|
||||
"Codex sessions and auth state",
|
||||
],
|
||||
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.",
|
||||
"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.",
|
||||
],
|
||||
@@ -404,6 +544,34 @@ function parseGcOptions(args: string[]): GcOptions {
|
||||
options.browserCache = true;
|
||||
} else if (arg === "--no-browser-cache") {
|
||||
options.browserCache = false;
|
||||
} else if (arg === "--include-tool-caches") {
|
||||
options.toolCaches = true;
|
||||
} else if (arg === "--no-tool-caches") {
|
||||
options.toolCaches = false;
|
||||
} else if (arg === "--include-vscode-stale-servers") {
|
||||
options.vscodeStaleServers = true;
|
||||
} else if (arg === "--no-vscode-stale-servers") {
|
||||
options.vscodeStaleServers = false;
|
||||
} else if (arg === "--vscode-keep-servers") {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--vscode-keep-servers must be a positive integer");
|
||||
options.vscodeKeepServers = Math.min(value, 20);
|
||||
} else if (arg === "--include-vscode-stale-extensions") {
|
||||
options.vscodeStaleExtensions = true;
|
||||
} else if (arg === "--no-vscode-stale-extensions") {
|
||||
options.vscodeStaleExtensions = false;
|
||||
} else if (arg === "--vscode-keep-extension-versions") {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--vscode-keep-extension-versions must be a positive integer");
|
||||
options.vscodeKeepExtensionVersions = Math.min(value, 20);
|
||||
} else if (arg === "--target-use-percent") {
|
||||
options.targetUsePercent = parseUsePercentOption(arg, args[++index]);
|
||||
} else if (arg === "--include-baidu-staging") {
|
||||
options.baiduStaging = true;
|
||||
} else if (arg === "--no-baidu-staging") {
|
||||
options.baiduStaging = false;
|
||||
} else if (arg === "--baidu-staging-keep-days") {
|
||||
options.baiduStagingKeepDays = parsePositiveIntegerOption(arg, args[++index], 3650);
|
||||
} else if (arg === "--no-file-logs" || arg === "--no-logs") {
|
||||
options.fileLogs = false;
|
||||
} else if (arg === "--no-docker-logs") {
|
||||
@@ -490,6 +658,18 @@ function parseNonNegativeNumber(name: string, raw: string | undefined): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseUsePercentOption(name: string, raw: string | undefined): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0 || value >= 100) throw new Error(`${name} must be greater than 0 and smaller than 100`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(name: string, raw: string | undefined, max: number): number {
|
||||
const value = parseNonNegativeNumber(name, raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return Math.min(value, max);
|
||||
}
|
||||
|
||||
function parseSizeOption(name: string, raw: string | undefined): number {
|
||||
const value = parseSize(raw ?? "");
|
||||
if (value === null || value <= 0) throw new Error(`${name} must be a positive size such as 512M, 1GiB or 50000000`);
|
||||
@@ -526,10 +706,18 @@ function publicOptions(options: GcOptions): Record<string, unknown> {
|
||||
tmp: options.tmp,
|
||||
tmpMinAgeHours: options.tmpMinAgeHours,
|
||||
browserCache: options.browserCache,
|
||||
toolCaches: options.toolCaches,
|
||||
vscodeStaleServers: options.vscodeStaleServers,
|
||||
vscodeKeepServers: options.vscodeKeepServers,
|
||||
vscodeStaleExtensions: options.vscodeStaleExtensions,
|
||||
vscodeKeepExtensionVersions: options.vscodeKeepExtensionVersions,
|
||||
baiduStaging: options.baiduStaging,
|
||||
baiduStagingKeepDays: options.baiduStagingKeepDays,
|
||||
dbSummary: options.dbSummary,
|
||||
limit: options.limit,
|
||||
resultLimit: options.resultLimit,
|
||||
full: options.full,
|
||||
targetUsePercent: options.targetUsePercent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -679,7 +867,149 @@ function collectBrowserCacheCandidate(): GcCandidate | null {
|
||||
};
|
||||
}
|
||||
|
||||
function collectProtectedStorage(config: UniDeskConfig): ProtectedGcItem[] {
|
||||
function collectToolCacheCandidates(): GcCandidate[] {
|
||||
const result: GcCandidate[] = [];
|
||||
for (const item of TOOL_CACHE_ALLOWLIST) {
|
||||
if (!existsSync(item.path)) continue;
|
||||
const sizeBytes = safePathSize(item.path);
|
||||
if (sizeBytes <= 0) continue;
|
||||
result.push({
|
||||
id: `tool-cache:${item.id}`,
|
||||
kind: "tool-cache-delete",
|
||||
risk: "medium",
|
||||
description: item.description,
|
||||
path: item.path,
|
||||
sizeBytes,
|
||||
estimatedReclaimBytes: sizeBytes,
|
||||
action: { op: "rm-recursive", allowlist: "tool-cache" },
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
}
|
||||
|
||||
function collectProtectedToolCaches(): ProtectedGcItem[] {
|
||||
const result: ProtectedGcItem[] = [];
|
||||
for (const item of TOOL_CACHE_ALLOWLIST) {
|
||||
if (!existsSync(item.path)) continue;
|
||||
const sizeBytes = safePathSize(item.path);
|
||||
if (sizeBytes <= 0) continue;
|
||||
result.push({
|
||||
kind: "tool-cache",
|
||||
risk: "blocked",
|
||||
ref: item.path,
|
||||
sizeBytes,
|
||||
reason: "Rebuildable tool cache is not removed by default; rerun with --include-tool-caches for explicit one-time cleanup.",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectVscodeServerCandidates(options: GcOptions): GcCandidate[] {
|
||||
if (!existsSync(VSCODE_SERVER_ROOT)) return [];
|
||||
const entries = readdirSync(VSCODE_SERVER_ROOT, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("Stable-"))
|
||||
.flatMap((entry) => {
|
||||
const path = join(VSCODE_SERVER_ROOT, entry.name);
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
return [{ path, name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes: safePathSize(path) }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.sizeBytes > 0)
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
const stale = entries.slice(options.vscodeKeepServers);
|
||||
return stale.map((entry) => ({
|
||||
id: `vscode-server:${entry.name}`,
|
||||
kind: "vscode-server-delete",
|
||||
risk: "medium",
|
||||
description: `Delete stale VS Code server version while keeping ${options.vscodeKeepServers} newest versions`,
|
||||
path: entry.path,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
estimatedReclaimBytes: entry.sizeBytes,
|
||||
action: { op: "rm-recursive", keepLatest: options.vscodeKeepServers, allowlist: "vscode-server-stable" },
|
||||
}));
|
||||
}
|
||||
|
||||
function collectVscodeExtensionCandidates(options: GcOptions): GcCandidate[] {
|
||||
if (!existsSync(VSCODE_EXTENSION_ROOT)) return [];
|
||||
const groups = new Map<string, Array<{ path: string; name: string; mtimeMs: number; sizeBytes: number }>>();
|
||||
for (const entry of readdirSync(VSCODE_EXTENSION_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const extensionId = vscodeExtensionIdFromDirectory(entry.name);
|
||||
if (extensionId === null) continue;
|
||||
const path = join(VSCODE_EXTENSION_ROOT, entry.name);
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
const sizeBytes = safePathSize(path);
|
||||
if (sizeBytes <= 0) continue;
|
||||
const current = groups.get(extensionId) ?? [];
|
||||
current.push({ path, name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes });
|
||||
groups.set(extensionId, current);
|
||||
} catch {
|
||||
// Ignore paths that disappear while gc is planning.
|
||||
}
|
||||
}
|
||||
const result: GcCandidate[] = [];
|
||||
for (const [extensionId, entries] of groups.entries()) {
|
||||
const stale = entries.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(options.vscodeKeepExtensionVersions);
|
||||
for (const entry of stale) {
|
||||
result.push({
|
||||
id: `vscode-extension:${entry.name}`,
|
||||
kind: "vscode-extension-delete",
|
||||
risk: "medium",
|
||||
description: `Delete stale VS Code extension version for ${extensionId} while keeping ${options.vscodeKeepExtensionVersions} newest version(s)`,
|
||||
path: entry.path,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
estimatedReclaimBytes: entry.sizeBytes,
|
||||
action: { op: "rm-recursive", keepLatest: options.vscodeKeepExtensionVersions, allowlist: "vscode-extension-version" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
}
|
||||
|
||||
function vscodeExtensionIdFromDirectory(name: string): string | null {
|
||||
const match = name.match(/^(.+)-(\d+\.\d[\w.+-]*)$/u);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function collectBaiduStagingCandidates(options: GcOptions, observedAt: string): GcCandidate[] {
|
||||
const root = rootPath(...BAIDU_STAGING_RELATIVE_ROOT);
|
||||
const pgdataRoot = join(root, "server-data", "unidesk-pg-data");
|
||||
if (!existsSync(pgdataRoot)) return [];
|
||||
const cutoffMs = new Date(observedAt).getTime() - options.baiduStagingKeepDays * 24 * 60 * 60 * 1000;
|
||||
const result: GcCandidate[] = [];
|
||||
for (const month of readdirSync(pgdataRoot, { withFileTypes: true })) {
|
||||
if (!month.isDirectory() || !/^\d{6}$/u.test(month.name)) continue;
|
||||
const monthPath = join(pgdataRoot, month.name);
|
||||
for (const entry of readdirSync(monthPath, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !/\.pg_basebackup\.tar\.gz$/u.test(entry.name)) continue;
|
||||
const path = join(monthPath, entry.name);
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoffMs || stat.size <= 0) continue;
|
||||
result.push({
|
||||
id: `baidu-staging:${month.name}/${entry.name}`,
|
||||
kind: "baidu-staging-file-delete",
|
||||
risk: "medium",
|
||||
description: `Delete local Baidu Netdisk PGDATA staging backup older than ${options.baiduStagingKeepDays} days`,
|
||||
path,
|
||||
sizeBytes: stat.size,
|
||||
estimatedReclaimBytes: stat.size,
|
||||
action: { op: "unlink", allowlist: "baidu-pgdata-staging", keepDays: options.baiduStagingKeepDays },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes);
|
||||
}
|
||||
|
||||
function collectProtectedStorage(config: UniDeskConfig, options: GcOptions): ProtectedGcItem[] {
|
||||
const result: ProtectedGcItem[] = [
|
||||
{
|
||||
kind: "docker-volume",
|
||||
@@ -697,11 +1027,13 @@ function collectProtectedStorage(config: UniDeskConfig): ProtectedGcItem[] {
|
||||
const baiduStaging = rootPath(".state", "baidu-netdisk", "staging");
|
||||
if (existsSync(baiduStaging)) {
|
||||
result.push({
|
||||
kind: "baidu-netdisk-staging",
|
||||
kind: options.baiduStaging ? "baidu-netdisk-staging-root" : "baidu-netdisk-staging",
|
||||
risk: "blocked",
|
||||
ref: baiduStaging,
|
||||
sizeBytes: safePathSize(baiduStaging),
|
||||
reason: "Baidu Netdisk staging may contain backups or transfer state and is not touched by gc.",
|
||||
reason: options.baiduStaging
|
||||
? "Baidu Netdisk staging root is protected; only selected old PGDATA backup tarballs are candidate files."
|
||||
: "Baidu Netdisk staging may contain backups or transfer state and is not touched by gc unless --include-baidu-staging is set.",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -868,8 +1200,8 @@ function gcPolicyPlan(options: GcPolicyOptions): unknown {
|
||||
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",
|
||||
"daily timer runs file-log, Docker json logs, 24h BuildKit cache and allowlisted /tmp gc",
|
||||
"timer does not touch PostgreSQL PGDATA, Docker images, Docker volumes, tool caches, VS Code servers/extensions 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.",
|
||||
@@ -920,7 +1252,7 @@ function gcPolicyInstall(options: GcPolicyOptions): unknown {
|
||||
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"))}`;
|
||||
const gcScript = `cd ${shellQuote(repoRoot)} && mkdir -p ${shellQuote(gcStateDir)} && ${shellQuote(bunPath)} scripts/cli.ts gc run --confirm --no-db-summary --no-journal --build-cache-until 24h --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",
|
||||
@@ -1036,6 +1368,30 @@ function executeCandidate(candidate: GcCandidate, options: GcOptions): { reclaim
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "tool-cache-delete" && candidate.path !== undefined) {
|
||||
assertToolCacheCandidatePath(candidate.path);
|
||||
const before = safePathSize(candidate.path);
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "vscode-server-delete" && candidate.path !== undefined) {
|
||||
assertVscodeServerCandidatePath(candidate.path);
|
||||
const before = safePathSize(candidate.path);
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "vscode-extension-delete" && candidate.path !== undefined) {
|
||||
assertVscodeExtensionCandidatePath(candidate.path);
|
||||
const before = safePathSize(candidate.path);
|
||||
rmSync(candidate.path, { recursive: true, force: true });
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "baidu-staging-file-delete" && candidate.path !== undefined) {
|
||||
assertBaiduStagingCandidatePath(candidate.path);
|
||||
const before = safeFileSize(candidate.path);
|
||||
unlinkSync(candidate.path);
|
||||
return { reclaimedBytes: before };
|
||||
}
|
||||
if (candidate.kind === "journal-vacuum") {
|
||||
const result = command(["journalctl", `--vacuum-size=${options.journalTargetBytes}`], 30000);
|
||||
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "journalctl vacuum failed");
|
||||
@@ -1091,7 +1447,40 @@ function assertTmpCandidatePath(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[]): GcPlan["summary"] {
|
||||
function assertToolCacheCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
const allowed = TOOL_CACHE_ALLOWLIST.some((item) => resolve(item.path) === resolved);
|
||||
if (!allowed) throw new Error(`refusing to remove tool cache path outside allowlist: ${path}`);
|
||||
}
|
||||
|
||||
function assertVscodeServerCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
const root = resolve(VSCODE_SERVER_ROOT);
|
||||
if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VS Code server outside server root: ${path}`);
|
||||
const name = basename(resolved);
|
||||
if (!/^Stable-[a-f0-9]{40}$/u.test(name)) throw new Error(`refusing to remove unexpected VS Code server name: ${path}`);
|
||||
}
|
||||
|
||||
function assertVscodeExtensionCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
const root = resolve(VSCODE_EXTENSION_ROOT);
|
||||
if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove VS Code extension outside extension root: ${path}`);
|
||||
const relativePath = resolved.slice(root.length + 1);
|
||||
if (relativePath.includes("/")) throw new Error(`refusing to remove nested VS Code extension path: ${path}`);
|
||||
if (vscodeExtensionIdFromDirectory(basename(resolved)) === null) throw new Error(`refusing to remove unexpected VS Code extension directory: ${path}`);
|
||||
}
|
||||
|
||||
function assertBaiduStagingCandidatePath(path: string): void {
|
||||
const resolved = resolve(path);
|
||||
const root = resolve(rootPath(...BAIDU_STAGING_RELATIVE_ROOT, "server-data", "unidesk-pg-data"));
|
||||
if (!resolved.startsWith(`${root}/`)) throw new Error(`refusing to remove Baidu staging file outside PGDATA staging root: ${path}`);
|
||||
const relativePath = resolved.slice(root.length + 1);
|
||||
if (!/^\d{6}\/[^/]+\.pg_basebackup\.tar\.gz$/u.test(relativePath)) {
|
||||
throw new Error(`refusing to remove unexpected Baidu staging file: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCandidate[], diskBefore: DiskSnapshot | null, options: GcOptions): GcPlan["summary"] {
|
||||
const byKind: GcPlan["summary"]["byKind"] = {};
|
||||
let estimatedReclaimBytes = 0;
|
||||
for (const candidate of candidates) {
|
||||
@@ -1111,6 +1500,29 @@ function summarizeCandidates(candidates: GcCandidate[], returnedCandidates: GcCa
|
||||
returnedEstimatedReclaimBytes,
|
||||
returnedEstimatedReclaim: formatBytes(returnedEstimatedReclaimBytes),
|
||||
byKind,
|
||||
target: targetSummary(diskBefore, options, estimatedReclaimBytes),
|
||||
};
|
||||
}
|
||||
|
||||
function targetSummary(diskBefore: DiskSnapshot | null, options: GcOptions, estimatedReclaimBytes: number): GcTargetSummary | null {
|
||||
if (diskBefore === null || options.targetUsePercent === null) return null;
|
||||
const dfEffectiveBytes = Math.max(1, diskBefore.usedBytes + diskBefore.availableBytes);
|
||||
const targetUsedBytes = Math.floor(dfEffectiveBytes * (options.targetUsePercent / 100));
|
||||
const requiredReclaimBytes = Math.max(0, diskBefore.usedBytes - targetUsedBytes);
|
||||
const estimatedAfterUsedBytes = Math.max(0, diskBefore.usedBytes - estimatedReclaimBytes);
|
||||
const estimatedAfterUsePercent = Math.ceil((estimatedAfterUsedBytes / dfEffectiveBytes) * 100);
|
||||
const shortfallBytes = Math.max(0, requiredReclaimBytes - estimatedReclaimBytes);
|
||||
return {
|
||||
targetUsePercent: options.targetUsePercent,
|
||||
currentUsePercent: diskBefore.usePercent,
|
||||
basis: "df-used-over-used-plus-available",
|
||||
requiredReclaimBytes,
|
||||
requiredReclaim: formatBytes(requiredReclaimBytes),
|
||||
estimatedAfterUsedBytes,
|
||||
estimatedAfterUsePercent,
|
||||
shortfallBytes,
|
||||
shortfall: formatBytes(shortfallBytes),
|
||||
safeStop: shortfallBytes > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user