feat: report remote gc target shortfall
This commit is contained in:
+95
-15
@@ -20,6 +20,7 @@ interface RemoteGcOptions {
|
||||
registryGcOnly: boolean;
|
||||
registryKeepPerRepo: number;
|
||||
registryMinAgeHours: number;
|
||||
targetUsePercent?: number;
|
||||
jobId?: string;
|
||||
limit: number;
|
||||
resultLimit: number;
|
||||
@@ -109,6 +110,10 @@ function parseRemoteGcOptions(args: string[]): RemoteGcOptions {
|
||||
} else if (arg === "--registry-min-age-hours") {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
options.registryMinAgeHours = value;
|
||||
} else if (arg === "--target-use-percent") {
|
||||
const value = parseNonNegativeNumber(arg, args[++index]);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 99) throw new Error("--target-use-percent must be an integer from 1 to 99");
|
||||
options.targetUsePercent = value;
|
||||
} else if (arg === "--job-id") {
|
||||
const value = args[++index];
|
||||
if (!value || !/^[A-Za-z0-9._-]{1,128}$/u.test(value)) throw new Error("--job-id must be a safe job id");
|
||||
@@ -529,6 +534,16 @@ def fmt_bytes(value):
|
||||
idx += 1
|
||||
return ("%0.0f %s" if size >= 10 or idx == 0 else "%0.1f %s") % (size, units[idx])
|
||||
|
||||
def disk_use_percent(size_bytes, used_bytes):
|
||||
try:
|
||||
size = int(size_bytes or 0)
|
||||
used = int(used_bytes or 0)
|
||||
except Exception:
|
||||
return None
|
||||
if size <= 0:
|
||||
return None
|
||||
return int((max(0, used) * 100 + size - 1) // size)
|
||||
|
||||
def parse_journal_usage(text):
|
||||
m = re.search(r"take up\s+([0-9.]+)\s*([KMGT]?)(?:i?B|B)?", text, re.I)
|
||||
if not m:
|
||||
@@ -1485,7 +1500,66 @@ def collect_candidates(observed_at):
|
||||
})
|
||||
return sorted(candidates, key=lambda item: item.get("estimatedReclaimBytes") or 0, reverse=True)
|
||||
|
||||
def summarize(candidates, returned):
|
||||
def target_assessment(disk, estimated_reclaim):
|
||||
raw = OPTIONS.get("targetUsePercent")
|
||||
if raw is None:
|
||||
return None
|
||||
if not disk:
|
||||
return {
|
||||
"targetUsePercent": raw,
|
||||
"ok": False,
|
||||
"state": "unavailable",
|
||||
"reason": "disk-snapshot-unavailable",
|
||||
}
|
||||
try:
|
||||
target = int(raw)
|
||||
size = int(disk.get("sizeBytes") or 0)
|
||||
used = int(disk.get("usedBytes") or 0)
|
||||
reclaim = max(0, int(estimated_reclaim or 0))
|
||||
except Exception:
|
||||
return {
|
||||
"targetUsePercent": raw,
|
||||
"ok": False,
|
||||
"state": "unavailable",
|
||||
"reason": "invalid-disk-snapshot",
|
||||
}
|
||||
target_used_bytes = (size * target) // 100
|
||||
required = max(0, used - target_used_bytes)
|
||||
projected_used = max(0, used - reclaim)
|
||||
projected_use_percent = disk_use_percent(size, projected_used)
|
||||
enough = reclaim >= required
|
||||
if required == 0:
|
||||
state = "already-below-target"
|
||||
elif enough:
|
||||
state = "candidate-estimate-meets-target"
|
||||
elif reclaim <= 0:
|
||||
state = "safe-stop-no-meaningful-candidates"
|
||||
else:
|
||||
state = "shortfall"
|
||||
shortfall = max(0, required - reclaim)
|
||||
return {
|
||||
"targetUsePercent": target,
|
||||
"ok": required == 0 or enough,
|
||||
"state": state,
|
||||
"currentUsePercent": disk.get("usePercent"),
|
||||
"currentUsedBytes": used,
|
||||
"currentUsed": fmt_bytes(used),
|
||||
"targetUsedBytes": target_used_bytes,
|
||||
"targetUsed": fmt_bytes(target_used_bytes),
|
||||
"requiredReclaimBytes": required,
|
||||
"requiredReclaim": fmt_bytes(required),
|
||||
"estimatedReclaimBytes": reclaim,
|
||||
"estimatedReclaim": fmt_bytes(reclaim),
|
||||
"shortfallBytes": shortfall,
|
||||
"shortfall": fmt_bytes(shortfall),
|
||||
"projectedUsedBytes": projected_used,
|
||||
"projectedUsed": fmt_bytes(projected_used),
|
||||
"projectedUsePercent": projected_use_percent,
|
||||
"safeStop": required > 0 and not enough,
|
||||
"decision": "stop-and-escalate-retention-or-capacity" if required > 0 and not enough else "target-covered-by-safe-candidates",
|
||||
}
|
||||
|
||||
def summarize(candidates, returned, disk=None):
|
||||
by_kind = {}
|
||||
total = 0
|
||||
for item in candidates:
|
||||
@@ -1505,6 +1579,7 @@ def summarize(candidates, returned):
|
||||
"returnedEstimatedReclaimBytes": returned_total,
|
||||
"returnedEstimatedReclaim": fmt_bytes(returned_total),
|
||||
"byKind": by_kind,
|
||||
"target": target_assessment(disk, total),
|
||||
}
|
||||
|
||||
def assert_tmp_candidate(path):
|
||||
@@ -1604,6 +1679,7 @@ def returned_results(results):
|
||||
return (failed + started + succeeded)[:int(OPTIONS.get("resultLimit") or 50)]
|
||||
|
||||
def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
disk = df_snapshot()
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "gc remote plan",
|
||||
@@ -1612,9 +1688,9 @@ def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
"mutation": False,
|
||||
"observedAt": observed_at,
|
||||
"options": OPTIONS,
|
||||
"diskBefore": df_snapshot(),
|
||||
"diskBefore": disk,
|
||||
"clusterPreflight": preflight,
|
||||
"summary": summarize(candidates, visible),
|
||||
"summary": summarize(candidates, visible, disk),
|
||||
"candidates": visible,
|
||||
"protected": protected,
|
||||
"policy": {
|
||||
@@ -1636,6 +1712,7 @@ def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
"HWLAB DEV runtime and local-path PVC data are protected and require HWLAB-specific retention commands.",
|
||||
"Core dump cleanup only removes untracked /root/unidesk/core.<pid> regular files with no active fuser reference.",
|
||||
"HWLAB registry retention is opt-in: it keeps workload tag/digest refs, all tags newer than the retention age and the newest N tags per repo before official registry garbage-collect.",
|
||||
"When summary.target.safeStop is true, do not broaden deletion scope; choose registry retention, k3s/containerd image cache maintenance, PVC/runtime retention or capacity expansion explicitly.",
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1684,6 +1761,20 @@ def main():
|
||||
disk_after = df_snapshot()
|
||||
failed = [item for item in results if item.get("status") == "failed"]
|
||||
returned = returned_results(results)
|
||||
run_summary = summarize(visible, returned, disk_before)
|
||||
run_summary.update({
|
||||
"plannedCandidateCount": len(visible),
|
||||
"attemptedCount": len(results),
|
||||
"startedCount": len([item for item in results if item.get("status") == "started"]),
|
||||
"succeededCount": len([item for item in results if item.get("status") == "succeeded"]),
|
||||
"failedCount": len(failed),
|
||||
"actualDiskReclaimBytes": (disk_after["availableBytes"] - disk_before["availableBytes"]) if disk_before and disk_after else None,
|
||||
"actualDiskReclaim": fmt_bytes(disk_after["availableBytes"] - disk_before["availableBytes"]) if disk_before and disk_after else None,
|
||||
"targetAfter": target_assessment(disk_after, 0),
|
||||
"resultCount": len(results),
|
||||
"returnedResultCount": len(returned),
|
||||
"omittedResultCount": max(0, len(results) - len(returned)),
|
||||
})
|
||||
payload = {
|
||||
"ok": len(failed) == 0,
|
||||
"action": "gc remote run",
|
||||
@@ -1696,18 +1787,7 @@ def main():
|
||||
"diskAfter": disk_after,
|
||||
"clusterPreflight": preflight,
|
||||
"clusterAfter": cluster_preflight(),
|
||||
"summary": {
|
||||
"plannedCandidateCount": len(visible),
|
||||
"attemptedCount": len(results),
|
||||
"startedCount": len([item for item in results if item.get("status") == "started"]),
|
||||
"succeededCount": len([item for item in results if item.get("status") == "succeeded"]),
|
||||
"failedCount": len(failed),
|
||||
"estimatedReclaimBytes": sum(int(item.get("estimatedReclaimBytes") or 0) for item in visible),
|
||||
"actualDiskReclaimBytes": (disk_after["availableBytes"] - disk_before["availableBytes"]) if disk_before and disk_after else None,
|
||||
"resultCount": len(results),
|
||||
"returnedResultCount": len(returned),
|
||||
"omittedResultCount": max(0, len(results) - len(returned)),
|
||||
},
|
||||
"summary": run_summary,
|
||||
"results": returned,
|
||||
"protected": protected,
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ function gcHelp(): unknown {
|
||||
"bun scripts/cli.ts gc policy plan",
|
||||
"bun scripts/cli.ts gc policy install",
|
||||
"bun scripts/cli.ts gc remote G14 plan",
|
||||
"bun scripts/cli.ts gc remote G14 plan --target-use-percent 50 --include-hwlab-registry",
|
||||
"bun scripts/cli.ts gc remote G14 run --confirm",
|
||||
"bun scripts/cli.ts gc remote G14 status --job-id <id>",
|
||||
"bun scripts/cli.ts gc plan --full",
|
||||
@@ -306,6 +307,7 @@ function gcHelp(): unknown {
|
||||
"--registry-gc-only": "remote G14 only: run official registry garbage-collect without deleting additional tags; intended for interrupted registry retention recovery",
|
||||
"--registry-keep-per-repo N": "remote registry only: keep at least N newest tags per service repo; default 20, minimum 1",
|
||||
"--registry-min-age-hours N": "remote registry only: keep all tags newer than N hours; default 48, minimum 0",
|
||||
"--target-use-percent N": "remote only: evaluate whether planned candidates can reduce root filesystem use to N%; reports required reclaim, projected use, shortfall and safe-stop decision",
|
||||
"--job-id ID": "remote status only: inspect a long-running remote gc job",
|
||||
"--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",
|
||||
|
||||
Reference in New Issue
Block a user