R-1303: watch — abandoned templates are GAP, a seed's unread pins stay pending (no storm after a seed); 38 checks
gates / gates (push) Successful in 4m5s

This commit is contained in:
admin committed 2026-10-26 23:34:29 +01:00
1 parent 1f62d62915
commit 6ee23a3b8f
4 files changed
+54 -4

No files matched your search

+3
View File
@@ -13,6 +13,9 @@
item as a recorded answer), one mail per advisory, the 7-day reminder, a source down one night vs two, the weekly, the
seed, the inventory gate. Red-proved four ways (dedupe, streak, the 30-day window, a swallowed source error).
- `hub/Dockerfile`: a `# upstream:` comment above each `FROM`.
- After the first CI dry run: a `lifecycle: abandoned` template (plant-it) is GAP and never looked up; a pin a seed
could not read (no-key rotation, a source down) stays PENDING and its first real read is recorded silently — no
storm in the nights after a seed, while a pin added later still mails (38 checks; red-proved with an empty pending set).
## 2026-10-26 (evening) — CI: heavy suites by path, no second full gate run, a nightly run (R-1297 A); a decoy hole closed
+29 -1
View File
@@ -385,6 +385,13 @@ def newest_in_channel(tag, names):
def check_item(it, src, today):
"""-> the item with `class`, `advisories`, `not_checked`, `behind`, `notes` filled in."""
if it.get("abandoned"):
it.update({"advisories": [], "not_checked": [], "behind": None, "class": "GAP",
"notes": ["abandoned upstream (lifecycle: abandoned) — no fix will ever come; not looked up"]})
if it.get("problems"):
it["not_checked"].append({"source": "inventory", "why": "; ".join(it["problems"])})
it["class"] = "NOT CHECKED"
return it
ours = version_of(it["tag"])
floating = is_floating(it["tag"])
advs, nc, notes = [], [], []
@@ -394,6 +401,7 @@ def check_item(it, src, today):
if kind in ("github", "advisories"):
if src.rotate and it["group"] == "app" and (zlib.crc32(arg.encode()) + today.toordinal()) % 2:
notes.append("advisories of %s: read tomorrow (no-key rotation)" % arg)
it["adv_unread"] = True
elif floating:
notes.append("advisories of %s: not matched (floating tag)" % arg)
else:
@@ -455,6 +463,7 @@ def check_item(it, src, today):
"link": VULNNOTES[arg]["url"]})
except SourceError as e:
nc.append({"source": source_name(kind, arg), "why": str(e)})
it["adv_unread"] = True
behind, newer_major = None, None
if it.get("no_registry"):
gh = next((a for k, a in it.get("sources") or [] if k == "github"), None)
@@ -525,6 +534,24 @@ def plan(items, state, today, seed=False):
checked_images.add(it["image"])
for a in it.get("advisories", []):
open_now[adv_key(it, a)] = (it, a)
# A seed that could not read some pins (no key: rotation; or a source down) leaves them PENDING: their first real
# read is recorded silently too, so the first nights after a seed are no mail storm. A pin added later is never
# pending, so its advisories mail normally.
pending = set(st.get("seed_pending", []))
if seed:
pending = {it["image"] for it in items if it.get("adv_unread")}
else:
for it in items:
if it["image"] in pending and not it.get("adv_unread"):
for a in it.get("advisories", []):
k = adv_key(it, a)
if k not in known:
known[k] = {"first": iso, "last_mailed": iso, "program": label(it), "summary": a["summary"][:160],
"silent": "first read after the seed"}
would.append({"kind": "advisory", "subject": "(silent, first read after the seed) %s %s" % (
it["image"], a["id"]), "text": ""})
pending.discard(it["image"])
st["seed_pending"] = sorted(pending)
new, remind = [], []
for k, (it, a) in sorted(open_now.items()):
if k not in known:
@@ -627,7 +654,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"), ""))
sorted({"%s — %s" % (it["image"], 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"}))
+5 -3
View File
@@ -135,8 +135,10 @@ def catalog_items(root):
compose = os.path.join(tdir, app, "docker-compose.yml")
if not os.path.isfile(compose):
continue
ups = felhom_upstream_block(_read(os.path.join(tdir, app, ".felhom.yml"))) \
if os.path.isfile(os.path.join(tdir, app, ".felhom.yml")) else {}
meta = _read(os.path.join(tdir, app, ".felhom.yml")) if os.path.isfile(os.path.join(tdir, app, ".felhom.yml")) else ""
ups = felhom_upstream_block(meta)
# `lifecycle: abandoned` — still running on boxes that had it, never updated again (plant-it): listed, not looked up.
abandoned = bool(re.search(r"^lifecycle:\s*[\"']?abandoned", meta, re.M))
seen = set()
for line in _read(compose).splitlines():
m = IMAGE_RX.match(line)
@@ -146,7 +148,7 @@ def catalog_items(root):
if (image, tag) in seen:
continue
seen.add((image, tag))
items.append({"group": "app", "owner": app, "image": image, "tag": tag,
items.append({"group": "app", "owner": app, "image": image, "tag": tag, "abandoned": abandoned,
"where": "templates/%s/.felhom.yml upstream: %s" % (app, image),
"sources_text": ups.get(image)})
return items
+17
View File
@@ -182,6 +182,12 @@ def t_classes():
fb["class"] == "BEHIND" and fb["behind"] == "1.5.7-stable", (fb["class"], fb.get("behind"), fb["advisories"]))
check("W3 redis 7-alpine -> FLOATING", red["class"] == "FLOATING", red["class"])
check("W3 postgres gap -> GAP (BEHIND still computed: no newer 16-…)", pg["class"] == "GAP", pg["class"])
ab = item("msdeluise/plant-it", "0.10.0", "github:MDeLuise/plant-it", group="app")
ab["abandoned"] = True
seen_before = len(FakeNet({}).seen)
run_items([ab], {})
check("W3 an abandoned template -> GAP, nothing looked up (no route needed)", ab["class"] == "GAP" and
not ab["not_checked"] and seen_before == 0, (ab["class"], ab.get("not_checked")))
check("W4 a source that fails -> NOT CHECKED, with the reason", down["class"] == "NOT CHECKED" and
"timed out" in down["not_checked"][0]["why"], down.get("not_checked"))
lines = w.table(items)
@@ -259,6 +265,17 @@ def t_memory():
sm, sst, would = w.plan([adv_item("traefik", "GHSA-1"), down("b")], {}, d(1), seed=True)
check("W7 a seed mails nothing, records the advisory, and says what it would have mailed",
sm == [] and "traefik|GHSA-1" in sst["advisories"] and kinds(would) == ["advisory"], (kinds(sm), kinds(would)))
unread = clean("immich")
unread["adv_unread"] = True
_, sst, _ = w.plan([adv_item("traefik", "GHSA-1"), unread], {}, d(1), seed=True)
check("W7 a pin the seed could not read is PENDING", sst["seed_pending"] == ["immich"], sst.get("seed_pending"))
first = adv_item("immich", "GHSA-im1", group="app")
newapp = adv_item("newapp", "GHSA-na1", group="app")
pm, sst, _ = w.plan([adv_item("traefik", "GHSA-1"), first, newapp], sst, d(2))
check("W7 its first real read is recorded SILENTLY; a pin added later still mails",
"immich|GHSA-im1" in sst["advisories"] and sst["seed_pending"] == [] and
[m for m in pm if m["kind"] == "advisory" and "GHSA-im1" in m["text"]] == [] and
any(m["kind"] == "advisory" and "GHSA-na1" in m["text"] for m in pm), (kinds(pm), sst.get("seed_pending")))
def t_inventory():