293 lines
15 KiB
Python
293 lines
15 KiB
Python
def registry_growth_snapshot():
|
|
summary = {
|
|
"path": REGISTRY_ROOT,
|
|
"sizeBytes": du_size(REGISTRY_ROOT, 60) or 0,
|
|
}
|
|
summary["sizeHuman"] = fmt_bytes(summary["sizeBytes"])
|
|
if OPTIONS.get("hwlabRegistry", False):
|
|
plan = plan_registry_retention()
|
|
retention = dict(plan.get("summary") or {})
|
|
for key in ["registrySizeBytes", "estimatedReclaimBytes"]:
|
|
if key in retention:
|
|
retention[key.replace("Bytes", "Human")] = fmt_bytes(retention.get(key) or 0)
|
|
summary["retentionPlan"] = retention
|
|
else:
|
|
summary["retentionPlan"] = {
|
|
"skipped": True,
|
|
"reason": "rerun snapshot with --include-hwlab-registry to compute tag/revision retention counters",
|
|
}
|
|
summary["cadence"] = {
|
|
"dryRun": "daily or before/after every v0.2 CI/CD burst",
|
|
"maintenanceRun": "weekly, or when root >=80%, or when registry growth exceeds the agreed daily threshold",
|
|
"planCommand": "bun scripts/cli.ts gc remote %s plan --target-use-percent 70 --include-hwlab-registry --limit 50" % PROVIDER_ID,
|
|
"snapshotCommand": "bun scripts/cli.ts gc remote %s snapshot --include-hwlab-registry --history-limit 12" % PROVIDER_ID,
|
|
"runCommand": "bun scripts/cli.ts gc remote %s run --confirm --include-hwlab-registry --target-use-percent 70 --limit 50" % PROVIDER_ID,
|
|
"defaultRetention": {
|
|
"keepPerRepo": int(OPTIONS.get("registryKeepPerRepo") or 20),
|
|
"minAgeHours": float(OPTIONS.get("registryMinAgeHours") or 48),
|
|
"protects": ["current workload refs", "digest closure", "protected tags", "recent tags", "newest N tags per repo"],
|
|
},
|
|
}
|
|
return summary
|
|
|
|
def growth_watermark_policy(root_disk):
|
|
use_percent = root_disk.get("usePercent") if isinstance(root_disk, dict) else None
|
|
if use_percent is None:
|
|
state = "unknown"
|
|
action = "collect-snapshot"
|
|
elif use_percent < 75:
|
|
state = "healthy"
|
|
action = "observe-trend"
|
|
elif use_percent < 80:
|
|
state = "watch"
|
|
action = "run-dry-run-plan"
|
|
elif use_percent < 85:
|
|
state = "maintenance"
|
|
action = "schedule-owner-aware-retention"
|
|
else:
|
|
state = "emergency"
|
|
action = "restore-runtime-then-file-evidence"
|
|
return {
|
|
"state": state,
|
|
"recommendedAction": action,
|
|
"watermarks": [
|
|
{"range": "<75%", "action": "trend only"},
|
|
{"range": "75%-80%", "action": "run dry-run plan and identify source"},
|
|
{"range": "80%-85%", "action": "small owner-aware retention run"},
|
|
{"range": ">=85%", "action": "runtime recovery first, then root-cause growth source"},
|
|
],
|
|
"growthThresholdPolicy": "If bytes/day remains high for consecutive snapshots, act before 80%; exact threshold should be set from the first week of saved snapshots.",
|
|
}
|
|
|
|
def snapshot_metric_map(snapshot):
|
|
metrics = {}
|
|
root = snapshot.get("rootDisk") or {}
|
|
if isinstance(root, dict) and root.get("usedBytes") is not None:
|
|
metrics["root.usedBytes"] = {"value": safe_int(root.get("usedBytes")), "unit": "bytes", "label": "root used bytes"}
|
|
for item in snapshot.get("sources") or []:
|
|
if not isinstance(item, dict) or item.get("sizeBytes") is None:
|
|
continue
|
|
key = "source.%s.sizeBytes" % item.get("id")
|
|
metrics[key] = {"value": safe_int(item.get("sizeBytes")), "unit": "bytes", "label": item.get("label") or item.get("id")}
|
|
storage = ((snapshot.get("ciStorage") or {}).get("byOwnerGroup") or {})
|
|
if not storage:
|
|
storage = ((snapshot.get("pvcAttribution") or {}).get("byOwnerGroup") or {})
|
|
for owner, value in storage.items():
|
|
metrics["ciStorage.%s.estimatedBytes" % owner] = {"value": safe_int((value or {}).get("estimatedBytes")), "unit": "bytes", "label": "CI storage %s" % owner}
|
|
memory = snapshot.get("memoryPressure") or {}
|
|
memory_summary = memory.get("summary") or {}
|
|
if memory_summary.get("matchedRssBytes") is not None:
|
|
metrics["memoryPressure.matchedRssBytes"] = {"value": safe_int(memory_summary.get("matchedRssBytes")), "unit": "bytes", "label": "matched observer/chrome RSS"}
|
|
if memory_summary.get("observeStateBytes") is not None:
|
|
metrics["memoryPressure.observeStateBytes"] = {"value": safe_int(memory_summary.get("observeStateBytes")), "unit": "bytes", "label": "web observe state bytes"}
|
|
for key in ["matchedProcessCount", "activeObserverSignals", "staleObserverSignals"]:
|
|
if memory_summary.get(key) is not None:
|
|
metrics["memoryPressure.%s" % key] = {"value": safe_int(memory_summary.get(key)), "unit": "count", "label": "memory pressure %s" % key}
|
|
registry = snapshot.get("registry") or {}
|
|
retention = registry.get("retentionPlan") or {}
|
|
for key in ["totalTags", "totalRevisions", "deleteTags", "deleteRevisions", "estimatedReclaimBytes"]:
|
|
if key in retention and retention.get(key) is not None:
|
|
unit = "bytes" if key.endswith("Bytes") else "count"
|
|
metrics["registry.%s" % key] = {"value": safe_int(retention.get(key)), "unit": unit, "label": "registry %s" % key}
|
|
return metrics
|
|
|
|
def delta_metric_rows(before, after):
|
|
before_metrics = snapshot_metric_map(before)
|
|
after_metrics = snapshot_metric_map(after)
|
|
before_ts = iso_to_epoch(before.get("observedAt"))
|
|
after_ts = iso_to_epoch(after.get("observedAt"))
|
|
seconds = (after_ts - before_ts) if before_ts is not None and after_ts is not None else None
|
|
rows = []
|
|
for key in sorted(set(before_metrics.keys()) | set(after_metrics.keys())):
|
|
old = before_metrics.get(key, {"value": 0, "unit": (after_metrics.get(key) or {}).get("unit"), "label": key})
|
|
new = after_metrics.get(key, {"value": 0, "unit": old.get("unit"), "label": old.get("label")})
|
|
delta = safe_int(new.get("value")) - safe_int(old.get("value"))
|
|
row = {
|
|
"key": key,
|
|
"label": new.get("label") or old.get("label") or key,
|
|
"unit": new.get("unit") or old.get("unit"),
|
|
"before": old.get("value"),
|
|
"after": new.get("value"),
|
|
"delta": delta,
|
|
}
|
|
if row["unit"] == "bytes":
|
|
row["beforeHuman"] = fmt_bytes(row["before"] or 0)
|
|
row["afterHuman"] = fmt_bytes(row["after"] or 0)
|
|
row["deltaHuman"] = ("-" if delta < 0 else "") + fmt_bytes(abs(delta))
|
|
if seconds and seconds > 0:
|
|
per_day = int(delta * 86400 / seconds)
|
|
row["perDayBytes"] = per_day
|
|
row["perDayHuman"] = ("-" if per_day < 0 else "") + fmt_bytes(abs(per_day)) + "/day"
|
|
rows.append(row)
|
|
rows.sort(key=lambda item: safe_int(item.get("delta")), reverse=True)
|
|
return {"durationSeconds": seconds, "metrics": rows}
|
|
|
|
def growth_trend_payload(points):
|
|
points = [point for point in points if isinstance(point, dict)]
|
|
if len(points) < 2:
|
|
return {
|
|
"pointCount": len(points),
|
|
"state": "insufficient-history",
|
|
"message": "Run snapshot at least twice to compute deltas.",
|
|
}
|
|
latest_delta = delta_metric_rows(points[-2], points[-1])
|
|
window_delta = delta_metric_rows(points[0], points[-1])
|
|
def rate_warning(delta):
|
|
seconds = delta.get("durationSeconds")
|
|
if seconds is not None and seconds < 3600:
|
|
return {
|
|
"code": "short-window-rate-noisy",
|
|
"message": "Per-day rates from windows shorter than 1 hour are directional only; use daily snapshots for governance decisions.",
|
|
"durationSeconds": seconds,
|
|
}
|
|
return None
|
|
return {
|
|
"pointCount": len(points),
|
|
"oldestAt": points[0].get("observedAt"),
|
|
"latestAt": points[-1].get("observedAt"),
|
|
"latestDelta": {
|
|
"durationSeconds": latest_delta.get("durationSeconds"),
|
|
"rateWarning": rate_warning(latest_delta),
|
|
"topGrowingBytes": [row for row in latest_delta.get("metrics", []) if row.get("unit") == "bytes" and safe_int(row.get("delta")) > 0][:10],
|
|
"topShrinkingBytes": [row for row in reversed(latest_delta.get("metrics", [])) if row.get("unit") == "bytes" and safe_int(row.get("delta")) < 0][:10],
|
|
"registryCounters": [row for row in latest_delta.get("metrics", []) if str(row.get("key", "")).startswith("registry.") and row.get("unit") == "count"],
|
|
},
|
|
"windowDelta": {
|
|
"durationSeconds": window_delta.get("durationSeconds"),
|
|
"rateWarning": rate_warning(window_delta),
|
|
"topGrowingBytes": [row for row in window_delta.get("metrics", []) if row.get("unit") == "bytes" and safe_int(row.get("delta")) > 0][:10],
|
|
"topShrinkingBytes": [row for row in reversed(window_delta.get("metrics", [])) if row.get("unit") == "bytes" and safe_int(row.get("delta")) < 0][:10],
|
|
"registryCounters": [row for row in window_delta.get("metrics", []) if str(row.get("key", "")).startswith("registry.") and row.get("unit") == "count"],
|
|
},
|
|
}
|
|
|
|
def compact_metric_rows(rows, limit=3):
|
|
compact = []
|
|
for row in (rows or [])[:limit]:
|
|
compact.append({
|
|
"key": row.get("key"),
|
|
"label": row.get("label"),
|
|
"unit": row.get("unit"),
|
|
"delta": row.get("delta"),
|
|
"deltaHuman": row.get("deltaHuman"),
|
|
"perDayHuman": row.get("perDayHuman"),
|
|
})
|
|
return compact
|
|
|
|
def compact_trend_payload(payload):
|
|
if payload.get("state") == "insufficient-history":
|
|
return payload
|
|
latest = payload.get("latestDelta") or {}
|
|
window = payload.get("windowDelta") or {}
|
|
return {
|
|
"pointCount": payload.get("pointCount"),
|
|
"oldestAt": payload.get("oldestAt"),
|
|
"latestAt": payload.get("latestAt"),
|
|
"latestDelta": {
|
|
"durationSeconds": latest.get("durationSeconds"),
|
|
"rateWarning": latest.get("rateWarning"),
|
|
"topGrowingBytes": compact_metric_rows(latest.get("topGrowingBytes") or [], 1),
|
|
"topShrinkingBytes": compact_metric_rows(latest.get("topShrinkingBytes") or [], 1),
|
|
"registryCounters": compact_metric_rows(latest.get("registryCounters") or [], 1),
|
|
},
|
|
"windowDelta": {
|
|
"durationSeconds": window.get("durationSeconds"),
|
|
"rateWarning": window.get("rateWarning"),
|
|
"topGrowingBytes": compact_metric_rows(window.get("topGrowingBytes") or [], 1),
|
|
"topShrinkingBytes": compact_metric_rows(window.get("topShrinkingBytes") or [], 1),
|
|
"registryCounters": compact_metric_rows(window.get("registryCounters") or [], 1),
|
|
},
|
|
"fullDisclosure": "rerun trend --full for all metric rows",
|
|
}
|
|
|
|
def compact_growth_point(item):
|
|
registry = item.get("registry") or {}
|
|
retention = registry.get("retentionPlan") or {}
|
|
ci_storage = item.get("ciStorage") or {}
|
|
containerd = item.get("containerd") or {}
|
|
memory = item.get("memoryPressure") or {}
|
|
memory_summary = memory.get("summary") or {}
|
|
observe = (memory.get("webObserve") or {})
|
|
return {
|
|
"observedAt": item.get("observedAt"),
|
|
"rootDisk": item.get("rootDisk"),
|
|
"sourceCount": len(item.get("sources") or []),
|
|
"registry": {
|
|
"sizeBytes": registry.get("sizeBytes"),
|
|
"sizeHuman": registry.get("sizeHuman"),
|
|
"totalTags": retention.get("totalTags"),
|
|
"totalRevisions": retention.get("totalRevisions"),
|
|
"deleteTags": retention.get("deleteTags"),
|
|
"deleteRevisions": retention.get("deleteRevisions"),
|
|
"estimatedReclaimBytes": retention.get("estimatedReclaimBytes"),
|
|
"estimatedReclaimHuman": retention.get("estimatedReclaimHuman"),
|
|
},
|
|
"ciStorage": {
|
|
"pvcCount": ci_storage.get("pvcCount"),
|
|
"estimatedBytes": ci_storage.get("estimatedBytes"),
|
|
"estimatedHuman": ci_storage.get("estimatedHuman"),
|
|
"byOwnerGroup": ci_storage.get("byOwnerGroup"),
|
|
},
|
|
"containerd": {
|
|
"state": containerd.get("state"),
|
|
"cleanupSupported": containerd.get("cleanupSupported"),
|
|
},
|
|
"memoryPressure": {
|
|
"matchedProcessCount": memory_summary.get("matchedProcessCount"),
|
|
"matchedRssBytes": memory_summary.get("matchedRssBytes"),
|
|
"matchedRssHuman": memory_summary.get("matchedRssHuman"),
|
|
"activeObserverSignals": memory_summary.get("activeObserverSignals"),
|
|
"staleObserverSignals": memory_summary.get("staleObserverSignals"),
|
|
"observeStateBytes": memory_summary.get("observeStateBytes"),
|
|
"observeStateHuman": memory_summary.get("observeStateHuman"),
|
|
"webObserveRootCount": observe.get("rootCount"),
|
|
},
|
|
}
|
|
|
|
def collect_growth_snapshot(observed_at, preflight):
|
|
root_disk = df_snapshot()
|
|
sources = disk_source_snapshot()
|
|
ci_storage = ci_storage_snapshot()
|
|
memory_pressure = collect_memory_pressure()
|
|
compact_pvc = compact_pvc_attribution(ci_storage)
|
|
if bool(OPTIONS.get("full")):
|
|
public_pvc = ci_storage
|
|
public_memory = memory_pressure
|
|
else:
|
|
public_pvc = compact_ci_storage_summary(ci_storage)
|
|
public_memory = compact_memory_summary(memory_pressure)
|
|
registry = registry_growth_snapshot()
|
|
containerd = containerd_breakdown_snapshot()
|
|
commands = {
|
|
"snapshot": "bun scripts/cli.ts gc remote %s snapshot --include-hwlab-registry --history-limit %s" % (PROVIDER_ID, int(OPTIONS.get("historyLimit") or 12)),
|
|
"trend": "bun scripts/cli.ts gc remote %s trend --history-limit %s" % (PROVIDER_ID, int(OPTIONS.get("historyLimit") or 12)),
|
|
"registryPlan": "bun scripts/cli.ts gc remote %s plan --target-use-percent 70 --include-hwlab-registry --limit 50" % PROVIDER_ID,
|
|
"hwlabCiRetention": ((ci_storage.get("handoff") or {}).get("hwlab") or {}).get("dryRun"),
|
|
"agentrunRetention": ((ci_storage.get("handoff") or {}).get("agentrun") or {}).get("dryRun"),
|
|
"remotePolicy": "bun scripts/cli.ts gc remote %s policy plan" % PROVIDER_ID,
|
|
}
|
|
if not bool(OPTIONS.get("full")):
|
|
commands = {
|
|
"trend": "bun scripts/cli.ts gc remote %s trend --history-limit %s" % (PROVIDER_ID, int(OPTIONS.get("historyLimit") or 12)),
|
|
"status": "bun scripts/cli.ts gc remote %s status --limit %s" % (PROVIDER_ID, int(OPTIONS.get("limit") or 50)),
|
|
"full": "bun scripts/cli.ts gc remote %s snapshot --full --no-save" % PROVIDER_ID,
|
|
}
|
|
return {
|
|
"ok": True,
|
|
"action": "gc remote snapshot",
|
|
"providerId": PROVIDER_ID,
|
|
"dryRun": True,
|
|
"mutation": False,
|
|
"diagnosticStateMutation": bool(OPTIONS.get("saveSnapshot", True)),
|
|
"observedAt": observed_at,
|
|
"rootDisk": root_disk,
|
|
"clusterPreflight": preflight,
|
|
"sources": sources,
|
|
"registry": registry,
|
|
"pvcAttribution": public_pvc,
|
|
"memoryPressure": public_memory,
|
|
"containerd": containerd,
|
|
"policy": growth_watermark_policy(root_disk or {}),
|
|
"commands": commands,
|
|
}
|