Files
pikasTech-unidesk/scripts/src/gc-remote-containerd.py
T
2026-07-05 04:18:22 +00:00

387 lines
19 KiB
Python

def k3s_crictl_base():
endpoint = str(CONTAINERD_CONFIG.get("runtimeEndpoint") or "unix:///run/k3s/containerd/containerd.sock")
return ["crictl", "--runtime-endpoint", endpoint]
def shell_single_quote(value):
return "'" + str(value).replace("'", "'\"'\"'") + "'"
def k3s_crictl_json(args, timeout=30):
result = command(k3s_crictl_base() + args + ["-o", "json"], timeout)
if result["exitCode"] != 0:
return None, result
try:
return json.loads(result["stdout"] or "{}"), result
except Exception:
return None, result
def ci_activity_snapshot_for_prune():
namespaces = config_list(CONTAINERD_CONFIG, "ciNamespaces", ["hwlab-ci", "agentrun-ci"])
active = []
commands = []
for namespace in namespaces:
result = command(["sh", "-lc", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pipelinerun,taskrun,job -n %s --no-headers 2>/dev/null | awk '$2 != \"True\" && $2 != \"False\" && $2 != \"Complete\" && $2 != \"Failed\" {print}' | head -20" % shell_single_quote(namespace)], 15)
commands.append({"namespace": namespace, "command": bounded(result)})
for line in (result.get("stdout") or "").splitlines():
if line.strip():
active.append({"namespace": namespace, "line": line.strip()})
return {"ok": True, "activeCount": len(active), "activePreview": active[:20], "commands": commands}
def compact_ci_activity(activity):
return {
"ok": activity.get("ok"),
"activeCount": activity.get("activeCount"),
"activePreview": activity.get("activePreview") or [],
}
def compact_image_ref(ref):
ref = str(ref or "")
return ref if len(ref) <= 120 else ref[:117] + "..."
def k3s_cri_image_rows():
images, image_cmd = k3s_crictl_json(["images"], 45)
containers, container_cmd = k3s_crictl_json(["ps", "-a"], 30)
if images is None:
return None, {"ok": False, "reason": "crictl-images-failed", "command": bounded(image_cmd)}
if containers is None:
return None, {"ok": False, "reason": "crictl-ps-failed", "command": bounded(container_cmd)}
used = set()
for container in containers.get("containers") or []:
for key in ["imageRef", "image", "imageId"]:
value = container.get(key)
if isinstance(value, str) and value:
used.add(value)
image = container.get("image") or {}
if isinstance(image, dict):
for key in ["image", "annotations", "userSpecifiedImage"]:
value = image.get(key)
if isinstance(value, str) and value:
used.add(value)
rows = []
for image in images.get("images") or []:
refs = []
for key in ["repoTags", "repoDigests"]:
value = image.get(key)
if isinstance(value, list):
refs.extend([str(item) for item in value if item])
image_id = str(image.get("id") or "")
pinned = bool(image.get("pinned"))
size = safe_int(image.get("size_") or image.get("size") or 0)
in_use = pinned or image_id in used or any(ref in used for ref in refs)
rows.append({"id": image_id, "refs": refs, "sizeBytes": size, "inUse": in_use, "pinned": pinned})
return rows, {"ok": True, "imageCommand": bounded(image_cmd), "containerCommand": bounded(container_cmd)}
def k3s_image_cache_candidate():
if not config_bool(CONTAINERD_CONFIG, "enabled", False):
return {
"id": "k3s-cri-image-prune:disabled",
"kind": "k3s-cri-image-prune-disabled",
"risk": "blocked",
"description": "K3s CRI image prune is disabled in YAML",
"estimatedReclaimBytes": 0,
"configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.containerdImageCache.enabled" % PROVIDER_ID,
}
activity = ci_activity_snapshot_for_prune()
if int(activity.get("activeCount") or 0) > 0:
return {
"id": "k3s-cri-image-prune:ci-active",
"kind": "k3s-cri-image-prune-blocked",
"risk": "blocked",
"description": "K3s CRI image prune is blocked while CI workloads are active",
"estimatedReclaimBytes": 0,
"ciActivity": compact_ci_activity(activity),
}
rows, meta = k3s_cri_image_rows()
if rows is None:
return {
"id": "k3s-cri-image-prune:unavailable",
"kind": "k3s-cri-image-prune-unavailable",
"risk": "blocked",
"description": "K3s CRI image list is unavailable",
"estimatedReclaimBytes": 0,
"diagnostic": meta,
}
unused = [row for row in rows if not row.get("inUse")]
estimated = sum(safe_int(row.get("sizeBytes")) for row in unused)
if estimated <= 0:
return None
return {
"id": "k3s-cri-image-prune:unused",
"kind": "k3s-cri-image-prune",
"risk": "medium",
"description": "Prune unused k3s CRI images through crictl rmi --prune; no containerd paths are deleted directly",
"sizeBytes": estimated,
"estimatedReclaimBytes": estimated,
"imageCount": len(rows),
"unusedImageCount": len(unused),
"unusedPreview": [{"id": row.get("id"), "refs": [compact_image_ref(ref) for ref in (row.get("refs") or [])[:2]], "sizeBytes": row.get("sizeBytes")} for row in unused[:3]],
"ciActivity": compact_ci_activity(activity),
"action": {"command": k3s_crictl_base() + ["rmi", "--prune"], "mode": "cri-unused-images-only"},
}
def execute_k3s_image_cache_prune():
activity = ci_activity_snapshot_for_prune()
if int(activity.get("activeCount") or 0) > 0:
raise RuntimeError("refusing k3s image prune while CI workloads are active")
before = du_size("/var/lib/rancher/k3s/agent/containerd", 45) or 0
result = command(k3s_crictl_base() + ["rmi", "--prune"], 300)
if result["exitCode"] != 0:
raise RuntimeError((result["stderr"] or result["stdout"] or "crictl rmi --prune failed").strip())
after = du_size("/var/lib/rancher/k3s/agent/containerd", 45) or 0
return {"reclaimedBytes": max(0, before - after), "commandOutput": bounded(result), "ciActivity": compact_ci_activity(activity)}
def host_ctr_base(namespace=None):
address = config_str(HOST_CONTAINERD_CONFIG, "address", "")
args = ["ctr"]
if address:
args.extend(["--address", address])
if namespace:
args.extend(["-n", namespace])
return args
def host_ctr(args, timeout=30, namespace=None):
return command(host_ctr_base(namespace) + args, timeout)
def host_containerd_namespaces():
configured = config_list(HOST_CONTAINERD_CONFIG, "namespaces", [])
if configured:
return configured, {"source": "yaml", "command": None}
result = host_ctr(["namespaces", "list", "-q"], 20)
if result["exitCode"] != 0:
return [], {"source": "ctr", "command": bounded(result), "error": "ctr-namespaces-failed"}
return [line.strip() for line in (result.get("stdout") or "").splitlines() if line.strip()], {"source": "ctr", "command": bounded(result)}
def host_containerd_activity():
if not config_bool(HOST_CONTAINERD_CONFIG, "enabled", False):
return {"ok": False, "reason": "host-containerd-cache-disabled", "activeCount": 0}
root = config_str(HOST_CONTAINERD_CONFIG, "root", "")
if not root or not os.path.isdir(root):
return {"ok": False, "reason": "host-containerd-root-unavailable", "root": root, "activeCount": 0}
namespaces, namespace_meta = host_containerd_namespaces()
active = []
commands = []
for namespace in namespaces:
task_result = host_ctr(["tasks", "list", "-q"], 20, namespace)
container_result = host_ctr(["containers", "list", "-q"], 20, namespace)
image_result = host_ctr(["images", "list", "-q"], 20, namespace)
lease_result = host_ctr(["leases", "list", "-q"], 20, namespace)
snapshot_result = host_ctr(["snapshots", "list"], 20, namespace)
content_result = host_ctr(["content", "list"], 20, namespace)
snapshot_lines = table_data_lines(snapshot_result.get("stdout") or "", "KEY")
content_lines = table_data_lines(content_result.get("stdout") or "", "DIGEST")
commands.append({
"namespace": namespace,
"tasks": bounded(task_result),
"containers": bounded(container_result),
"images": bounded(image_result),
"leases": bounded(lease_result),
"snapshots": bounded(snapshot_result),
"content": bounded(content_result),
})
for kind, result in [("task", task_result), ("container", container_result), ("lease", lease_result)]:
if result["exitCode"] != 0:
active.append({"namespace": namespace, "kind": kind, "state": "unknown", "reason": "ctr-list-failed"})
continue
for line in (result.get("stdout") or "").splitlines():
if line.strip():
active.append({"namespace": namespace, "kind": kind, "name": line.strip()})
if snapshot_result["exitCode"] != 0:
active.append({"namespace": namespace, "kind": "snapshot", "state": "unknown", "reason": "ctr-list-failed"})
for line in snapshot_lines:
active.append({"namespace": namespace, "kind": "snapshot", "name": line.split()[0] if line.split() else line})
if content_result["exitCode"] != 0:
active.append({"namespace": namespace, "kind": "content", "state": "unknown", "reason": "ctr-list-failed"})
for line in content_lines:
active.append({"namespace": namespace, "kind": "content", "name": line.split()[0] if line.split() else line})
return {
"ok": True,
"root": root,
"namespaces": namespaces,
"namespaceMeta": namespace_meta,
"activeCount": len(active),
"activePreview": active[:20],
"commands": commands,
}
def compact_host_containerd_activity(activity):
return {
"ok": activity.get("ok"),
"reason": activity.get("reason"),
"root": activity.get("root"),
"namespaces": activity.get("namespaces"),
"activeCount": activity.get("activeCount"),
"activePreview": activity.get("activePreview") or [],
}
def table_data_lines(stdout, header_prefix):
lines = [line.strip() for line in str(stdout or "").splitlines() if line.strip()]
return [line for line in lines if not line.startswith(header_prefix)]
def host_containerd_orphan_config():
value = HOST_CONTAINERD_CONFIG.get("orphanCleanup") if isinstance(HOST_CONTAINERD_CONFIG, dict) else None
return value if isinstance(value, dict) else {}
def direct_child_paths(root, predicate):
if not root or not os.path.isdir(root) or os.path.islink(root):
return []
rows = []
for name in sorted(os.listdir(root)):
path = os.path.realpath(os.path.abspath(os.path.join(root, name)))
if os.path.dirname(path) != os.path.realpath(os.path.abspath(root)):
continue
if not predicate(name, path):
continue
rows.append({"name": name, "path": path, "estimatedReclaimBytes": du_size(path, 10) or path_size(path)})
return rows
def host_containerd_orphan_rows(activity):
cfg = host_containerd_orphan_config()
if not config_bool(cfg, "enabled", False):
return [], {"ok": False, "reason": "host-containerd-orphan-cleanup-disabled"}
if not activity.get("ok") or int(activity.get("activeCount") or 0) > 0:
return [], {"ok": False, "reason": "host-containerd-metadata-not-empty", "activity": compact_host_containerd_activity(activity)}
overlay_root = os.path.realpath(os.path.abspath(config_str(cfg, "overlaySnapshotsRoot", "")))
content_root = os.path.realpath(os.path.abspath(config_str(cfg, "contentBlobRoot", "")))
root = os.path.realpath(os.path.abspath(config_str(HOST_CONTAINERD_CONFIG, "root", "")))
if not root or not overlay_root.startswith(root.rstrip("/") + "/") or not content_root.startswith(root.rstrip("/") + "/"):
return [], {"ok": False, "reason": "host-containerd-orphan-root-outside-containerd-root", "root": root}
open_roots = []
for candidate_root in [overlay_root, content_root]:
if os.path.exists(candidate_root) and path_has_open_fd(candidate_root):
open_roots.append(candidate_root)
if open_roots:
return [], {"ok": False, "reason": "host-containerd-orphan-root-open-fd", "openRoots": open_roots}
overlay_rows = direct_child_paths(overlay_root, lambda name, path: os.path.isdir(path) and not os.path.islink(path) and re.match(r"^[0-9]+$", name) is not None)
content_rows = direct_child_paths(content_root, lambda name, path: os.path.isfile(path) and not os.path.islink(path) and re.match(r"^[0-9a-f]{64}$", name) is not None)
safe_rows = []
for kind, rows in [("overlay-snapshot-dir", overlay_rows), ("content-blob-file", content_rows)]:
for row in rows:
safe_rows.append({**row, "kind": kind})
safe_rows.sort(key=lambda item: safe_int(item.get("estimatedReclaimBytes")), reverse=True)
return safe_rows, {
"ok": True,
"root": root,
"overlaySnapshotsRoot": overlay_root,
"contentBlobRoot": content_root,
"overlayCandidateCount": len(overlay_rows),
"contentCandidateCount": len(content_rows),
"protectedCount": 0,
"protectedPreview": [],
}
def host_containerd_orphan_candidate(activity):
rows, meta = host_containerd_orphan_rows(activity)
if not meta.get("ok"):
return None
limit = int(OPTIONS.get("limit") or 50)
selected = rows[:limit]
estimated = sum(safe_int(row.get("estimatedReclaimBytes")) for row in selected)
if estimated <= 0:
return None
return {
"id": "host-containerd-orphan-state:delete",
"kind": "host-containerd-orphan-state-delete",
"risk": "medium",
"description": "Delete YAML-allowlisted host containerd orphan snapshot/content files only when ctr metadata has no tasks, containers, leases, images, snapshots or content",
"path": meta.get("root"),
"sizeBytes": estimated,
"estimatedReclaimBytes": estimated,
"orphanCount": len(rows),
"selectedOrphanCount": len(selected),
"overlayCandidateCount": meta.get("overlayCandidateCount"),
"contentCandidateCount": meta.get("contentCandidateCount"),
"protectedCount": meta.get("protectedCount"),
"selectedPreview": [{"kind": row.get("kind"), "name": row.get("name"), "estimatedReclaimBytes": row.get("estimatedReclaimBytes")} for row in selected[:8]],
"protectedPreview": meta.get("protectedPreview"),
"action": {"op": "remove-yaml-allowlisted-host-containerd-orphans", "limit": limit},
}
def host_containerd_cache_candidate():
activity = host_containerd_activity()
if not activity.get("ok"):
return {
"id": "host-containerd-cache:unavailable",
"kind": "host-containerd-cache-unavailable",
"risk": "blocked",
"description": "Host containerd cache cleanup is disabled or unavailable by YAML",
"estimatedReclaimBytes": 0,
"diagnostic": compact_host_containerd_activity(activity),
}
if int(activity.get("activeCount") or 0) > 0:
return {
"id": "host-containerd-cache:active",
"kind": "host-containerd-cache-blocked",
"risk": "blocked",
"description": "Host containerd cache prune is blocked while host containerd tasks or containers exist",
"estimatedReclaimBytes": 0,
"activity": compact_host_containerd_activity(activity),
}
orphan = host_containerd_orphan_candidate(activity)
if orphan:
return orphan
root = activity.get("root") or ""
size = du_size(root, 45) or 0
if size <= 0:
return None
return {
"id": "host-containerd-cache:prune-unused",
"kind": "host-containerd-cache-prune",
"risk": "medium",
"description": "Prune host containerd images in YAML-selected namespaces only when no host containerd tasks or containers exist",
"path": root,
"sizeBytes": size,
"estimatedReclaimBytes": size,
"activity": compact_host_containerd_activity(activity),
"action": {"command": "ctr images prune --all per namespace", "mode": "host-containerd-unused-images-only"},
}
def execute_host_containerd_cache_prune():
activity = host_containerd_activity()
if not activity.get("ok"):
raise RuntimeError("host containerd cache cleanup unavailable: %s" % activity.get("reason"))
if int(activity.get("activeCount") or 0) > 0:
raise RuntimeError("refusing host containerd prune while tasks or containers exist")
root = activity.get("root") or ""
before = du_size(root, 45) or 0
results = []
for namespace in activity.get("namespaces") or []:
result = host_ctr(["images", "prune", "--all"], 300, namespace)
results.append({"namespace": namespace, "imagesPrune": bounded(result)})
if result["exitCode"] != 0:
raise RuntimeError("host containerd image prune failed in namespace %s: %s" % (namespace, (result.get("stderr") or result.get("stdout") or "").strip()))
after = du_size(root, 45) or 0
return {
"reclaimedBytes": max(0, before - after),
"activity": compact_host_containerd_activity(activity),
"commandResults": results[:8],
}
def execute_host_containerd_orphan_cleanup():
activity = host_containerd_activity()
rows, meta = host_containerd_orphan_rows(activity)
if not meta.get("ok"):
raise RuntimeError("host containerd orphan cleanup unavailable: %s" % meta.get("reason"))
for root_path in [meta.get("overlaySnapshotsRoot"), meta.get("contentBlobRoot")]:
if root_path and os.path.exists(root_path) and path_has_open_fd(root_path):
raise RuntimeError("refusing host containerd orphan cleanup with open fd/cwd under root: %s" % root_path)
limit = int(OPTIONS.get("limit") or 50)
selected = rows[:limit]
reclaimed = 0
deleted = []
for row in selected:
path = row.get("path")
before = du_size(path, 10) or path_size(path)
if row.get("kind") == "overlay-snapshot-dir":
shutil.rmtree(path, ignore_errors=True)
elif row.get("kind") == "content-blob-file":
os.unlink(path)
else:
raise RuntimeError("unsupported host containerd orphan kind: %s" % row.get("kind"))
reclaimed += before
deleted.append({"kind": row.get("kind"), "name": row.get("name"), "reclaimedBytes": before})
return {
"reclaimedBytes": reclaimed,
"deletedOrphanCount": len(deleted),
"deletedPreview": deleted[:12],
"root": meta.get("root"),
}