Files
pikasTech-unidesk/scripts/src/gc-remote-host-docker.py
T
2026-07-21 10:16:44 +02:00

154 lines
9.8 KiB
Python

from datetime import datetime, timezone
def parse_docker_human_size(raw):
raw = str(raw).split("(")[0].strip()
match = re.match(r"^([0-9.]+)\s*([KMGT]?B)$", raw, re.I)
if not match:
return None
multiplier = {"B": 1, "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4}.get(match.group(2).upper(), 1)
return int(float(match.group(1)) * multiplier)
def parse_docker_build_cache(text):
for line in text.splitlines():
if not line.startswith("Build Cache"):
continue
match = re.match(r"^Build Cache\s+\S+\s+\S+\s+(\S+)\s+(\S+)", line.strip())
if not match:
continue
size = parse_docker_human_size(match.group(1))
reclaim = parse_docker_human_size(match.group(2))
if size is None or reclaim is None:
return None
return {"sizeBytes": size, "reclaimableBytes": reclaim}
return None
def build_cache_only_payload(observed_at):
disk_before = df_snapshot()
section = host_docker_gc_section("buildCachePrune")
until = str(OPTIONS.get("buildCacheUntil") or section.get("until") or "24h")
if not host_docker_gc_enabled("buildCachePrune"):
return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": host_docker_gc_reason("buildCachePrune", "BuildKit cache GC is disabled by YAML")}
system_df = command(["docker", "system", "df"], 8)
if system_df["exitCode"] != 0:
return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": bounded(system_df)}
cache = parse_docker_build_cache(system_df["stdout"])
candidate = None
if cache is not None and cache["reclaimableBytes"] > 0:
candidate = {"id": "docker-builder:prune", "kind": "docker-build-cache-prune", "risk": "low", "sizeBytes": cache["sizeBytes"], "estimatedReclaimBytes": cache["reclaimableBytes"], "action": {"command": ["docker", "builder", "prune", "--all", "--force", "--filter", "until=%s" % until]}}
payload = {"ok": True, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc.buildCachePrune" % PROVIDER_ID, "dryRun": ACTION == "plan", "mutation": ACTION == "run" and candidate is not None, "observedAt": observed_at, "diskBefore": disk_before, "until": until, "candidates": [candidate] if candidate else []}
if ACTION == "run" and candidate is not None:
payload["result"] = execute(candidate)
payload["diskAfter"] = df_snapshot()
return payload
def docker_containers():
ps = command(["docker", "ps", "-qa", "--no-trunc"], 5)
if ps["exitCode"] != 0 or not ps["stdout"].strip():
return []
ids = ps["stdout"].split()
inspect = command(["docker", "inspect"] + ids, 10)
if inspect["exitCode"] != 0 or not inspect["stdout"].strip():
return []
try:
data = json.loads(inspect["stdout"])
except Exception:
return []
rows = []
for item in data:
cfg = item.get("Config") or {}
rows.append({"id": str(item.get("Id") or ""), "name": str(item.get("Name") or "").lstrip("/"), "image": str(cfg.get("Image") or item.get("Image") or ""), "imageId": str(item.get("Image") or ""), "logPath": str(item.get("LogPath") or "")})
return [row for row in rows if row["id"]]
def parse_docker_created(raw):
value = str(raw or "").strip()
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()
except Exception:
return None
def collect_host_docker_image_candidates(observed_at):
global HOST_DOCKER_IMAGE_SCAN
section = host_docker_gc_section("imagePrune")
min_age_hours = config_float(section, "minAgeHours", 168.0, minimum=0.0)
max_delete = config_int(section, "maxDeletePerRun", 20, minimum=1, maximum=500)
preview_limit = config_int(section, "previewLimit", max_delete, minimum=1, maximum=500)
image_ids = command(["docker", "image", "ls", "-aq", "--no-trunc"], 10)
if image_ids["exitCode"] != 0:
HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-list-failed", "command": bounded(image_ids)}
return []
ids = sorted(set(image_ids["stdout"].split()))
referenced = set(row.get("imageId") for row in docker_containers() if row.get("imageId"))
if not ids:
HOST_DOCKER_IMAGE_SCAN = {"ok": True, "minAgeHours": min_age_hours, "maxDeletePerRun": max_delete, "imageCount": 0, "referencedImageCount": len(referenced), "eligibleCount": 0, "selectedCount": 0}
return []
inspected = command(["docker", "image", "inspect"] + ids, 30)
if inspected["exitCode"] != 0:
HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-inspect-failed", "command": bounded(inspected)}
return []
try:
images = json.loads(inspected["stdout"])
except Exception as exc:
HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-inspect-invalid-json", "details": str(exc)}
return []
cutoff = time.time() - min_age_hours * 3600.0
eligible = []
protected_rows = []
for image in images:
image_id = str(image.get("Id") or "")
created_at = str(image.get("Created") or "")
created_epoch = parse_docker_created(created_at)
tags = [str(item) for item in (image.get("RepoTags") or []) if item]
if image_id in referenced:
protected_rows.append({"imageId": image_id, "tags": tags, "reason": "referenced-by-container"})
continue
if created_epoch is None or created_epoch > cutoff:
protected_rows.append({"imageId": image_id, "tags": tags, "reason": "younger-than-min-age-or-age-unknown", "createdAt": created_at})
continue
size = int(image.get("Size") or 0)
eligible.append({"id": "docker-image:%s" % image_id, "kind": "docker-image-delete", "risk": "medium", "description": "Delete old host Docker image unreferenced by any container", "imageId": image_id, "tags": tags, "createdAt": created_at, "ageHours": round(max(0.0, (time.time() - created_epoch) / 3600.0), 1), "minAgeHours": min_age_hours, "sizeBytes": size, "estimatedReclaimBytes": size, "protection": {"allContainerStatesInspected": True, "unreferencedByAnyContainer": True}, "action": {"command": ["docker", "image", "rm", image_id]}})
eligible.sort(key=lambda item: (item.get("createdAt") or "", item.get("imageId") or ""))
selected = eligible[:max_delete]
HOST_DOCKER_IMAGE_SCAN = {"ok": True, "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc.imagePrune" % PROVIDER_ID, "minAgeHours": min_age_hours, "maxDeletePerRun": max_delete, "imageCount": len(images), "referencedImageCount": len(referenced), "protectedCount": len(protected_rows), "eligibleCount": len(eligible), "selectedCount": len(selected), "protectedPreview": protected_rows[:preview_limit]}
return selected
def host_docker_only_payload(observed_at):
disk_before = df_snapshot()
candidates = []
build_section = host_docker_gc_section("buildCachePrune")
build_until = str(OPTIONS.get("buildCacheUntil") or build_section.get("until") or "24h")
if host_docker_gc_enabled("buildCachePrune"):
system_df = command(["docker", "system", "df"], 8)
if system_df["exitCode"] != 0:
return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "host-docker-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": bounded(system_df)}
cache = parse_docker_build_cache(system_df["stdout"])
if cache is not None and cache["reclaimableBytes"] > 0:
candidates.append({"id": "docker-builder:prune", "kind": "docker-build-cache-prune", "risk": "low", "description": "Prune Docker BuildKit cache unused for %s" % build_until, "sizeBytes": cache["sizeBytes"], "estimatedReclaimBytes": cache["reclaimableBytes"], "action": {"command": ["docker", "builder", "prune", "--all", "--force", "--filter", "until=%s" % build_until]}})
if host_docker_gc_enabled("imagePrune", False):
candidates.extend(collect_host_docker_image_candidates(observed_at))
visible = visible_items(candidates)
payload = {"ok": HOST_DOCKER_IMAGE_SCAN.get("ok") is not False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "host-docker-only", "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc" % PROVIDER_ID, "dryRun": ACTION == "plan", "mutation": ACTION == "run" and bool(visible), "observedAt": observed_at, "diskBefore": disk_before, "buildCacheUntil": build_until, "imageRetention": HOST_DOCKER_IMAGE_SCAN, "summary": summarize(candidates, visible, disk_before), "candidates": visible, "protected": {"containerStates": ["running", "stopped", "created"], "referencedImages": "excluded-and-rechecked-before-delete", "containers": "never-deleted", "volumes": "never-deleted"}}
if ACTION == "run":
results = []
for candidate in visible:
try:
execution = execute(candidate)
results.append({**candidate, "status": "succeeded", "reclaimedBytes": execution.get("reclaimedBytes"), "commandOutput": execution.get("commandOutput")})
except Exception as exc:
results.append({**candidate, "status": "failed", "reclaimedBytes": None, "error": str(exc)})
break
payload["results"] = results
payload["ok"] = payload["ok"] and all(item.get("status") == "succeeded" for item in results)
payload["diskAfter"] = df_snapshot()
if payload["diskBefore"] and payload["diskAfter"]:
payload["actualDiskReclaimBytes"] = max(0, payload["diskAfter"]["availableBytes"] - payload["diskBefore"]["availableBytes"])
return payload