feat: add NC01 registry retention GC
This commit is contained in:
@@ -154,6 +154,39 @@ gc:
|
||||
remote:
|
||||
targets:
|
||||
NC01:
|
||||
registryRetention:
|
||||
enabled: true
|
||||
configRef: config/hwlab-node-control-plane.yaml#nodes.NC01.registry
|
||||
repositoryPrefixes:
|
||||
- hwlab/
|
||||
- agentrun/
|
||||
- pikaoa/
|
||||
- selfmedia/
|
||||
- unidesk/
|
||||
protectedRepositoryPrefixes:
|
||||
- hwlab/cache/
|
||||
- pikaoa/cache-
|
||||
protectedTags:
|
||||
- latest
|
||||
- 16-alpine
|
||||
- 20-bookworm-slim
|
||||
- node22-alpine-v1
|
||||
- node22-alpine-bun-v1
|
||||
- sidecar
|
||||
deletableTagPattern: ^[0-9a-f]{7,64}$
|
||||
keepPerRepo: 20
|
||||
minAgeHours: 48
|
||||
previewLimit: 20
|
||||
estimateBlobLimit: 5000
|
||||
automatic:
|
||||
enabled: false
|
||||
maxDeleteManifestsPerRun: 100
|
||||
requireIdleNamespaces:
|
||||
- devops-infra
|
||||
- agentrun-ci
|
||||
- hwlab-ci
|
||||
- pikaoa-ci
|
||||
- selfmedia-ci
|
||||
hostDockerGc:
|
||||
dockerJsonLogs:
|
||||
enabled: false
|
||||
@@ -332,6 +365,22 @@ gc:
|
||||
includeHostContainerdCache: false
|
||||
includeLocalPathOrphans: false
|
||||
includeKubernetesObjectRetention: true
|
||||
pvcAttribution:
|
||||
namespaces:
|
||||
- agentrun-ci
|
||||
- agentrun-v02
|
||||
- devops-infra
|
||||
- hwlab-ci
|
||||
- pikaoa-ci
|
||||
- selfmedia-ci
|
||||
candidateNamespaces:
|
||||
- agentrun-ci
|
||||
- hwlab-ci
|
||||
hwlabNode: NC01
|
||||
hwlabLane: v03
|
||||
agentrunNode: NC01
|
||||
agentrunLane: nc01-v02
|
||||
limit: 80
|
||||
JD01:
|
||||
hostDockerGc:
|
||||
dockerJsonLogs:
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
- 按目标节点 `kubernetesObjectRetention` YAML 规划和异步回收 Kubernetes 历史对象;
|
||||
- `plan` 与手动、自动 `run` 必须复用同一个 fail-closed 筛选器;
|
||||
- 表格形状、时间或分组无法识别时保护对象并停止删除。
|
||||
- `gc remote <providerId> plan --include-registry`:
|
||||
- 从 `config/unidesk-cli.yaml#gc.remote.targets.<providerId>.registryRetention` 读取留存策略;
|
||||
- 通过显式 `configRef` 读取 node-local registry 的 endpoint、Deployment、namespace 和 PVC;
|
||||
- 保护当前 workload tag/digest refs、近期 tag、每仓库最新 tag、保护仓库和 digest closure;
|
||||
- 输出候选 manifest、保护原因、估算收益和 typed diagnosis;
|
||||
- 不支持的目标、PVC 未绑定、存储布局不可读或 workload 形状漂移时必须 fail closed。
|
||||
- `gc remote <providerId> run --confirm --include-registry`:
|
||||
- 作为异步 job 返回,并通过 `gc remote <providerId> status --job-id <id>` 短查询;
|
||||
- 只通过 Registry API 删除 plan 中不受保护的 manifest;
|
||||
- 对 YAML 声明的 Registry Deployment 缩容后运行官方 `registry garbage-collect`;
|
||||
- PVC 只挂载给 GC Pod,不删除 PVC、PV、blob 目录、containerd 或 k3s storage;
|
||||
- 无论成功或失败都恢复原副本数,并复核 Deployment readiness 与 `/v2/` endpoint。
|
||||
- `--include-tool-caches`:本机和远端都必须是显式 opt-in,只清理固定 allowlist 的可重建 npm/npx/Bun 缓存;不得扩大成清理 `~/.npm`、`~/.bun`、`node_modules`、auth/config 或运行面依赖。
|
||||
|
||||
所有成功和失败输出都必须是 JSON。`plan` 必须标记 `dryRun=true`、`mutation=false`;`run` 必须要求 `--confirm` 并报告 `diskBefore`、`diskAfter`、`summary`、`results` 和 `protected`。远端 GC 可用 `--target-use-percent N` 显式表达目标根盘水位;`summary.target` 必须给出目标所需释放量、候选估算、预计水位、缺口和 `safeStop` 决策,避免靠人工心算判断是否应该继续扩大清理范围。
|
||||
|
||||
@@ -65,7 +65,9 @@ def ci_storage_snapshot():
|
||||
hwlab_node = config_str(PVC_CONFIG, "hwlabNode", PROVIDER_ID)
|
||||
hwlab_lane = config_str(PVC_CONFIG, "hwlabLane", "v03")
|
||||
agentrun_node = config_str(PVC_CONFIG, "agentrunNode", PROVIDER_ID)
|
||||
agentrun_lane = config_str(PVC_CONFIG, "agentrunLane", "v02")
|
||||
agentrun_lane = config_str(PVC_CONFIG, "agentrunLane", "")
|
||||
if not agentrun_lane:
|
||||
raise RuntimeError("gc.remote.targets.%s.pvcAttribution.agentrunLane must be declared in YAML" % PROVIDER_ID)
|
||||
limit = config_int(PVC_CONFIG, "limit", int(OPTIONS.get("limit") or 50), minimum=1, maximum=5000)
|
||||
pv_data = kubectl_json(["get", "pv"], 30) or {}
|
||||
pvc_data = kubectl_json(["get", "pvc", "-A"], 30) or {}
|
||||
|
||||
@@ -1,21 +1,100 @@
|
||||
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 registry_config_list(key, default=None):
|
||||
return config_list(REGISTRY_CONFIG, key, default or [])
|
||||
|
||||
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 registry_config_str(key, default=""):
|
||||
return config_str(REGISTRY_CONFIG, key, default)
|
||||
|
||||
def wait_no_active_hwlab_ci(timeout=180):
|
||||
def registry_spec():
|
||||
value = REGISTRY_CONFIG.get("resolvedRegistry") if isinstance(REGISTRY_CONFIG, dict) else None
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def registry_identity():
|
||||
spec = registry_spec()
|
||||
mode = config_str(spec, "mode", "host-path" if PROVIDER_ID.upper() == "G14" else "")
|
||||
namespace = config_str(spec, "namespace", "hwlab-ci" if PROVIDER_ID.upper() == "G14" else "")
|
||||
deployment = config_str(spec, "deploymentName", "hwlab-registry" if PROVIDER_ID.upper() == "G14" else "")
|
||||
pvc = config_str(spec, "pvcName", "")
|
||||
endpoint = config_str(spec, "endpoint", "127.0.0.1:5000")
|
||||
image = config_str(spec, "image", "registry:2.8.3")
|
||||
return {
|
||||
"mode": mode,
|
||||
"namespace": namespace,
|
||||
"deploymentName": deployment,
|
||||
"pvcName": pvc,
|
||||
"endpoint": endpoint,
|
||||
"image": image,
|
||||
"configRef": registry_config_str("configRef", "") if REGISTRY_CONFIG else ("legacy:G14" if PROVIDER_ID.upper() == "G14" else None),
|
||||
}
|
||||
|
||||
REGISTRY_ROOT = "/var/lib/hwlab/registry"
|
||||
REGISTRY_REPOSITORY_ROOT = os.path.join(REGISTRY_ROOT, "docker/registry/v2/repositories")
|
||||
|
||||
def configure_registry_storage():
|
||||
global REGISTRY_ROOT, REGISTRY_REPOSITORY_ROOT
|
||||
identity = registry_identity()
|
||||
if REGISTRY_CONFIG and not config_bool(REGISTRY_CONFIG, "enabled", False):
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-retention-disabled", "Registry retention is disabled by YAML", configRef=identity.get("configRef"))}
|
||||
if identity.get("mode") == "host-path" and PROVIDER_ID.upper() == "G14":
|
||||
REGISTRY_ROOT = "/var/lib/hwlab/registry"
|
||||
REGISTRY_REPOSITORY_ROOT = os.path.join(REGISTRY_ROOT, "docker/registry/v2/repositories")
|
||||
return {"ok": True, "identity": identity, "storagePath": REGISTRY_ROOT, "storageKind": "host-path"}
|
||||
if identity.get("mode") != "k8s-workload":
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-target-mode-unsupported", "Registry retention requires a YAML-declared k8s-workload registry", mode=identity.get("mode"), configRef=identity.get("configRef"))}
|
||||
required = ["namespace", "deploymentName", "pvcName", "endpoint"]
|
||||
missing = [key for key in required if not identity.get(key)]
|
||||
if missing:
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-target-config-incomplete", "Registry configRef is missing required workload fields", missingFields=missing, configRef=identity.get("configRef"))}
|
||||
pvc = kubectl_json(["-n", identity["namespace"], "get", "pvc", identity["pvcName"]], 20)
|
||||
if not pvc:
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-pvc-unavailable", "Registry PVC could not be read", namespace=identity["namespace"], pvcName=identity["pvcName"], configRef=identity.get("configRef"))}
|
||||
volume_name = str(((pvc.get("spec") or {}).get("volumeName")) or "")
|
||||
if not volume_name or str(((pvc.get("status") or {}).get("phase")) or "") != "Bound":
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-pvc-not-bound", "Registry PVC must be Bound before retention planning", namespace=identity["namespace"], pvcName=identity["pvcName"], volumeName=volume_name or None)}
|
||||
pv = kubectl_json(["get", "pv", volume_name], 20)
|
||||
spec = (pv or {}).get("spec") or {}
|
||||
storage_path = str(((spec.get("local") or {}).get("path")) or ((spec.get("hostPath") or {}).get("path")) or "")
|
||||
if not storage_path.startswith("/"):
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-pv-storage-path-unsupported", "Registry PV does not expose a node-local absolute storage path", volumeName=volume_name, storageClass=spec.get("storageClassName"))}
|
||||
storage_path = os.path.realpath(storage_path)
|
||||
repository_root = os.path.join(storage_path, "docker/registry/v2/repositories")
|
||||
if not os.path.isdir(repository_root):
|
||||
return {"ok": False, "diagnosis": registry_diagnosis("registry-storage-layout-unavailable", "Registry repository layout is not readable from the node-local PVC path", storagePath=storage_path, repositoryRoot=repository_root)}
|
||||
REGISTRY_ROOT = storage_path
|
||||
REGISTRY_REPOSITORY_ROOT = repository_root
|
||||
return {"ok": True, "identity": identity, "storagePath": storage_path, "storageKind": "pvc", "volumeName": volume_name}
|
||||
|
||||
def registry_diagnosis(code, message, **details):
|
||||
return {"code": code, "message": message, "providerId": PROVIDER_ID, **details}
|
||||
|
||||
def active_registry_writes():
|
||||
automatic = REGISTRY_CONFIG.get("automatic") if isinstance(REGISTRY_CONFIG.get("automatic"), dict) else {}
|
||||
namespaces = config_list(automatic, "requireIdleNamespaces", [])
|
||||
rows = []
|
||||
commands = []
|
||||
for namespace in namespaces:
|
||||
result = kctl(["-n", namespace, "get", "pipelinerun,taskrun", "-o", "json"], 20)
|
||||
commands.append(bounded(result))
|
||||
if result["exitCode"] != 0:
|
||||
return {"ok": False, "activeCount": None, "activePreview": rows, "commands": commands, "namespace": namespace}
|
||||
try:
|
||||
items = json.loads(result.get("stdout") or "{}").get("items") or []
|
||||
except Exception:
|
||||
return {"ok": False, "activeCount": None, "activePreview": rows, "commands": commands, "namespace": namespace, "error": "invalid-kubernetes-json"}
|
||||
for item in items:
|
||||
conditions = ((item.get("status") or {}).get("conditions")) or []
|
||||
terminal = any(condition.get("type") == "Succeeded" and condition.get("status") in set(["True", "False"]) for condition in conditions)
|
||||
if not terminal:
|
||||
meta = item.get("metadata") or {}
|
||||
rows.append("%s/%s/%s" % (namespace, item.get("kind") or "Unknown", meta.get("name") or "unknown"))
|
||||
return {"ok": True, "activeCount": len(rows), "activePreview": rows[:40], "commands": commands}
|
||||
|
||||
def wait_no_active_registry_writes(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:
|
||||
writes = active_registry_writes()
|
||||
last = {"writes": writes}
|
||||
if writes.get("ok") and int(writes.get("activeCount") or 0) == 0:
|
||||
return {"ok": True, "last": last}
|
||||
time.sleep(5)
|
||||
return {"ok": False, "last": last}
|
||||
@@ -33,14 +112,21 @@ 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)
|
||||
script = """{
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get deploy,sts,ds -A -o jsonpath='{range .items[*]}{range .spec.template.spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.template.spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pod -A --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pod -A --field-selector=status.phase=Pending -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
|
||||
} | sort -u"""
|
||||
result = command(["sh", "-lc", script], 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/"):
|
||||
endpoint = registry_identity().get("endpoint") or ""
|
||||
prefix = endpoint.rstrip("/") + "/"
|
||||
if not image.startswith(prefix):
|
||||
continue
|
||||
ref = image.split("127.0.0.1:5000/", 1)[1]
|
||||
ref = image[len(prefix):]
|
||||
if "@sha256:" in ref:
|
||||
repo, digest = ref.split("@", 1)
|
||||
refs.add((repo, "@" + digest))
|
||||
@@ -51,7 +137,8 @@ def workload_image_refs():
|
||||
return refs, digests, bounded(result)
|
||||
|
||||
def registry_request(method, path, headers=None, timeout=20):
|
||||
url = "http://127.0.0.1:5000" + path
|
||||
endpoint = registry_identity().get("endpoint") or ""
|
||||
url = (endpoint if endpoint.startswith("http://") or endpoint.startswith("https://") else "http://" + endpoint) + path
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
body = response.read()
|
||||
@@ -132,7 +219,11 @@ def registry_revision_rows():
|
||||
return rows
|
||||
|
||||
def registry_retention_repo(repo):
|
||||
return repo.startswith("hwlab/hwlab-") or repo.startswith("hwlab/cache/hwlab-")
|
||||
prefixes = registry_config_list("repositoryPrefixes", ["hwlab/hwlab-"])
|
||||
return any(repo.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
def registry_protected_repo(repo):
|
||||
return any(repo.startswith(prefix) for prefix in registry_config_list("protectedRepositoryPrefixes", ["hwlab/cache/"]))
|
||||
|
||||
def registry_digest_hex(digest):
|
||||
if not isinstance(digest, str) or not digest.startswith("sha256:"):
|
||||
@@ -168,23 +259,25 @@ def registry_manifest_json(digest):
|
||||
def registry_manifest_refs(digest):
|
||||
manifest = registry_manifest_json(digest)
|
||||
if not isinstance(manifest, dict):
|
||||
return set()
|
||||
refs = set()
|
||||
return {"manifests": set(), "blobs": set()}
|
||||
manifests = set()
|
||||
blobs = 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)
|
||||
blobs.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)
|
||||
blobs.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
|
||||
manifests.add(item_digest)
|
||||
blobs.add(item_digest)
|
||||
return {"manifests": manifests, "blobs": blobs}
|
||||
|
||||
def registry_digest_closure(seed):
|
||||
def registry_manifest_closure(seed):
|
||||
seen = set()
|
||||
stack = list(seed)
|
||||
while stack:
|
||||
@@ -192,11 +285,18 @@ def registry_digest_closure(seed):
|
||||
if digest in seen or registry_digest_hex(digest) is None:
|
||||
continue
|
||||
seen.add(digest)
|
||||
for child in registry_manifest_refs(digest):
|
||||
for child in registry_manifest_refs(digest).get("manifests") or []:
|
||||
if child not in seen:
|
||||
stack.append(child)
|
||||
return seen
|
||||
|
||||
def registry_blob_closure(seed):
|
||||
manifests = registry_manifest_closure(seed)
|
||||
blobs = set(manifests)
|
||||
for digest in manifests:
|
||||
blobs.update(registry_manifest_refs(digest).get("blobs") or [])
|
||||
return blobs
|
||||
|
||||
def registry_blob_size(digest):
|
||||
path = registry_blob_data_path(digest)
|
||||
if path is None or not os.path.isfile(path):
|
||||
@@ -207,14 +307,31 @@ def registry_blob_size(digest):
|
||||
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)
|
||||
deleted = registry_blob_closure(delete_manifest_digests)
|
||||
kept = registry_blob_closure(kept_manifest_digests)
|
||||
reclaim = sorted(deleted - kept)
|
||||
limit = config_int(REGISTRY_CONFIG, "estimateBlobLimit", 5000, minimum=1, maximum=50000)
|
||||
sampled = reclaim[:limit]
|
||||
return {
|
||||
"bytes": sum(registry_blob_size(digest) for digest in sampled),
|
||||
"blobCount": len(reclaim),
|
||||
"sampledBlobCount": len(sampled),
|
||||
"truncated": len(sampled) < len(reclaim),
|
||||
"basis": "allocated-bytes-lower-bound" if len(sampled) < len(reclaim) else "allocated-bytes",
|
||||
}
|
||||
|
||||
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)
|
||||
storage = configure_registry_storage()
|
||||
if not storage.get("ok"):
|
||||
return {"tagRows": [], "revisionRows": [], "deleteRows": [], "deleteRevisionRows": [], "diagnosis": storage.get("diagnosis"), "summary": {"configRef": registry_identity().get("configRef"), "registrySizeBytes": 0, "estimatedReclaimBytes": 0}}
|
||||
keep_per_repo = config_int(REGISTRY_CONFIG, "keepPerRepo", int(OPTIONS.get("registryKeepPerRepo") or 20), minimum=1, maximum=200)
|
||||
min_age_hours = config_float(REGISTRY_CONFIG, "minAgeHours", float(OPTIONS.get("registryMinAgeHours") or 48), minimum=0.0)
|
||||
protected_tags = set(registry_config_list("protectedTags", ["latest"]))
|
||||
tag_pattern = registry_config_str("deletableTagPattern", r"^[0-9a-f]{7,40}$")
|
||||
try:
|
||||
deletable_tag = re.compile(tag_pattern)
|
||||
except re.error as exc:
|
||||
return {"tagRows": [], "revisionRows": [], "deleteRows": [], "deleteRevisionRows": [], "diagnosis": registry_diagnosis("registry-tag-pattern-invalid", "Registry deletableTagPattern is invalid", configRef=registry_identity().get("configRef"), error=str(exc)), "summary": {"configRef": registry_identity().get("configRef"), "registrySizeBytes": 0, "estimatedReclaimBytes": 0}}
|
||||
cutoff = time.time() - min_age_hours * 3600
|
||||
refs, digests, refs_command = workload_image_refs()
|
||||
rows = registry_tag_rows()
|
||||
@@ -232,7 +349,7 @@ def plan_registry_retention():
|
||||
keep_reasons[key] = "latest-per-repo"
|
||||
for row in items:
|
||||
key = (row["repo"], row["tag"])
|
||||
if row["tag"] in REGISTRY_PROTECTED_TAGS:
|
||||
if row["tag"] in protected_tags:
|
||||
keep.add(key)
|
||||
keep_reasons[key] = "protected-tag"
|
||||
if key in refs:
|
||||
@@ -241,7 +358,7 @@ def plan_registry_retention():
|
||||
if row["digest"] in digests:
|
||||
keep.add(key)
|
||||
keep_reasons[key] = "workload-digest-ref"
|
||||
if row["repo"].startswith("hwlab/cache/"):
|
||||
if registry_protected_repo(row["repo"]):
|
||||
keep.add(key)
|
||||
keep_reasons[key] = "cache-repo"
|
||||
if row["mtime"] >= cutoff:
|
||||
@@ -256,8 +373,9 @@ def plan_registry_retention():
|
||||
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
|
||||
and registry_retention_repo(row["repo"])
|
||||
and not registry_protected_repo(row["repo"])
|
||||
and deletable_tag.match(row["tag"]) is not None
|
||||
)
|
||||
if should_delete:
|
||||
delete_rows.append(row)
|
||||
@@ -266,9 +384,18 @@ def plan_registry_retention():
|
||||
kept_count += 1
|
||||
kept_digests.add(row["digest"])
|
||||
keep_by_repo[row["repo"]] = keep_by_repo.get(row["repo"], 0) + 1
|
||||
shared_digest_rows = [row for row in delete_rows if row["digest"] in kept_digests]
|
||||
if shared_digest_rows:
|
||||
delete_rows = [row for row in delete_rows if row["digest"] not in kept_digests]
|
||||
kept_count += len(shared_digest_rows)
|
||||
delete_by_repo = {}
|
||||
for row in delete_rows:
|
||||
delete_by_repo[row["repo"]] = delete_by_repo.get(row["repo"], 0) + 1
|
||||
for row in shared_digest_rows:
|
||||
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)
|
||||
protected_digests = registry_manifest_closure(protected_digests)
|
||||
delete_revision_rows = []
|
||||
revision_delete_by_repo = {}
|
||||
for row in revision_rows:
|
||||
@@ -288,7 +415,7 @@ def plan_registry_retention():
|
||||
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
|
||||
registry_size = None
|
||||
estimate = estimate_registry_reclaim(delete_revision_digests, kept_revision_digests)
|
||||
return {
|
||||
"tagRows": rows,
|
||||
@@ -296,6 +423,13 @@ def plan_registry_retention():
|
||||
"deleteRows": delete_rows,
|
||||
"deleteRevisionRows": delete_revision_rows,
|
||||
"summary": {
|
||||
"configRef": registry_identity().get("configRef"),
|
||||
"storageKind": storage.get("storageKind"),
|
||||
"storagePath": storage.get("storagePath"),
|
||||
"namespace": registry_identity().get("namespace"),
|
||||
"deploymentName": registry_identity().get("deploymentName"),
|
||||
"pvcName": registry_identity().get("pvcName"),
|
||||
"endpoint": registry_identity().get("endpoint"),
|
||||
"totalTags": len(rows),
|
||||
"totalRevisions": len(revision_rows),
|
||||
"repoCount": len(by_repo),
|
||||
@@ -306,37 +440,58 @@ def plan_registry_retention():
|
||||
"protectedDigestClosure": len(protected_digests),
|
||||
"keptTags": kept_count,
|
||||
"deleteTags": len(delete_rows),
|
||||
"sharedDigestProtectedTags": len(shared_digest_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,
|
||||
"estimatedReclaimBytes": estimate.get("bytes"),
|
||||
"estimatedReclaimBlobCount": estimate.get("blobCount"),
|
||||
"estimatedReclaimSampledBlobCount": estimate.get("sampledBlobCount"),
|
||||
"estimatedReclaimTruncated": estimate.get("truncated"),
|
||||
"estimatedReclaimBasis": estimate.get("basis"),
|
||||
},
|
||||
"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)
|
||||
identity = registry_identity()
|
||||
namespace = identity.get("namespace")
|
||||
deployment_name = identity.get("deploymentName")
|
||||
dep = kubectl_json(["-n", namespace, "get", "deploy", deployment_name], 20)
|
||||
if not dep:
|
||||
return {"ok": False, "reason": "registry-deployment-missing"}
|
||||
return {"ok": False, "reason": "registry-deployment-missing", "diagnosis": registry_diagnosis("registry-deployment-missing", "Registry Deployment could not be read", namespace=namespace, deploymentName=deployment_name)}
|
||||
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_storage = any(
|
||||
vol.get("name") == "storage" and (
|
||||
((vol.get("hostPath") or {}).get("path") == REGISTRY_ROOT)
|
||||
or ((vol.get("persistentVolumeClaim") or {}).get("claimName") == identity.get("pvcName"))
|
||||
)
|
||||
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)
|
||||
expected_image = str(identity.get("image") or "")
|
||||
image_matches = image.startswith("registry:") if PROVIDER_ID.upper() == "G14" and not REGISTRY_CONFIG else image == expected_image
|
||||
ok = bool(has_storage and has_mount and image_matches and spec.get("hostNetwork") is True)
|
||||
return {
|
||||
"ok": ok,
|
||||
"reason": "ok" if ok else "unexpected-registry-deployment-shape",
|
||||
"diagnosis": None if ok else registry_diagnosis("registry-deployment-shape-mismatch", "Registry Deployment does not match the YAML configRef", namespace=namespace, deploymentName=deployment_name, configRef=identity.get("configRef")),
|
||||
"namespace": namespace,
|
||||
"deploymentName": deployment_name,
|
||||
"pvcName": identity.get("pvcName"),
|
||||
"image": image,
|
||||
"expectedImage": expected_image,
|
||||
"hostNetwork": spec.get("hostNetwork"),
|
||||
"hasExpectedHostPath": has_host_path,
|
||||
"hasExpectedStorage": has_storage,
|
||||
"hasExpectedMount": has_mount,
|
||||
"replicas": (dep.get("spec") or {}).get("replicas"),
|
||||
"readyReplicas": (dep.get("status") or {}).get("readyReplicas"),
|
||||
@@ -355,10 +510,13 @@ def patch_cronjob_suspend(name, suspend):
|
||||
return kctl(["-n", "hwlab-ci", "patch", "cronjob", name, "--type=merge", "-p", payload], 30)
|
||||
|
||||
def wait_registry_pod_count(target, timeout=90):
|
||||
identity = registry_identity()
|
||||
namespace = identity.get("namespace")
|
||||
deployment_name = identity.get("deploymentName")
|
||||
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)
|
||||
result = kctl(["-n", namespace, "get", "pods", "-l", "app.kubernetes.io/name=%s" % deployment_name, "--no-headers"], 20)
|
||||
last = bounded(result)
|
||||
lines = [line for line in (result.get("stdout") or "").splitlines() if line.strip()]
|
||||
active = []
|
||||
@@ -374,10 +532,11 @@ def wait_registry_pod_count(target, timeout=90):
|
||||
return {"ok": False, "lines": [], "last": last}
|
||||
|
||||
def wait_pod_terminal(name, timeout=900):
|
||||
namespace = registry_identity().get("namespace")
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
data = kubectl_json(["-n", "hwlab-ci", "get", "pod", name], 20)
|
||||
data = kubectl_json(["-n", namespace, "get", "pod", name], 20)
|
||||
if data:
|
||||
phase = ((data.get("status") or {}).get("phase")) or ""
|
||||
last = {"phase": phase}
|
||||
@@ -388,7 +547,108 @@ def wait_pod_terminal(name, timeout=900):
|
||||
time.sleep(3)
|
||||
return {"ok": False, "phase": "Timeout", "last": last}
|
||||
|
||||
def registry_gc_pod_overrides(identity):
|
||||
storage = {"name": "storage", "persistentVolumeClaim": {"claimName": identity.get("pvcName")}}
|
||||
return {
|
||||
"apiVersion": "v1",
|
||||
"spec": {
|
||||
"restartPolicy": "Never",
|
||||
"containers": [{
|
||||
"name": "registry-gc",
|
||||
"image": identity.get("image"),
|
||||
"command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
|
||||
"volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
|
||||
}],
|
||||
"volumes": [storage],
|
||||
},
|
||||
}
|
||||
|
||||
def execute_k8s_registry_maintenance(mode):
|
||||
storage = configure_registry_storage()
|
||||
if not storage.get("ok"):
|
||||
raise RuntimeError("registry storage preflight failed: %s" % ((storage.get("diagnosis") or {}).get("code") or "unknown"))
|
||||
identity = storage.get("identity") or registry_identity()
|
||||
deployment = registry_deployment_preflight()
|
||||
if not deployment.get("ok"):
|
||||
raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
|
||||
plan = plan_registry_retention()
|
||||
if plan.get("diagnosis"):
|
||||
raise RuntimeError("registry retention plan failed: %s" % ((plan.get("diagnosis") or {}).get("code") or "unknown"))
|
||||
delete_manifests = plan.get("deleteManifestsByRepo") or {}
|
||||
if mode == "retention" and not delete_manifests:
|
||||
return {"reclaimedBytes": 0, "commandOutput": {"message": "no unprotected registry manifests matched YAML retention", "registryPlan": plan.get("summary")}}
|
||||
idle = wait_no_active_registry_writes(180)
|
||||
if not idle.get("ok"):
|
||||
raise RuntimeError("refusing registry maintenance because configured writer namespaces are not idle")
|
||||
namespace = identity.get("namespace")
|
||||
deployment_name = identity.get("deploymentName")
|
||||
before = du_size(REGISTRY_ROOT, 60) or 0
|
||||
gc_name = "%s-registry-gc-%s" % (re.sub(r"[^a-z0-9]+", "-", PROVIDER_ID.lower()).strip("-") or "node", int(time.time()))
|
||||
steps = [{"step": "writer-idle-preflight", "result": idle}]
|
||||
deleted_manifests = []
|
||||
try:
|
||||
if mode == "retention":
|
||||
automatic = REGISTRY_CONFIG.get("automatic") if isinstance(REGISTRY_CONFIG.get("automatic"), dict) else {}
|
||||
max_delete = config_int(automatic, "maxDeleteManifestsPerRun", 100, minimum=1, maximum=1000)
|
||||
selected = []
|
||||
for repo in sorted(delete_manifests.keys()):
|
||||
for digest in sorted(delete_manifests.get(repo) or []):
|
||||
selected.append((repo, digest))
|
||||
selected = selected[:max_delete]
|
||||
for repo, digest in selected:
|
||||
encoded_repo = "/".join(urllib.parse.quote(part, safe="") for part in repo.split("/"))
|
||||
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, application/vnd.oci.image.index.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", namespace, "scale", "deploy", deployment_name, "--replicas=0"], 60)
|
||||
steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
|
||||
if scale_down["exitCode"] != 0 or not wait_registry_pod_count(0, 120).get("ok"):
|
||||
raise RuntimeError("registry workload did not scale down")
|
||||
overrides = registry_gc_pod_overrides(identity)
|
||||
run_gc = kctl(["-n", namespace, "run", gc_name, "--restart=Never", "--image=%s" % identity.get("image"), "--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)
|
||||
logs = kctl(["-n", namespace, "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", namespace, "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
|
||||
steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
|
||||
scale_up = kctl(["-n", namespace, "scale", "deploy", deployment_name, "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
|
||||
steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
|
||||
rollout = kctl(["-n", namespace, "rollout", "status", "deploy/%s" % deployment_name, "--timeout=180s"], 200)
|
||||
steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
|
||||
readiness = registry_deployment_preflight()
|
||||
if not readiness.get("ok") or int(readiness.get("readyReplicas") or 0) < 1:
|
||||
raise RuntimeError("registry workload did not recover readiness")
|
||||
probe = registry_request("GET", "/v2/", timeout=20)
|
||||
if int(probe.get("status") or 0) != 200:
|
||||
raise RuntimeError("registry endpoint did not recover readiness")
|
||||
after = du_size(REGISTRY_ROOT, 60) or 0
|
||||
return {
|
||||
"reclaimedBytes": max(0, before - after),
|
||||
"commandOutput": {
|
||||
"registryPlan": plan.get("summary"),
|
||||
"deletedManifestCount": len(deleted_manifests),
|
||||
"diskBeforeBytes": before,
|
||||
"diskAfterBytes": after,
|
||||
"readiness": readiness,
|
||||
"steps": steps[-12:],
|
||||
},
|
||||
}
|
||||
|
||||
def execute_registry_retention():
|
||||
if registry_identity().get("mode") == "k8s-workload":
|
||||
return execute_k8s_registry_maintenance("retention")
|
||||
if PROVIDER_ID.upper() != "G14":
|
||||
raise RuntimeError("HWLAB registry retention is only supported on G14")
|
||||
deployment = registry_deployment_preflight()
|
||||
@@ -516,6 +776,8 @@ def execute_registry_retention():
|
||||
}
|
||||
|
||||
def execute_registry_garbage_collect_only():
|
||||
if registry_identity().get("mode") == "k8s-workload":
|
||||
return execute_k8s_registry_maintenance("garbage-collect")
|
||||
if PROVIDER_ID.upper() != "G14":
|
||||
raise RuntimeError("HWLAB registry garbage-collect is only supported on G14")
|
||||
deployment = registry_deployment_preflight()
|
||||
@@ -591,7 +853,8 @@ def execute_registry_garbage_collect_only():
|
||||
}
|
||||
|
||||
def start_registry_retention_job(mode):
|
||||
job_id = "g14-registry-%s-%s" % (int(time.time()), os.getpid())
|
||||
provider_slug = re.sub(r"[^a-z0-9]+", "-", PROVIDER_ID.lower()).strip("-") or "provider"
|
||||
job_id = "%s-registry-%s-%s" % (provider_slug, int(time.time()), os.getpid())
|
||||
paths = job_paths(job_id)
|
||||
started_at = now_iso()
|
||||
initial = {
|
||||
@@ -600,7 +863,7 @@ def start_registry_retention_job(mode):
|
||||
"providerId": PROVIDER_ID,
|
||||
"jobId": job_id,
|
||||
"status": "running",
|
||||
"kind": "hwlab-registry-retention-gc" if mode == "retention" else "hwlab-registry-garbage-collect",
|
||||
"kind": "registry-retention-gc" if mode == "retention" else "registry-garbage-collect",
|
||||
"mode": mode,
|
||||
"startedAt": started_at,
|
||||
"statePath": paths["state"],
|
||||
@@ -619,7 +882,7 @@ def start_registry_retention_job(mode):
|
||||
"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",
|
||||
"message": "registry maintenance is running as a detached remote job",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -640,7 +903,7 @@ def start_registry_retention_job(mode):
|
||||
except Exception:
|
||||
log_handle = None
|
||||
try:
|
||||
print("[%s] starting HWLAB registry %s job %s" % (now_iso(), mode, job_id), flush=True)
|
||||
print("[%s] starting 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({
|
||||
@@ -651,7 +914,7 @@ def start_registry_retention_job(mode):
|
||||
"clusterAfter": cluster_preflight(),
|
||||
})
|
||||
write_json_atomic(paths["state"], payload)
|
||||
print("[%s] completed HWLAB registry %s job %s" % (now_iso(), mode, job_id), flush=True)
|
||||
print("[%s] completed registry %s job %s" % (now_iso(), mode, job_id), flush=True)
|
||||
os._exit(0)
|
||||
except Exception as exc:
|
||||
payload = dict(initial)
|
||||
@@ -667,7 +930,7 @@ def start_registry_retention_job(mode):
|
||||
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)
|
||||
print("[%s] failed registry %s job %s: %s" % (now_iso(), mode, job_id, exc), flush=True)
|
||||
os._exit(1)
|
||||
finally:
|
||||
try:
|
||||
|
||||
+110
-26
@@ -27,6 +27,7 @@ LOCAL_PATH_CONFIG = REMOTE_TARGET.get("localPathStorage") if isinstance(REMOTE_T
|
||||
KUBERNETES_RETENTION_CONFIG = REMOTE_TARGET.get("kubernetesObjectRetention") if isinstance(REMOTE_TARGET.get("kubernetesObjectRetention"), dict) else {}
|
||||
POLICY_TIMER_CONFIG = REMOTE_TARGET.get("policyTimer") if isinstance(REMOTE_TARGET.get("policyTimer"), dict) else {}
|
||||
HOST_DOCKER_GC_CONFIG = REMOTE_TARGET.get("hostDockerGc") if isinstance(REMOTE_TARGET.get("hostDockerGc"), dict) else {}
|
||||
REGISTRY_CONFIG = REMOTE_TARGET.get("registryRetention") if isinstance(REMOTE_TARGET.get("registryRetention"), dict) else {}
|
||||
POLICY_RUNNER_SOURCE = base64.b64decode("__UNIDESK_GC_REMOTE_POLICY_RUNNER_BASE64__").decode("utf-8")
|
||||
|
||||
TMP_PREFIX_ALLOWLIST = [
|
||||
@@ -88,18 +89,6 @@ TOOL_CACHE_ALLOWLIST = [
|
||||
},
|
||||
]
|
||||
|
||||
REGISTRY_REPOSITORY_ROOT = "/var/lib/hwlab/registry/docker/registry/v2/repositories"
|
||||
REGISTRY_ROOT = "/var/lib/hwlab/registry"
|
||||
REGISTRY_PROTECTED_TAGS = set([
|
||||
"latest",
|
||||
"16-alpine",
|
||||
"20-bookworm-slim",
|
||||
"node22-alpine-v1",
|
||||
"node22-alpine-bun-v1",
|
||||
"sidecar",
|
||||
"1b99888d3dae",
|
||||
])
|
||||
|
||||
EXPECTED_G14_NODE = "ubuntu-rog-zephyrus-g14-ga401iv-ga401iv"
|
||||
REMOTE_GC_JOB_DIR = "/tmp/unidesk-gc-remote/jobs"
|
||||
REMOTE_GROWTH_SNAPSHOT_DIR = "/tmp/unidesk-gc-remote/growth-snapshots"
|
||||
@@ -1343,19 +1332,31 @@ def collect_candidates(observed_at):
|
||||
"estimatedReclaimBytes": size,
|
||||
"action": {"op": "rm-recursive", "allowlist": "tmp-prefix"},
|
||||
})
|
||||
if OPTIONS.get("hwlabRegistry", False):
|
||||
if OPTIONS.get("registry", False) or OPTIONS.get("hwlabRegistry", False):
|
||||
registry = plan_registry_retention()
|
||||
summary = registry.get("summary") or {}
|
||||
diagnosis = registry.get("diagnosis")
|
||||
delete_rows = registry.get("deleteRows") or []
|
||||
delete_revision_rows = registry.get("deleteRevisionRows") or []
|
||||
estimate = int(summary.get("estimatedReclaimBytes") or 0)
|
||||
if delete_rows or delete_revision_rows:
|
||||
if diagnosis:
|
||||
candidates.append({
|
||||
"id": "hwlab-registry:retention-gc",
|
||||
"kind": "hwlab-registry-retention-gc",
|
||||
"id": "registry:%s" % str(diagnosis.get("code") or "unsupported-target"),
|
||||
"kind": "registry-retention-diagnosis",
|
||||
"risk": "none",
|
||||
"description": str(diagnosis.get("message") or "Registry retention is unavailable for this target"),
|
||||
"sizeBytes": int(summary.get("registrySizeBytes") or 0),
|
||||
"estimatedReclaimBytes": 0,
|
||||
"diagnosis": diagnosis,
|
||||
"action": {"op": "none", "runAllowed": False},
|
||||
})
|
||||
elif delete_rows or delete_revision_rows:
|
||||
candidates.append({
|
||||
"id": "registry:retention-gc",
|
||||
"kind": "registry-retention-gc",
|
||||
"risk": "medium",
|
||||
"description": "Conservative HWLAB registry retention: keep current workload refs, retained tags and protected repos, delete stale manifest revisions, then run official registry garbage-collect",
|
||||
"path": REGISTRY_ROOT,
|
||||
"description": "Conservative registry retention: keep workload refs, recent tags, per-repository newest tags and digest closure, then run official registry garbage-collect",
|
||||
"path": summary.get("storagePath"),
|
||||
"sizeBytes": int(summary.get("registrySizeBytes") or 0),
|
||||
"estimatedReclaimBytes": estimate,
|
||||
"action": {
|
||||
@@ -1375,11 +1376,11 @@ def collect_candidates(observed_at):
|
||||
})
|
||||
elif bool(OPTIONS.get("registryGcOnly")) and int(summary.get("totalTags") or 0) > 0 and int(summary.get("deleteTags") or 0) == 0:
|
||||
candidates.append({
|
||||
"id": "hwlab-registry:garbage-collect-only",
|
||||
"kind": "hwlab-registry-garbage-collect",
|
||||
"id": "registry:garbage-collect-only",
|
||||
"kind": "registry-garbage-collect",
|
||||
"risk": "medium",
|
||||
"description": "Run official HWLAB registry garbage-collect without deleting additional tags; useful after a previously interrupted retention run",
|
||||
"path": REGISTRY_ROOT,
|
||||
"description": "Run official registry garbage-collect without deleting additional manifests",
|
||||
"path": summary.get("storagePath"),
|
||||
"sizeBytes": int(summary.get("registrySizeBytes") or 0),
|
||||
"estimatedReclaimBytes": 0,
|
||||
"action": {
|
||||
@@ -1636,9 +1637,11 @@ def execute(candidate):
|
||||
before = allocated_file_size(path)
|
||||
os.unlink(path)
|
||||
return {"reclaimedBytes": before}
|
||||
if kind == "hwlab-registry-retention-gc":
|
||||
if kind == "registry-retention-diagnosis":
|
||||
raise RuntimeError("registry retention is not runnable: %s" % ((candidate.get("diagnosis") or {}).get("code") or "unsupported-target"))
|
||||
if kind in set(["registry-retention-gc", "hwlab-registry-retention-gc"]):
|
||||
return start_registry_retention_job("retention")
|
||||
if kind == "hwlab-registry-garbage-collect":
|
||||
if kind in set(["registry-garbage-collect", "hwlab-registry-garbage-collect"]):
|
||||
return start_registry_retention_job("garbage-collect")
|
||||
raise RuntimeError("unsupported remote gc candidate kind: %s" % kind)
|
||||
|
||||
@@ -1737,7 +1740,7 @@ def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
"/var/lib/rancher/k3s/storage",
|
||||
"/var/lib/kubelet",
|
||||
"/var/lib/containerd",
|
||||
"/var/lib/hwlab unless --include-hwlab-registry is explicitly supplied",
|
||||
"registry storage unless --include-registry is explicitly supplied",
|
||||
"Kubernetes Deployments/StatefulSets/Secrets/PVCs/PVs",
|
||||
"HWLAB fixed source workspaces",
|
||||
"Docker images, containers and volumes",
|
||||
@@ -1747,7 +1750,7 @@ def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
"G14 run requires the expected native k3s node preflight before mutation.",
|
||||
"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.",
|
||||
"Registry retention is opt-in: it keeps workload tag/digest refs, all tags newer than the YAML 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.",
|
||||
],
|
||||
}
|
||||
@@ -1797,6 +1800,84 @@ def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
}
|
||||
return payload
|
||||
|
||||
def registry_plan_payload(observed_at):
|
||||
registry = plan_registry_retention()
|
||||
full_summary = registry.get("summary") or {}
|
||||
summary = full_summary if bool(OPTIONS.get("full")) else {
|
||||
key: full_summary.get(key)
|
||||
for key in [
|
||||
"configRef", "storageKind", "storagePath", "namespace", "deploymentName", "pvcName", "endpoint",
|
||||
"totalTags", "totalRevisions", "repoCount", "keepPerRepo", "minAgeHours", "protectedWorkloadRefs",
|
||||
"protectedDigestRefs", "protectedDigestClosure", "keptTags", "deleteTags", "deleteManifests",
|
||||
"deleteRevisions", "registrySizeBytes", "estimatedReclaimBytes", "estimatedReclaimBlobCount",
|
||||
"estimatedReclaimSampledBlobCount", "estimatedReclaimTruncated", "estimatedReclaimBasis",
|
||||
]
|
||||
}
|
||||
diagnosis = registry.get("diagnosis")
|
||||
preview_limit = min(
|
||||
config_int(REGISTRY_CONFIG, "previewLimit", 20, minimum=1, maximum=200),
|
||||
int(OPTIONS.get("limit") or 10),
|
||||
5 if not bool(OPTIONS.get("full")) else 200,
|
||||
)
|
||||
delete_rows = registry.get("deleteRows") or []
|
||||
delete_revision_rows = registry.get("deleteRevisionRows") or []
|
||||
automatic = REGISTRY_CONFIG.get("automatic") if isinstance(REGISTRY_CONFIG.get("automatic"), dict) else {}
|
||||
candidate = {
|
||||
"id": "registry:retention-gc",
|
||||
"kind": "registry-retention-gc",
|
||||
"risk": "medium",
|
||||
"estimatedReclaimBytes": int(summary.get("estimatedReclaimBytes") or 0),
|
||||
"action": {
|
||||
"op": "registry-retention-gc",
|
||||
"runAllowed": diagnosis is None,
|
||||
"deleteTags": summary.get("deleteTags"),
|
||||
"deleteManifests": summary.get("deleteManifests"),
|
||||
"deleteRevisions": summary.get("deleteRevisions"),
|
||||
},
|
||||
}
|
||||
if diagnosis:
|
||||
candidate.update({"kind": "registry-retention-diagnosis", "risk": "none", "diagnosis": diagnosis})
|
||||
return {
|
||||
"ok": diagnosis is None,
|
||||
"action": "gc remote plan",
|
||||
"providerId": PROVIDER_ID,
|
||||
"scope": "registry-retention",
|
||||
"dryRun": True,
|
||||
"mutation": False,
|
||||
"observedAt": observed_at,
|
||||
"configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.registryRetention" % PROVIDER_ID,
|
||||
"summary": summary,
|
||||
"candidates": [] if diagnosis else [candidate],
|
||||
"diagnosis": diagnosis,
|
||||
"preview": {
|
||||
"deleteTags": [
|
||||
{"repo": row.get("repo"), "tag": row.get("tag"), "digest": row.get("digest"), "mtimeIso": row.get("mtimeIso")}
|
||||
for row in delete_rows[:preview_limit]
|
||||
],
|
||||
"deleteRevisions": [
|
||||
{"repo": row.get("repo"), "digest": row.get("digest"), "mtimeIso": row.get("mtimeIso")}
|
||||
for row in delete_revision_rows[:preview_limit]
|
||||
],
|
||||
"limit": preview_limit,
|
||||
},
|
||||
"protected": {
|
||||
"reasons": ["workload-tag-ref", "workload-digest-ref", "digest-closure", "recent-tag", "latest-per-repo", "protected-tag", "protected-repository"],
|
||||
"workloadTagRefs": summary.get("protectedWorkloadRefs"),
|
||||
"workloadDigestRefs": summary.get("protectedDigestRefs"),
|
||||
"digestClosure": summary.get("protectedDigestClosure"),
|
||||
"keepPerRepo": summary.get("keepPerRepo"),
|
||||
"minAgeHours": summary.get("minAgeHours"),
|
||||
},
|
||||
"automaticPolicy": {
|
||||
"enabled": config_bool(automatic, "enabled", False),
|
||||
"maxDeleteManifestsPerRun": config_int(automatic, "maxDeleteManifestsPerRun", 100, minimum=1, maximum=1000),
|
||||
"requireIdleNamespaces": config_list(automatic, "requireIdleNamespaces", []),
|
||||
"mode": "bounded-rolling-manifest-delete-then-official-gc",
|
||||
},
|
||||
"runCommand": "bun scripts/cli.ts gc remote %s run --confirm --include-registry --limit 1" % PROVIDER_ID,
|
||||
"statusCommand": "bun scripts/cli.ts gc remote %s status --job-id <job-id>" % PROVIDER_ID,
|
||||
}
|
||||
|
||||
def safe_unit_name(value):
|
||||
raw = str(value or "").strip().lower()
|
||||
raw = re.sub(r"[^a-z0-9_.@-]+", "-", raw).strip("-")
|
||||
@@ -2687,6 +2768,9 @@ def main():
|
||||
else:
|
||||
emit_json(start_memory_pressure_job(observed_at, visible), persist_large=True)
|
||||
return 0
|
||||
if (bool(OPTIONS.get("registry")) or bool(OPTIONS.get("hwlabRegistry"))) and ACTION == "plan":
|
||||
emit_json(registry_plan_payload(observed_at), persist_large=True)
|
||||
return 0
|
||||
preflight = cluster_preflight()
|
||||
if ACTION == "policy-plan":
|
||||
emit_json(remote_policy_plan_payload(observed_at), persist_large=False)
|
||||
|
||||
@@ -25,6 +25,7 @@ interface RemoteGcOptions {
|
||||
aptCache: boolean;
|
||||
coreDumps: boolean;
|
||||
coreDumpMinAgeHours: number;
|
||||
registry: boolean;
|
||||
hwlabRegistry: boolean;
|
||||
registryGcOnly: boolean;
|
||||
registryKeepPerRepo: number;
|
||||
@@ -57,6 +58,7 @@ const DEFAULT_REMOTE_OPTIONS: RemoteGcOptions = {
|
||||
aptCache: true,
|
||||
coreDumps: true,
|
||||
coreDumpMinAgeHours: 1,
|
||||
registry: false,
|
||||
hwlabRegistry: false,
|
||||
registryGcOnly: false,
|
||||
registryKeepPerRepo: 20,
|
||||
@@ -96,7 +98,7 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri
|
||||
return {
|
||||
ok: false,
|
||||
error: "gc-remote-provider-required",
|
||||
usage: "bun scripts/cli.ts gc remote <providerId> memory|memory-distribution|retention|plan|snapshot|trend|run|status|policy [--confirm]",
|
||||
usage: "bun scripts/cli.ts gc remote <providerId> memory|memory-distribution|retention|plan|snapshot|trend|run|status|policy [--include-registry] [--confirm]",
|
||||
};
|
||||
}
|
||||
const subaction = action ?? "plan";
|
||||
@@ -181,14 +183,15 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri
|
||||
if (subaction === "status") return await runRemoteGc(config, providerId, "status", options);
|
||||
if (subaction === "run") {
|
||||
if (!options.confirm) {
|
||||
const registryFlag = options.registry ? " --include-registry" : "";
|
||||
return {
|
||||
ok: false,
|
||||
error: "gc-remote-run-requires-confirm",
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
requiredFlag: "--confirm",
|
||||
planCommand: `bun scripts/cli.ts gc remote ${providerId} plan${options.memoryPressureOnly ? " --memory-pressure-only" : ""}`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${providerId} run --confirm${options.memoryPressureOnly ? " --memory-pressure-only" : ""}`,
|
||||
planCommand: `bun scripts/cli.ts gc remote ${providerId} plan${options.memoryPressureOnly ? " --memory-pressure-only" : registryFlag}`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${providerId} run --confirm${options.memoryPressureOnly ? " --memory-pressure-only" : registryFlag}`,
|
||||
};
|
||||
}
|
||||
return await runRemoteGc(config, providerId, "run", options);
|
||||
@@ -280,9 +283,13 @@ function parseRemoteGcOptions(args: string[]): RemoteGcOptions {
|
||||
options.aptCache = false;
|
||||
} else if (arg === "--no-core-dumps") {
|
||||
options.coreDumps = false;
|
||||
} else if (arg === "--include-registry") {
|
||||
options.registry = true;
|
||||
} else if (arg === "--include-hwlab-registry") {
|
||||
options.registry = true;
|
||||
options.hwlabRegistry = true;
|
||||
} else if (arg === "--registry-gc-only") {
|
||||
options.registry = true;
|
||||
options.hwlabRegistry = true;
|
||||
options.registryGcOnly = true;
|
||||
} else if (arg === "--full" || arg === "--raw") {
|
||||
@@ -339,11 +346,40 @@ function loadRemoteGcTargetConfig(providerId: string): Record<string, unknown> {
|
||||
const targets = yamlRecordOrEmpty(remote.targets, GC_REMOTE_CONFIG_REF);
|
||||
const candidates = [providerId, providerId.toUpperCase(), providerId.toLowerCase()];
|
||||
for (const key of candidates) {
|
||||
if (Object.prototype.hasOwnProperty.call(targets, key)) return yamlRecordOrEmpty(targets[key], `${GC_REMOTE_CONFIG_REF}.${key}`);
|
||||
if (Object.prototype.hasOwnProperty.call(targets, key)) {
|
||||
const target = yamlRecordOrEmpty(targets[key], `${GC_REMOTE_CONFIG_REF}.${key}`);
|
||||
const registryRetention = yamlRecordOrEmpty(target.registryRetention, `${GC_REMOTE_CONFIG_REF}.${key}.registryRetention`);
|
||||
if (Object.keys(registryRetention).length === 0) return target;
|
||||
const configRef = typeof registryRetention.configRef === "string" ? registryRetention.configRef : "";
|
||||
if (configRef.length === 0) throw new Error(`${GC_REMOTE_CONFIG_REF}.${key}.registryRetention.configRef must be a file#path reference`);
|
||||
return {
|
||||
...target,
|
||||
registryRetention: {
|
||||
...registryRetention,
|
||||
resolvedRegistry: resolveYamlConfigRef(configRef),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveYamlConfigRef(configRef: string): Record<string, unknown> {
|
||||
const separator = configRef.indexOf("#");
|
||||
if (separator <= 0 || separator === configRef.length - 1) throw new Error(`configRef must contain file#path: ${configRef}`);
|
||||
const relativePath = configRef.slice(0, separator);
|
||||
const selector = configRef.slice(separator + 1);
|
||||
const absolutePath = rootPath(relativePath);
|
||||
if (!existsSync(absolutePath)) throw new Error(`configRef file does not exist: ${relativePath}`);
|
||||
let value: unknown = Bun.YAML.parse(readFileSync(absolutePath, "utf8")) as unknown;
|
||||
for (const segment of selector.split(".").filter(Boolean)) {
|
||||
const record = yamlRecordOrEmpty(value, `${relativePath}#${selector}`);
|
||||
if (!Object.prototype.hasOwnProperty.call(record, segment)) throw new Error(`configRef path not found: ${configRef}`);
|
||||
value = record[segment];
|
||||
}
|
||||
return yamlRecordOrEmpty(value, configRef);
|
||||
}
|
||||
|
||||
function recordOrEmpty(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user