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

678 lines
31 KiB
Python

def active_hwlab_ci_writes():
result = command(["sh", "-lc", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pipelinerun,taskrun -n hwlab-ci --no-headers 2>/dev/null | awk '$2 != \"True\" && $2 != \"False\" {print}' | head -40"], 15)
lines = [line for line in (result.get("stdout") or "").splitlines() if line.strip()]
return {"ok": result["exitCode"] == 0, "activeCount": len(lines), "activePreview": lines, "command": bounded(result)}
def active_hwlab_ci_jobs():
result = command(["sh", "-lc", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get jobs -n hwlab-ci --no-headers 2>/dev/null | awk '$2 != \"Complete\" && $2 != \"Failed\" {print}' | head -40"], 15)
lines = [line for line in (result.get("stdout") or "").splitlines() if line.strip()]
return {"ok": result["exitCode"] == 0, "activeCount": len(lines), "activePreview": lines, "command": bounded(result)}
def wait_no_active_hwlab_ci(timeout=180):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
writes = active_hwlab_ci_writes()
jobs = active_hwlab_ci_jobs()
last = {"writes": writes, "jobs": jobs}
if writes.get("ok") and jobs.get("ok") and int(writes.get("activeCount") or 0) == 0 and int(jobs.get("activeCount") or 0) == 0:
return {"ok": True, "last": last}
time.sleep(5)
return {"ok": False, "last": last}
def kubectl_json(args, timeout=20):
result = command(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl"] + args + ["-o", "json"], timeout)
if result["exitCode"] != 0:
return None
try:
return json.loads(result["stdout"] or "{}")
except Exception:
return None
def kctl(args, timeout=30):
return command(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl"] + args, timeout)
def workload_image_refs():
result = command(["sh", "-lc", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get deploy,sts,ds,pod -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.initContainers[*]}{.image}{\"\\n\"}{end}{range .spec.template.spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.template.spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null | sort -u"], 30)
refs = set()
digests = set()
for image in (result.get("stdout") or "").splitlines():
image = image.strip()
if not image.startswith("127.0.0.1:5000/"):
continue
ref = image.split("127.0.0.1:5000/", 1)[1]
if "@sha256:" in ref:
repo, digest = ref.split("@", 1)
refs.add((repo, "@" + digest))
digests.add("sha256:" + digest.split(":", 1)[1])
elif ":" in ref:
repo, tag = ref.rsplit(":", 1)
refs.add((repo, tag))
return refs, digests, bounded(result)
def registry_request(method, path, headers=None, timeout=20):
url = "http://127.0.0.1:5000" + path
req = urllib.request.Request(url, method=method, headers=headers or {})
with urllib.request.urlopen(req, timeout=timeout) as response:
body = response.read()
return {"status": response.status, "headers": dict(response.headers), "body": body.decode("utf-8", errors="replace")}
def registry_tag_rows():
rows = []
root = REGISTRY_REPOSITORY_ROOT
if not os.path.isdir(root):
return rows
for repo_root, dirs, files in os.walk(root):
if os.path.basename(repo_root) != "tags":
continue
rel = os.path.relpath(repo_root, root)
suffix = "/_manifests/tags"
if not rel.endswith(suffix):
continue
repo = rel[:-len(suffix)]
try:
tags = os.listdir(repo_root)
except OSError:
continue
for tag in sorted(tags):
link = os.path.join(repo_root, tag, "current", "link")
if not os.path.isfile(link):
continue
try:
with open(link, "r", encoding="utf-8") as handle:
digest = handle.read().strip()
stat = os.stat(link)
except OSError:
continue
rows.append({
"repo": repo,
"tag": tag,
"digest": digest,
"mtime": stat.st_mtime,
"mtimeIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
"path": os.path.join(repo_root, tag),
})
return rows
def registry_revision_rows():
rows = []
root = REGISTRY_REPOSITORY_ROOT
if not os.path.isdir(root):
return rows
for repo_root, dirs, files in os.walk(root):
if os.path.basename(repo_root) != "sha256":
continue
rel = os.path.relpath(repo_root, root)
suffix = "/_manifests/revisions/sha256"
if not rel.endswith(suffix):
continue
repo = rel[:-len(suffix)]
try:
revisions = os.listdir(repo_root)
except OSError:
continue
for digest_hex in sorted(revisions):
path = os.path.join(repo_root, digest_hex)
link = os.path.join(path, "link")
if not os.path.isfile(link):
continue
try:
with open(link, "r", encoding="utf-8") as handle:
digest = handle.read().strip()
stat = os.stat(link)
except OSError:
continue
rows.append({
"repo": repo,
"digest": digest,
"mtime": stat.st_mtime,
"mtimeIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
"path": path,
})
return rows
def registry_retention_repo(repo):
return repo.startswith("hwlab/hwlab-") or repo.startswith("hwlab/cache/hwlab-")
def registry_digest_hex(digest):
if not isinstance(digest, str) or not digest.startswith("sha256:"):
return None
value = digest.split(":", 1)[1]
if re.match(r"^[0-9a-f]{64}$", value) is None:
return None
return value
def registry_blob_data_path(digest):
value = registry_digest_hex(digest)
if value is None:
return None
return os.path.join(REGISTRY_ROOT, "docker/registry/v2/blobs/sha256", value[:2], value, "data")
_manifest_cache = {}
def registry_manifest_json(digest):
if digest in _manifest_cache:
return _manifest_cache[digest]
path = registry_blob_data_path(digest)
if path is None or not os.path.isfile(path):
_manifest_cache[digest] = None
return None
try:
with open(path, "rb") as handle:
data = handle.read(8 * 1024 * 1024)
value = json.loads(data.decode("utf-8"))
except Exception:
value = None
_manifest_cache[digest] = value
return value
def registry_manifest_refs(digest):
manifest = registry_manifest_json(digest)
if not isinstance(manifest, dict):
return set()
refs = set()
config = manifest.get("config") or {}
config_digest = config.get("digest")
if isinstance(config_digest, str) and registry_digest_hex(config_digest) is not None:
refs.add(config_digest)
for item in manifest.get("layers") or []:
item_digest = (item or {}).get("digest")
if isinstance(item_digest, str) and registry_digest_hex(item_digest) is not None:
refs.add(item_digest)
for item in manifest.get("manifests") or []:
item_digest = (item or {}).get("digest")
if isinstance(item_digest, str) and registry_digest_hex(item_digest) is not None:
refs.add(item_digest)
return refs
def registry_digest_closure(seed):
seen = set()
stack = list(seed)
while stack:
digest = stack.pop()
if digest in seen or registry_digest_hex(digest) is None:
continue
seen.add(digest)
for child in registry_manifest_refs(digest):
if child not in seen:
stack.append(child)
return seen
def registry_blob_size(digest):
path = registry_blob_data_path(digest)
if path is None or not os.path.isfile(path):
return 0
try:
return int(os.lstat(path).st_blocks) * 512
except OSError:
return 0
def estimate_registry_reclaim(delete_manifest_digests, kept_manifest_digests):
deleted = registry_digest_closure(delete_manifest_digests)
kept = registry_digest_closure(kept_manifest_digests)
reclaim = deleted - kept
return sum(registry_blob_size(digest) for digest in reclaim)
def plan_registry_retention():
keep_per_repo = int(OPTIONS.get("registryKeepPerRepo") if OPTIONS.get("registryKeepPerRepo") is not None else 5)
min_age_hours = float(OPTIONS.get("registryMinAgeHours") if OPTIONS.get("registryMinAgeHours") is not None else 48)
cutoff = time.time() - min_age_hours * 3600
refs, digests, refs_command = workload_image_refs()
rows = registry_tag_rows()
revision_rows = registry_revision_rows()
by_repo = {}
for row in rows:
by_repo.setdefault(row["repo"], []).append(row)
keep = set()
keep_reasons = {}
for repo, items in by_repo.items():
items.sort(key=lambda item: item["mtime"], reverse=True)
for row in items[:keep_per_repo]:
key = (row["repo"], row["tag"])
keep.add(key)
keep_reasons[key] = "latest-per-repo"
for row in items:
key = (row["repo"], row["tag"])
if row["tag"] in REGISTRY_PROTECTED_TAGS:
keep.add(key)
keep_reasons[key] = "protected-tag"
if key in refs:
keep.add(key)
keep_reasons[key] = "workload-tag-ref"
if row["digest"] in digests:
keep.add(key)
keep_reasons[key] = "workload-digest-ref"
if row["repo"].startswith("hwlab/cache/"):
keep.add(key)
keep_reasons[key] = "cache-repo"
if row["mtime"] >= cutoff:
keep.add(key)
keep_reasons[key] = "recent-tag"
delete_rows = []
kept_count = 0
delete_by_repo = {}
keep_by_repo = {}
kept_digests = set()
for row in rows:
key = (row["repo"], row["tag"])
should_delete = (
key not in keep
and row["repo"].startswith("hwlab/hwlab-")
and re.match(r"^[0-9a-f]{7,40}$", row["tag"]) is not None
)
if should_delete:
delete_rows.append(row)
delete_by_repo[row["repo"]] = delete_by_repo.get(row["repo"], 0) + 1
else:
kept_count += 1
kept_digests.add(row["digest"])
keep_by_repo[row["repo"]] = keep_by_repo.get(row["repo"], 0) + 1
protected_digests = kept_digests | digests
protected_digests.update(row["digest"] for row in revision_rows if not registry_retention_repo(row["repo"]))
protected_digests = registry_digest_closure(protected_digests)
delete_revision_rows = []
revision_delete_by_repo = {}
for row in revision_rows:
if not registry_retention_repo(row["repo"]):
continue
if row["digest"] in protected_digests:
continue
delete_revision_rows.append(row)
revision_delete_by_repo[row["repo"]] = revision_delete_by_repo.get(row["repo"], 0) + 1
kept_revision_digests = set(row["digest"] for row in revision_rows if row not in delete_revision_rows)
delete_revision_digests = set(row["digest"] for row in delete_revision_rows)
deletable_manifests = {}
for row in delete_rows:
if row["digest"] in kept_digests:
continue
deletable_manifests.setdefault(row["repo"], set()).add(row["digest"])
for row in delete_revision_rows:
deletable_manifests.setdefault(row["repo"], set()).add(row["digest"])
deletable_manifest_count = sum(len(items) for items in deletable_manifests.values())
registry_size = du_size(REGISTRY_ROOT, 30) or 0
estimate = estimate_registry_reclaim(delete_revision_digests, kept_revision_digests)
return {
"tagRows": rows,
"revisionRows": revision_rows,
"deleteRows": delete_rows,
"deleteRevisionRows": delete_revision_rows,
"summary": {
"totalTags": len(rows),
"totalRevisions": len(revision_rows),
"repoCount": len(by_repo),
"keepPerRepo": keep_per_repo,
"minAgeHours": min_age_hours,
"protectedWorkloadRefs": len(refs),
"protectedDigestRefs": len(digests),
"protectedDigestClosure": len(protected_digests),
"keptTags": kept_count,
"deleteTags": len(delete_rows),
"deleteManifests": deletable_manifest_count,
"deleteRevisions": len(delete_revision_rows),
"deleteByRepo": delete_by_repo,
"revisionDeleteByRepo": revision_delete_by_repo,
"keepByRepo": keep_by_repo,
"registrySizeBytes": registry_size,
"estimatedReclaimBytes": estimate,
},
"deleteManifestsByRepo": {repo: sorted(list(digests)) for repo, digests in deletable_manifests.items()},
"refsCommand": refs_command,
}
def registry_deployment_preflight():
dep = kubectl_json(["-n", "hwlab-ci", "get", "deploy", "hwlab-registry"], 20)
if not dep:
return {"ok": False, "reason": "registry-deployment-missing"}
spec = ((dep.get("spec") or {}).get("template") or {}).get("spec") or {}
containers = spec.get("containers") or []
volumes = spec.get("volumes") or []
registry_container = next((item for item in containers if item.get("name") == "registry"), containers[0] if containers else {})
mounts = registry_container.get("volumeMounts") or []
has_host_path = any(((vol.get("hostPath") or {}).get("path") == REGISTRY_ROOT and vol.get("name") == "storage") for vol in volumes)
has_mount = any((mount.get("name") == "storage" and mount.get("mountPath") == "/var/lib/registry") for mount in mounts)
image = str(registry_container.get("image") or "")
ok = bool(has_host_path and has_mount and image.startswith("registry:") and spec.get("hostNetwork") is True)
return {
"ok": ok,
"reason": "ok" if ok else "unexpected-registry-deployment-shape",
"image": image,
"hostNetwork": spec.get("hostNetwork"),
"hasExpectedHostPath": has_host_path,
"hasExpectedMount": has_mount,
"replicas": (dep.get("spec") or {}).get("replicas"),
"readyReplicas": (dep.get("status") or {}).get("readyReplicas"),
}
def cronjob_suspend_states(names):
states = {}
for name in names:
data = kubectl_json(["-n", "hwlab-ci", "get", "cronjob", name], 15)
if data:
states[name] = bool(((data.get("spec") or {}).get("suspend")) is True)
return states
def patch_cronjob_suspend(name, suspend):
payload = json.dumps({"spec": {"suspend": bool(suspend)}})
return kctl(["-n", "hwlab-ci", "patch", "cronjob", name, "--type=merge", "-p", payload], 30)
def wait_registry_pod_count(target, timeout=90):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
result = kctl(["-n", "hwlab-ci", "get", "pods", "-l", "app.kubernetes.io/name=hwlab-registry", "--no-headers"], 20)
last = bounded(result)
lines = [line for line in (result.get("stdout") or "").splitlines() if line.strip()]
active = []
for line in lines:
parts = line.split()
status = parts[2] if len(parts) >= 3 else ""
if status in set(["Completed", "Error", "Failed", "Succeeded"]):
continue
active.append(line)
if len(active) == target:
return {"ok": True, "lines": active, "allLines": lines, "last": last}
time.sleep(2)
return {"ok": False, "lines": [], "last": last}
def wait_pod_terminal(name, timeout=900):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
data = kubectl_json(["-n", "hwlab-ci", "get", "pod", name], 20)
if data:
phase = ((data.get("status") or {}).get("phase")) or ""
last = {"phase": phase}
if phase == "Succeeded":
return {"ok": True, "phase": phase}
if phase == "Failed":
return {"ok": False, "phase": phase}
time.sleep(3)
return {"ok": False, "phase": "Timeout", "last": last}
def execute_registry_retention():
if PROVIDER_ID.upper() != "G14":
raise RuntimeError("HWLAB registry retention is only supported on G14")
deployment = registry_deployment_preflight()
if not deployment.get("ok"):
raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
plan = plan_registry_retention()
delete_rows = plan.get("deleteRows") or []
delete_revision_rows = plan.get("deleteRevisionRows") or []
delete_manifests = plan.get("deleteManifestsByRepo") or {}
if not delete_rows and not delete_revision_rows:
return {"reclaimedBytes": 0, "commandOutput": {"message": "no registry tags or revisions matched conservative retention", "registryPlan": plan.get("summary")}}
if not delete_manifests:
return {"reclaimedBytes": 0, "commandOutput": {"message": "matched manifests are still referenced by retained manifests; registry GC would not reclaim blobs", "registryPlan": plan.get("summary")}}
cronjobs = ["hwlab-g14-branch-poller", "hwlab-v02-branch-poller"]
original_crons = cronjob_suspend_states(cronjobs)
before = du_size(REGISTRY_ROOT, 60) or 0
gc_name = "hwlab-registry-gc-%s" % int(time.time())
steps = []
try:
for name in original_crons:
result = patch_cronjob_suspend(name, True)
steps.append({"step": "suspend-cronjob", "name": name, "result": bounded(result)})
if result["exitCode"] != 0:
raise RuntimeError("failed to suspend cronjob %s" % name)
idle_after_suspend = wait_no_active_hwlab_ci(180)
steps.append({"step": "idle-after-suspend", "result": idle_after_suspend})
if not idle_after_suspend.get("ok"):
raise RuntimeError("refusing registry maintenance because hwlab-ci did not become idle after suspend")
deleted_manifests = []
for repo, digests in delete_manifests.items():
encoded_repo = "/".join(urllib.parse.quote(part, safe="") for part in repo.split("/"))
for digest in digests:
try:
result = registry_request("DELETE", "/v2/%s/manifests/%s" % (encoded_repo, urllib.parse.quote(digest, safe=":")), {"Accept": "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json"})
deleted_manifests.append({"repo": repo, "digest": digest, "status": result.get("status")})
except urllib.error.HTTPError as exc:
if exc.code == 404:
deleted_manifests.append({"repo": repo, "digest": digest, "status": 404})
else:
raise
steps.append({"step": "registry-api-delete-manifests", "count": len(deleted_manifests), "preview": deleted_manifests[:20]})
scale_down = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=0"], 60)
steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
if scale_down["exitCode"] != 0:
raise RuntimeError("failed to scale registry down")
waited_down = wait_registry_pod_count(0, 120)
steps.append({"step": "wait-registry-down", "result": waited_down})
if not waited_down.get("ok"):
raise RuntimeError("registry pod did not scale down")
deleted = []
for row in delete_rows:
path = os.path.abspath(str(row.get("path") or ""))
if not path.startswith(REGISTRY_REPOSITORY_ROOT + "/") or "/_manifests/tags/" not in path:
raise RuntimeError("refusing unexpected registry tag path: %s" % path)
if not re.match(r"^[0-9a-f]{7,40}$", str(row.get("tag") or "")):
raise RuntimeError("refusing unexpected registry tag name: %s" % row.get("tag"))
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
deleted.append({"repo": row.get("repo"), "tag": row.get("tag"), "digest": row.get("digest")})
steps.append({"step": "delete-tag-directories", "count": len(deleted)})
deleted_revisions = []
for row in delete_revision_rows:
path = os.path.abspath(str(row.get("path") or ""))
digest_hex = registry_digest_hex(str(row.get("digest") or ""))
if digest_hex is None:
raise RuntimeError("refusing unexpected registry revision digest: %s" % row.get("digest"))
if not path.startswith(REGISTRY_REPOSITORY_ROOT + "/") or "/_manifests/revisions/sha256/" not in path:
raise RuntimeError("refusing unexpected registry revision path: %s" % path)
if os.path.basename(path) != digest_hex:
raise RuntimeError("refusing registry revision path/digest mismatch: %s" % path)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
deleted_revisions.append({"repo": row.get("repo"), "digest": row.get("digest")})
steps.append({"step": "delete-revision-directories", "count": len(deleted_revisions)})
overrides = {
"apiVersion": "v1",
"spec": {
"restartPolicy": "Never",
"containers": [{
"name": "registry-gc",
"image": "registry:2.8.3",
"command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
"volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
}],
"volumes": [{"name": "storage", "hostPath": {"path": REGISTRY_ROOT, "type": "DirectoryOrCreate"}}],
},
}
run_gc = kctl(["-n", "hwlab-ci", "run", gc_name, "--restart=Never", "--image=registry:2.8.3", "--overrides=%s" % json.dumps(overrides)], 60)
steps.append({"step": "start-registry-gc-pod", "result": bounded(run_gc), "pod": gc_name})
if run_gc["exitCode"] != 0:
raise RuntimeError("failed to start registry GC pod")
waited_gc = wait_pod_terminal(gc_name, 900)
steps.append({"step": "wait-registry-gc", "result": waited_gc})
logs = kctl(["-n", "hwlab-ci", "logs", gc_name], 120)
steps.append({"step": "registry-gc-logs", "result": bounded(logs)})
if not waited_gc.get("ok"):
raise RuntimeError("registry GC pod did not complete successfully")
finally:
cleanup_gc = kctl(["-n", "hwlab-ci", "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
scale_up = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
rollout = kctl(["-n", "hwlab-ci", "rollout", "status", "deploy/hwlab-registry", "--timeout=180s"], 200)
steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
for name, was_suspended in original_crons.items():
restore = patch_cronjob_suspend(name, was_suspended)
steps.append({"step": "restore-cronjob", "name": name, "suspend": was_suspended, "result": bounded(restore)})
after = du_size(REGISTRY_ROOT, 60) or 0
return {
"reclaimedBytes": max(0, before - after),
"commandOutput": {
"registryPlan": plan.get("summary"),
"deletedTagCount": len(delete_rows),
"deletedRevisionCount": len(delete_revision_rows),
"deletedManifestCount": sum(len(items) for items in delete_manifests.values()),
"diskBeforeBytes": before,
"diskAfterBytes": after,
"steps": steps[-12:],
},
}
def execute_registry_garbage_collect_only():
if PROVIDER_ID.upper() != "G14":
raise RuntimeError("HWLAB registry garbage-collect is only supported on G14")
deployment = registry_deployment_preflight()
if not deployment.get("ok"):
raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
cronjobs = ["hwlab-g14-branch-poller", "hwlab-v02-branch-poller"]
original_crons = cronjob_suspend_states(cronjobs)
before = du_size(REGISTRY_ROOT, 60) or 0
gc_name = "hwlab-registry-gc-%s" % int(time.time())
steps = []
try:
for name in original_crons:
result = patch_cronjob_suspend(name, True)
steps.append({"step": "suspend-cronjob", "name": name, "result": bounded(result)})
if result["exitCode"] != 0:
raise RuntimeError("failed to suspend cronjob %s" % name)
idle_after_suspend = wait_no_active_hwlab_ci(180)
steps.append({"step": "idle-after-suspend", "result": idle_after_suspend})
if not idle_after_suspend.get("ok"):
raise RuntimeError("refusing registry maintenance because hwlab-ci did not become idle after suspend")
scale_down = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=0"], 60)
steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
if scale_down["exitCode"] != 0:
raise RuntimeError("failed to scale registry down")
waited_down = wait_registry_pod_count(0, 120)
steps.append({"step": "wait-registry-down", "result": waited_down})
if not waited_down.get("ok"):
raise RuntimeError("registry pod did not scale down")
overrides = {
"apiVersion": "v1",
"spec": {
"restartPolicy": "Never",
"containers": [{
"name": "registry-gc",
"image": "registry:2.8.3",
"command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
"volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
}],
"volumes": [{"name": "storage", "hostPath": {"path": REGISTRY_ROOT, "type": "DirectoryOrCreate"}}],
},
}
run_gc = kctl(["-n", "hwlab-ci", "run", gc_name, "--restart=Never", "--image=registry:2.8.3", "--overrides=%s" % json.dumps(overrides)], 60)
steps.append({"step": "start-registry-gc-pod", "result": bounded(run_gc), "pod": gc_name})
if run_gc["exitCode"] != 0:
raise RuntimeError("failed to start registry GC pod")
waited_gc = wait_pod_terminal(gc_name, 900)
steps.append({"step": "wait-registry-gc", "result": waited_gc})
logs = kctl(["-n", "hwlab-ci", "logs", gc_name], 120)
steps.append({"step": "registry-gc-logs", "result": bounded(logs)})
if not waited_gc.get("ok"):
raise RuntimeError("registry GC pod did not complete successfully")
finally:
cleanup_gc = kctl(["-n", "hwlab-ci", "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
scale_up = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
rollout = kctl(["-n", "hwlab-ci", "rollout", "status", "deploy/hwlab-registry", "--timeout=180s"], 200)
steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
for name, was_suspended in original_crons.items():
restore = patch_cronjob_suspend(name, was_suspended)
steps.append({"step": "restore-cronjob", "name": name, "suspend": was_suspended, "result": bounded(restore)})
after = du_size(REGISTRY_ROOT, 60) or 0
return {
"reclaimedBytes": max(0, before - after),
"commandOutput": {
"message": "official registry garbage-collect only; no additional tag deletion",
"diskBeforeBytes": before,
"diskAfterBytes": after,
"steps": steps[-12:],
},
}
def start_registry_retention_job(mode):
job_id = "g14-registry-%s-%s" % (int(time.time()), os.getpid())
paths = job_paths(job_id)
started_at = now_iso()
initial = {
"ok": True,
"action": "gc remote status",
"providerId": PROVIDER_ID,
"jobId": job_id,
"status": "running",
"kind": "hwlab-registry-retention-gc" if mode == "retention" else "hwlab-registry-garbage-collect",
"mode": mode,
"startedAt": started_at,
"statePath": paths["state"],
"logPath": paths["log"],
"options": OPTIONS,
}
write_json_atomic(paths["state"], initial)
pid = os.fork()
if pid != 0:
return {
"status": "started",
"reclaimedBytes": None,
"commandOutput": {
"jobId": job_id,
"pid": pid,
"statePath": paths["state"],
"logPath": paths["log"],
"statusCommand": "bun scripts/cli.ts gc remote %s status --job-id %s" % (PROVIDER_ID, job_id),
"message": "registry retention GC is running as a detached remote job",
},
}
try:
os.setsid()
except Exception:
pass
try:
devnull = os.open(os.devnull, os.O_RDONLY)
os.dup2(devnull, 0)
os.close(devnull)
except Exception:
pass
try:
log_handle = open(paths["log"], "a", encoding="utf-8", buffering=1)
os.dup2(log_handle.fileno(), 1)
os.dup2(log_handle.fileno(), 2)
except Exception:
log_handle = None
try:
print("[%s] starting HWLAB registry %s job %s" % (now_iso(), mode, job_id), flush=True)
result = execute_registry_retention() if mode == "retention" else execute_registry_garbage_collect_only()
payload = dict(initial)
payload.update({
"status": "succeeded",
"finishedAt": now_iso(),
"result": result,
"diskAfter": df_snapshot(),
"clusterAfter": cluster_preflight(),
})
write_json_atomic(paths["state"], payload)
print("[%s] completed HWLAB registry %s job %s" % (now_iso(), mode, job_id), flush=True)
os._exit(0)
except Exception as exc:
payload = dict(initial)
payload.update({
"ok": False,
"status": "failed",
"finishedAt": now_iso(),
"error": str(exc),
"diskAfter": df_snapshot(),
"clusterAfter": cluster_preflight(),
})
try:
write_json_atomic(paths["state"], payload)
except Exception:
pass
print("[%s] failed HWLAB registry %s job %s: %s" % (now_iso(), mode, job_id, exc), flush=True)
os._exit(1)
finally:
try:
if log_handle:
log_handle.close()
except Exception:
pass