security watch: seven pins it can never read are a stated GAP, so the source streaks reset; a read timeout is retried; every failed source is printed (R-1334); R-1310's rules (another package, every GHSA a release names, a word-only line, date tags)
gates / gates (push) Successful in 3m35s
gates / gates (push) Successful in 3m35s
This commit is contained in:
1 parent
c25b790ce9
commit
4afa8c3cde
12 files changed
+2202
-35
No files matched your search
+157
-32
@@ -7,7 +7,8 @@ publishes its security notices, and sorts every pin into exactly one class:
|
||||
|
||||
ADVISORY a notice affects the version we pin (with the fixed version when the notice gives one)
|
||||
NOT CHECKED a source this pin needs failed tonight — printed and counted, never a silent pass
|
||||
GAP the pin has no machine-readable source (a stated `gap:` line); BEHIND is still shown
|
||||
GAP the pin has no machine-readable source (a stated `gap:` line), or the watch can never read it (our own
|
||||
private registry; STATED_UNREADABLE) — listed with its reason in every weekly mail; BEHIND is still shown
|
||||
FLOATING the tag names no exact version (`redis:7-alpine`), so no range can be matched; BEHIND still shown
|
||||
BEHIND a newer version exists in the same channel, and no notice
|
||||
CURRENT none of the above
|
||||
@@ -79,6 +80,10 @@ VULNNOTES = {
|
||||
}
|
||||
STRONG = re.compile(r"\bCVE-\d{4}-\d{3,}|\bGHSA(?:-[0-9a-z]{4}){3}\b|vulnerab|security (?:fix|issue|update|release|"
|
||||
r"advisory|patch|vulnerability)|\[security\]", re.I)
|
||||
GHSA = re.compile(r"\bGHSA(?:-[0-9a-z]{4}){3}\b", re.I)
|
||||
# A line where „security" is only a word (R-1310, 2026-11-02): Traefik v3.7.17's „Point security announcements to GitHub
|
||||
# advisories", Dawarich's „no longer receives security fixes". Pinned: test W16. A new shape is listed, the safe direction.
|
||||
WORD_ONLY = re.compile(r"security announcements?|security (?:policy|contact|researchers?)|no longer (?:receives?|gets?) security", re.I)
|
||||
FEED_WORDS = re.compile(r"vulnerab|compromis|\bCVE-\d{4}|security (?:update|fix|advisory|issue|notice)|exploit", re.I)
|
||||
|
||||
|
||||
@@ -86,6 +91,10 @@ class SourceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Transient(SourceError):
|
||||
"""A transport failure (a read timeout, a reset): worth one more try. An HTTP answer is not transient, except a 429."""
|
||||
|
||||
|
||||
# ── network (the tests replace Net) ──────────────────────────────────────────────────────────────────────────────────
|
||||
class Net:
|
||||
def __init__(self, gh_token=""):
|
||||
@@ -106,7 +115,7 @@ class Net:
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, dict(e.headers or {}), e.read() if hasattr(e, "read") else b""
|
||||
except Exception as e: # noqa: BLE001 — every transport failure is a NOT CHECKED, named
|
||||
raise SourceError("%s: %s" % (url.split("?")[0], e))
|
||||
raise Transient("%s: %s" % (url.split("?")[0], e))
|
||||
|
||||
def post_json(self, url, body, timeout=30):
|
||||
self._count(url)
|
||||
@@ -188,16 +197,32 @@ def patched_for(text, ours):
|
||||
|
||||
# A repo whose advisory list covers more than the program we run: only these package names count.
|
||||
PACKAGE_ONLY = {"nextcloud/security-advisories": re.compile(r"^server$", re.I)}
|
||||
# A repo whose advisories also name OTHER programs we do not run (R-1310, read from every repo's list 2026-11-02). A notice
|
||||
# naming only these is not ours; a notice naming these AND the pinned program is. A new name is listed until it is added
|
||||
# here — the safe direction. Pinned: test W16.
|
||||
PACKAGE_NOT_OURS = {
|
||||
"go-gitea/gitea": re.compile(r"gitea\.com/gitea/runner", re.I),
|
||||
"home-assistant/core": re.compile(r"Companion|Operating System|Supervisor|home-assistant-js-websocket", re.I),
|
||||
"advplyr/audiobookshelf": re.compile(r"audiobookshelf-app", re.I),
|
||||
"go-vikunja/vikunja": re.compile(r"vikunja-desktop|code\.vikunja\.io/veans", re.I),
|
||||
"actualbudget/actual": re.compile(r"@actual-app/cli", re.I),
|
||||
"Termix-SSH/Termix": re.compile(r"@termix-plugin", re.I),
|
||||
"louislam/uptime-kuma": re.compile(r"Rootless Docker Image only|louislam/dockge", re.I),
|
||||
"PrivateBin/PrivateBin": re.compile(r"privatebin/(?:gcs|nginx-fpm-alpine|s3|unit-\S+)", re.I),
|
||||
}
|
||||
|
||||
|
||||
def ghsa_affects(adv, ours, repo=""):
|
||||
"""-> (affected True/False/None, fixed text). None = the range is unreadable (listed, never dropped)."""
|
||||
verdicts, fixed = [], []
|
||||
only = PACKAGE_ONLY.get(repo)
|
||||
only, not_ours, skipped = PACKAGE_ONLY.get(repo), PACKAGE_NOT_OURS.get(repo), False
|
||||
for vul in adv.get("vulnerabilities") or []:
|
||||
pkg = (vul.get("package") or {}).get("name") or ""
|
||||
if only and not only.search(pkg.strip()):
|
||||
continue
|
||||
if not_ours and not_ours.search(pkg):
|
||||
skipped = True
|
||||
continue
|
||||
# A GitHub Actions advisory is about the project's own CI workflow, never the image we run (R-1310: Home
|
||||
# Assistant's GHSA-jff5, 2023). Pinned: test W9.
|
||||
if ((vul.get("package") or {}).get("ecosystem") or "").lower() == "actions":
|
||||
@@ -221,7 +246,7 @@ def ghsa_affects(adv, ours, repo=""):
|
||||
if any(v is True for v in verdicts):
|
||||
return True, ", ".join(sorted(set(fixed)))
|
||||
if not verdicts or all(v is False for v in verdicts):
|
||||
return False if verdicts else (None if not only else False), "" if verdicts else "the notice names no version range"
|
||||
return False if verdicts or skipped else (None if not only else False), "" if verdicts else "the notice names no version range"
|
||||
return None, "the range is unreadable — check by hand"
|
||||
|
||||
|
||||
@@ -287,12 +312,20 @@ def cached_runtime_reader(cache, resolve=None, read=None):
|
||||
stats[key] += n
|
||||
|
||||
def reader(ref):
|
||||
try:
|
||||
digest = resolve(ref)
|
||||
except image_runtime.Unreadable as e:
|
||||
return {"ref": ref, "unchecked": [str(e)]}
|
||||
except Exception as e: # noqa: BLE001 — a network error is a NOT CHECKED, never a crash of the whole watch
|
||||
return {"ref": ref, "unchecked": ["%s: %s" % (type(e).__name__, str(e)[:120])]}
|
||||
digest = None
|
||||
for attempt in (1, 2): # a read timeout gets one more try (R-1334)
|
||||
try:
|
||||
digest = resolve(ref)
|
||||
break
|
||||
except image_runtime.Unreadable as e:
|
||||
# counted: the log's RUNTIME line said not-checked=0 for six nights while this path failed (R-1334)
|
||||
count("not_checked")
|
||||
return {"ref": ref, "unchecked": [str(e)]}
|
||||
except Exception as e: # noqa: BLE001 — a network error is a NOT CHECKED, never a crash of the whole watch
|
||||
if attempt == 2:
|
||||
count("not_checked")
|
||||
return {"ref": ref, "unchecked": ["%s: %s" % (type(e).__name__, str(e)[:120])]}
|
||||
time.sleep(5)
|
||||
if digest and digest in cache:
|
||||
hit = dict(cache[digest], ref=ref)
|
||||
hit["_used"] = True
|
||||
@@ -324,6 +357,19 @@ HUB_TRIES = 4
|
||||
HUB_GAP_S = 1.0
|
||||
HUB_BACKOFF_S = (10, 30, 60)
|
||||
TAG_CACHE_S = 24 * 3600
|
||||
# A read timeout is tried again (R-1334: 1-3 pins a night failed that way on 2026-10-29..11-02, a different pin each
|
||||
# night, and were read fine the night after). The budget stops a real outage from stretching the job past its limit.
|
||||
TRANSIENT_TRIES = 3
|
||||
TRANSIENT_WAIT_S = (5, 15)
|
||||
TRANSIENT_BUDGET = 30
|
||||
# A pin the watch can never read, stated here with the ONE answer it gives: it is a GAP with this reason, not a failed
|
||||
# source. Any other answer (a timeout, a 500) is still NOT CHECKED, and an unlisted 401 too — a Docker Hub outage must
|
||||
# never read as a gap. Earned 2026-11-02 (R-1334): this one pin and our six private images held the „registry:docker.io"
|
||||
# and „runtime" streaks open for a week, so no later outage of either source could have mailed. Pinned: test W15.
|
||||
STATED_UNREADABLE = {
|
||||
"minio/minio": ("HTTP 401", "Docker Hub answers HTTP 401 for its tags and its manifest: the image is no longer published "
|
||||
"there (seen 2026-10-28, R-1328). No notice can be matched; the homelab session moves the pin"),
|
||||
}
|
||||
|
||||
|
||||
class Sources:
|
||||
@@ -335,10 +381,38 @@ class Sources:
|
||||
self.now = time.time() if now is None else now
|
||||
self.sleep, self.clock = sleep or time.sleep, clock or time.monotonic
|
||||
self._hub_lock, self._hub_last = threading.Lock(), None
|
||||
self.tag_stats = {"from_cache": 0, "fetched": 0, "retried_429": 0}
|
||||
self.tag_stats = {"from_cache": 0, "fetched": 0, "retried_429": 0, "retried_timeout": 0}
|
||||
self._transient_left = TRANSIENT_BUDGET
|
||||
|
||||
def _may_retry_transient(self):
|
||||
with self._hub_lock:
|
||||
if self._transient_left <= 0:
|
||||
return False
|
||||
self._transient_left -= 1
|
||||
self.tag_stats["retried_timeout"] += 1
|
||||
return True
|
||||
|
||||
def get(self, url, headers=None):
|
||||
"""A GET to any source but Docker Hub: a read timeout is tried again, within tonight's budget."""
|
||||
for i in range(TRANSIENT_TRIES):
|
||||
try:
|
||||
st, h, body = self.net.get(url, headers)
|
||||
except Transient:
|
||||
if i == TRANSIENT_TRIES - 1 or not self._may_retry_transient():
|
||||
raise
|
||||
self.sleep(TRANSIENT_WAIT_S[min(i, len(TRANSIENT_WAIT_S) - 1)])
|
||||
continue
|
||||
hh = h or {}
|
||||
# a 429 with quota left is a burst refusal (ghcr.io, 2026-11-02 by hand): wait and try again; GitHub's spent quota
|
||||
# (X-RateLimit-Remaining: 0) is the caller's to read
|
||||
spent = str(hh.get("X-RateLimit-Remaining", hh.get("x-ratelimit-remaining", ""))) == "0"
|
||||
if st != 429 or spent or i == TRANSIENT_TRIES - 1 or not self._may_retry_transient():
|
||||
return st, h, body
|
||||
ra = str(hh.get("Retry-After", hh.get("retry-after", "")))
|
||||
self.sleep(min(int(ra), 120) if ra.isdigit() else TRANSIENT_WAIT_S[min(i, len(TRANSIENT_WAIT_S) - 1)])
|
||||
|
||||
def hub_get(self, url, headers=None):
|
||||
"""A GET to Docker Hub: spaced across the workers, a 429 retried. Returns the last answer."""
|
||||
"""A GET to Docker Hub: spaced across the workers, a 429 or a read timeout retried. Returns the last answer."""
|
||||
for i in range(HUB_TRIES):
|
||||
with self._hub_lock:
|
||||
if self._hub_last is not None:
|
||||
@@ -346,7 +420,13 @@ class Sources:
|
||||
if wait > 0:
|
||||
self.sleep(wait)
|
||||
self._hub_last = self.clock()
|
||||
st, h, body = self.net.get(url, headers)
|
||||
try:
|
||||
st, h, body = self.net.get(url, headers)
|
||||
except Transient:
|
||||
if i == HUB_TRIES - 1 or not self._may_retry_transient():
|
||||
raise
|
||||
self.sleep(HUB_BACKOFF_S[min(i, len(HUB_BACKOFF_S) - 1)])
|
||||
continue
|
||||
if st != 429 or i == HUB_TRIES - 1:
|
||||
return st, h, body
|
||||
self.tag_stats["retried_429"] += 1
|
||||
@@ -365,7 +445,7 @@ class Sources:
|
||||
if self.net.gh_token:
|
||||
headers["Authorization"] = "Bearer %s" % self.net.gh_token
|
||||
for _ in range(5):
|
||||
st, h, body = self.net.get(url, headers)
|
||||
st, h, body = self.get(url, headers)
|
||||
if st in (403, 429) and str(h.get("X-RateLimit-Remaining", h.get("x-ratelimit-remaining", ""))) == "0":
|
||||
self.rate_limited = True
|
||||
raise SourceError("GitHub API rate limit (%s)" % st)
|
||||
@@ -382,7 +462,7 @@ class Sources:
|
||||
def release_feed(self, repo):
|
||||
if repo in self._rel:
|
||||
return self._rel[repo]
|
||||
st, _h, body = self.net.get("https://github.com/%s/releases.atom" % repo)
|
||||
st, _h, body = self.get("https://github.com/%s/releases.atom" % repo)
|
||||
if st != 200:
|
||||
raise SourceError("release feed of %s: HTTP %s" % (repo, st))
|
||||
text = body.decode("utf-8", "replace")
|
||||
@@ -401,7 +481,7 @@ class Sources:
|
||||
def feed(self, name):
|
||||
if name in self._feed:
|
||||
return self._feed[name]
|
||||
st, _h, body = self.net.get(FEEDS[name]["url"])
|
||||
st, _h, body = self.get(FEEDS[name]["url"])
|
||||
if st != 200:
|
||||
raise SourceError("feed %s: HTTP %s" % (name, st))
|
||||
text = body.decode("utf-8", "replace")
|
||||
@@ -419,7 +499,7 @@ class Sources:
|
||||
def vulnnotes(self, name):
|
||||
if name in self._notes:
|
||||
return self._notes[name]
|
||||
st, _h, body = self.net.get(VULNNOTES[name]["url"])
|
||||
st, _h, body = self.get(VULNNOTES[name]["url"])
|
||||
if st != 200:
|
||||
raise SourceError("release notes %s: HTTP %s" % (name, st))
|
||||
secs, cur = {}, None
|
||||
@@ -480,13 +560,13 @@ class Sources:
|
||||
if not url:
|
||||
break
|
||||
elif reg == "ghcr.io":
|
||||
st, _h, body = self.net.get("https://ghcr.io/token?scope=repository:%s:pull" % path)
|
||||
st, _h, body = self.get("https://ghcr.io/token?scope=repository:%s:pull" % path)
|
||||
if st != 200:
|
||||
raise SourceError("ghcr.io token for %s: HTTP %s" % (path, st))
|
||||
tok = json.loads(body).get("token", "")
|
||||
url = "https://ghcr.io/v2/%s/tags/list?n=10000" % path
|
||||
for _ in range(50): # ghcr.io PAGES its list (Link: rel="next") — page 1 alone missed every newer tag (R-1327)
|
||||
st, h, body = self.net.get(url, {"Authorization": "Bearer %s" % tok})
|
||||
st, h, body = self.get(url, {"Authorization": "Bearer %s" % tok})
|
||||
if st != 200:
|
||||
raise SourceError("ghcr.io tags of %s: HTTP %s" % (path, st))
|
||||
names += json.loads(body).get("tags") or []
|
||||
@@ -517,6 +597,10 @@ def registry_of(image):
|
||||
|
||||
|
||||
# ── one pin ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
def is_date_build(v):
|
||||
return len(v) == 3 and 20000101 <= v[2] <= 20991231
|
||||
|
||||
|
||||
def newest_in_channel(tag, names):
|
||||
ours = version_of(tag)
|
||||
if not ours or not names:
|
||||
@@ -529,6 +613,8 @@ def newest_in_channel(tag, names):
|
||||
v = version_of(n)
|
||||
if not v or len(v) != len(ours):
|
||||
continue
|
||||
if is_date_build(v) and not is_date_build(ours):
|
||||
continue # linuxserver's 26.09.20260929 is a rebuild date, not a version above 26.09.2 (R-1310, test W16)
|
||||
if same_channel(ours, v):
|
||||
if vcmp(v, ours) > 0 and (best is None or vcmp(v, version_of(best)) > 0):
|
||||
best = n
|
||||
@@ -570,15 +656,25 @@ def check_item(it, src, today):
|
||||
"fixed": fixed, "link": a.get("html_url") or "",
|
||||
"unsure": hit is None, "published": (a.get("published_at") or "")[:10]})
|
||||
if kind == "github" and not floating:
|
||||
for e in src.release_feed(arg):
|
||||
named = {a["id"] for a in advs}
|
||||
# oldest first, so a GHSA named by two releases reads as fixed in the lower one
|
||||
for e in sorted(src.release_feed(arg), key=lambda r: (version_of(r["tag"]) or ()) + (0,) * 6):
|
||||
v = version_of(e["tag"])
|
||||
if not v or PRERELEASE.search(e["tag"]) or vcmp(v, ours) <= 0 or not same_channel(ours, v):
|
||||
continue
|
||||
m = STRONG.search(e["content"]) or STRONG.search(e["title"])
|
||||
if m:
|
||||
line = next((ln.strip() for ln in re.split(r"[\n.]", e["content"]) if STRONG.search(ln)), m.group(0))
|
||||
lines = [ln.strip() for ln in re.split(r"[\n.]", e["content"]) + [e["title"]]
|
||||
if STRONG.search(ln) and not WORD_ONLY.search(ln)]
|
||||
ghsas = []
|
||||
for ln in lines: # every GHSA a release names is its own entry (R-1310: Tandoor 2.7.0 named eight)
|
||||
ghsas += [("GHSA" + g[4:].lower(), ln) for g in GHSA.findall(ln)]
|
||||
for gid, ln in ghsas:
|
||||
if gid not in named:
|
||||
named.add(gid)
|
||||
advs.append({"id": gid, "source": "release notes", "severity": "", "summary": ln[:200],
|
||||
"fixed": e["tag"], "link": e["link"], "unsure": False, "published": ""})
|
||||
if lines and not ghsas:
|
||||
advs.append({"id": "release:%s@%s" % (arg, e["tag"]), "source": "release notes",
|
||||
"severity": "", "summary": line[:200], "fixed": e["tag"], "link": e["link"],
|
||||
"severity": "", "summary": lines[0][:200], "fixed": e["tag"], "link": e["link"],
|
||||
"unsure": False, "published": ""})
|
||||
elif kind == "osv" and not floating:
|
||||
ver = ".".join(str(x) for x in ours)
|
||||
@@ -637,15 +733,26 @@ def check_item(it, src, today):
|
||||
else:
|
||||
behind, newer_major = newest_in_channel(it["tag"], names)
|
||||
except SourceError as e:
|
||||
nc.append({"source": "registry:%s" % registry_of(it["image"])[0], "why": str(e)})
|
||||
if stated_unreadable(it, str(e)):
|
||||
it["gap_why"] = stated_unreadable(it, str(e))
|
||||
notes.append("registry: %s" % e)
|
||||
else:
|
||||
nc.append({"source": "registry:%s" % registry_of(it["image"])[0], "why": str(e)})
|
||||
if newer_major:
|
||||
notes.append("a newer major exists: %s" % newer_major)
|
||||
if getattr(src, "runtime", None) and not it.get("no_registry") and it.get("image") and it.get("tag"):
|
||||
res = src.runtime("%s:%s" % (it["image"], it["tag"]))
|
||||
unchecked = res.get("unchecked") or []
|
||||
no_route = [u for u in unchecked if u.startswith("registry ") and "no anonymous token route" in u]
|
||||
if no_route and it["group"] != "dooplex":
|
||||
if no_route:
|
||||
notes.append("runtime: %s" % no_route[0])
|
||||
if it["group"] == "dooplex":
|
||||
# our own private registry: the CI has no read of it, tonight or ever. A stated hole, not a failed source
|
||||
# (R-1334: six such images kept the „runtime" streak open, so a real outage could not mail).
|
||||
it["gap_why"] = "%s — our own build in the private registry: registry and runtime not read" % no_route[0]
|
||||
elif unchecked and all(stated_unreadable(it, u) for u in unchecked):
|
||||
it["gap_why"] = stated_unreadable(it, unchecked[0])
|
||||
notes.append("runtime: %s" % unchecked[0])
|
||||
elif unchecked:
|
||||
nc.append({"source": "runtime", "why": "; ".join(unchecked)[:300]})
|
||||
advs += runtime_advisories(res, it["group"])
|
||||
@@ -656,7 +763,7 @@ def check_item(it, src, today):
|
||||
it["class"] = "ADVISORY"
|
||||
elif nc:
|
||||
it["class"] = "NOT CHECKED"
|
||||
elif "gap" in kinds and not [k for k in kinds if k != "gap"]:
|
||||
elif it.get("gap_why") or ("gap" in kinds and not [k for k in kinds if k != "gap"]):
|
||||
it["class"] = "GAP"
|
||||
elif "self" in kinds and len(kinds) == 1:
|
||||
it["class"] = "SELF"
|
||||
@@ -669,6 +776,18 @@ def check_item(it, src, today):
|
||||
return it
|
||||
|
||||
|
||||
def stated_unreadable(it, why):
|
||||
"""The stated reason when this failure is the one answer this pin is known to give (STATED_UNREADABLE), else None."""
|
||||
known = STATED_UNREADABLE.get(it.get("image"))
|
||||
return known[1] if known and known[0] in why else None
|
||||
|
||||
|
||||
def failed_lines(items):
|
||||
"""Every failed source of every pin. The table shows one class per pin, so an ADVISORY pin hid its failed source."""
|
||||
return ["FAILED-SOURCE %s %s:%s — %s" % (f["source"], it["image"], it["tag"], f["why"][:200])
|
||||
for it in items for f in it.get("not_checked", [])]
|
||||
|
||||
|
||||
def source_name(kind, arg):
|
||||
return {"github": "github", "advisories": "github", "osv": "osv", "feed": "feed:" + arg,
|
||||
"vulnnotes": "vulnnotes:" + arg}.get(kind, kind)
|
||||
@@ -840,8 +959,8 @@ def weekly_mail(items, today):
|
||||
lines += block("Open advisories (mailed already):", sorted({"%s %s — %s" % (it["image"], it["tag"], a["id"])
|
||||
for it in items for a in it.get("advisories", [])}))
|
||||
lines += block("No machine-readable source (GAP) — not watched for notices:",
|
||||
sorted({"%s — %s" % (it["image"], next((v for k, v in it.get("sources", []) if k == "gap"),
|
||||
"; ".join(it.get("notes", []))))
|
||||
sorted({"%s — %s" % (it["image"], it.get("gap_why") or next((v for k, v in it.get("sources", []) if k == "gap"),
|
||||
"; ".join(it.get("notes", []))))
|
||||
for it in items if it.get("class") == "GAP"}))
|
||||
lines += block("Floating tags (no exact version to match a notice against):",
|
||||
sorted({"%s:%s (%s)" % (it["image"], it["tag"], it["owner"]) for it in items if it.get("class") == "FLOATING"}))
|
||||
@@ -951,13 +1070,19 @@ def main(argv):
|
||||
for it in items:
|
||||
counts[it["class"]] = counts.get(it["class"], 0) + 1
|
||||
print("COUNTS " + " ".join("%s=%d" % (k.replace(" ", "_"), v) for k, v in sorted(counts.items())))
|
||||
failed = failed_lines(items)
|
||||
for line in failed:
|
||||
print(line)
|
||||
print("FAILED-SOURCES %d" % len(failed))
|
||||
if rreader:
|
||||
st = rreader.stats
|
||||
print("RUNTIME read=%d (%.0f s) from-cache=%d not-checked=%d zstd-decoder=%s" % (
|
||||
st["read"], st["read_s"], st["cached"], st["not_checked"],
|
||||
nc_rt = sum(1 for it in items for f in it.get("not_checked", []) if f["source"] == "runtime")
|
||||
print("RUNTIME read=%d (%.0f s) from-cache=%d not-checked=%d stated-unreadable=%d zstd-decoder=%s" % (
|
||||
st["read"], st["read_s"], st["cached"], nc_rt, max(st["not_checked"] - nc_rt, 0),
|
||||
image_runtime.zstd_decoder_how() if st["read"] else "not needed"))
|
||||
ts = run.sources.tag_stats
|
||||
print("TAGS fetched=%d from-cache=%d retried-429=%d (cache %s)" % (ts["fetched"], ts["from_cache"], ts["retried_429"], tpath))
|
||||
print("TAGS fetched=%d from-cache=%d retried-429=%d retried-timeout=%d (cache %s)" % (
|
||||
ts["fetched"], ts["from_cache"], ts["retried_429"], ts["retried_timeout"], tpath))
|
||||
print("REQUESTS " + " ".join("%s=%d" % kv for kv in sorted(net.requests.items())))
|
||||
state_path = opts.get("state")
|
||||
state, missing = {}, True
|
||||
|
||||
Reference in new issue
Block a user