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