Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
"""Version-coupling checks.
The reason this module exists: in a live AV fleet the common way an update
breaks a service is not that the release was bad. It is that half a matched
set got flashed. You update the ATEM but not the 2 M/E panel and the panel
drops offline. You update the AD4Q receivers but not the AD1 packs and nothing
links. You update the VX1000 but not the receiving cards and the wall goes
black.
Judging each item on its own merits cannot see any of that, so a per-item
"safe to update" verdict is actively misleading for coupled gear. These checks
run after every item has been scanned and downgrade verdicts accordingly.
The rule that matters most: an item in a coupled set is never reported as
plain "go" on its own. It is "go, together with X and Y".
"""
from __future__ import annotations
import logging
from .models import Coupling, CouplingWarning, Finding, Item
from .versions import compare, parse
log = logging.getLogger(__name__)
def _name(items: dict[str, Item], item_id: str) -> str:
it = items.get(item_id)
return it.name if it else item_id
def _check_one(
item: Item,
link: Coupling,
findings: dict[str, Finding],
items: dict[str, Item],
) -> CouplingWarning | None:
other = items.get(link.id)
if other is None:
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=link.id,
relation=link.relation, severity="info",
message=(f"Declares a {link.relation} dependency on {link.id!r}, "
f"which is not in the inventory. Fix the id or add the item."),
)
other_name = other.name
mine = findings.get(item.id)
theirs = findings.get(link.id)
# --- must_match: exact equality or they will not talk ----------------
if link.relation == "must_match":
a, b = parse(item.installed), parse(other.installed)
if not (a.valid and b.valid):
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="warning",
message=(f"Must run the same version as {other_name}, but one "
f"of the two installed versions is unknown, so the "
f"match cannot be checked. Record both."),
)
if a != b:
# A must_match edge is only meaningful when both sides share a
# numbering scheme. Plenty of vendors number a console and its
# stagebox completely differently (DiGiCo: Stealth Core 22.0.2242
# against rack firmware 1.11), and declaring must_match there
# produces a confident "BROKEN NOW" every single scan -- which
# teaches the reader to ignore the one section that should never
# be ignored. Detect the impossible comparison and report the
# EDGE as suspect rather than the gear as faulty.
if abs(a.parts[0] - b.parts[0]) > 3:
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="info",
message=(
f"This item declares must_match against {other_name}, "
f"but {item.installed} and {other.installed} do not "
f"look like the same numbering scheme. That is almost "
f"certainly a wrong coupling rather than a real "
f"mismatch -- change the edge to a shared "
f"update_group instead, so they are planned together "
f"without their version numbers being compared."
),
)
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="blocking",
message=(f"MISMATCH NOW: running {item.installed} while "
f"{other_name} runs {other.installed}. These must "
f"match exactly"
+ (f" -- {link.note}" if link.note else "")
+ ". Fix this before considering any further update."),
)
# Versions match today; the danger is updating only one of them --
# unless the vendor's utility does both anyway, in which case saying
# so every week is noise about an unskippable step.
if mine and mine.comparison == "behind" and not link.auto:
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="blocking",
message=(f"Must be updated in the same session as "
f"{other_name} (currently both on "
f"{item.installed}). Updating one alone will break "
f"the pair"
+ (f" -- {link.note}" if link.note else "") + "."),
)
return None
# --- must_be_at_least: tool >= device --------------------------------
if link.relation == "must_be_at_least":
rel = compare(other.installed, item.installed)
if rel == "unknown":
return None
if rel == "behind":
# `other` is behind `item` -- that is the wrong way round.
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="blocking",
message=(f"{other_name} is on {other.installed}, below this "
f"item's {item.installed}. It must be at or above "
f"it. Update {other_name} first"
+ (f" -- {link.note}" if link.note else "") + "."),
)
return None
# --- carries_firmware_for: update the tool first ----------------------
if link.relation == "carries_firmware_for":
if theirs and theirs.comparison == "behind":
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="warning",
message=(f"This utility is how {other_name} gets flashed, and "
f"{other_name} is behind. Update this tool first, "
f"then re-check the device -- flashing with a "
f"mismatched tool is its own hazard."),
)
if mine and mine.comparison == "behind":
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="info",
message=(f"Carrier for {other_name}. Update it before "
f"touching that device."),
)
return None
# --- host_of: platform under dependents -------------------------------
if link.relation == "host_of":
if not (mine and mine.comparison == "behind"):
return None
threshold = {
"any": ("major", "minor", "patch", "unknown"),
"minor": ("major", "minor", "unknown"),
"major": ("major",),
}.get(link.significant, ("major",))
if mine.bump in threshold:
size = ("Any update" if link.significant == "any"
else f"A {mine.bump} update")
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="blocking",
message=(f"{size} here is a platform change under "
f"{other_name}. Confirm {other_name} supports the "
f"new version before installing"
+ (f" -- {link.note}" if link.note else "") + "."),
)
return None
# --- content_for: show files and libraries ----------------------------
if link.relation == "content_for":
if theirs and theirs.comparison == "behind":
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="warning",
message=(f"These are show files / library content for "
f"{other_name}, and that engine has an update "
f"pending. Back this up first -- the format change "
f"is usually one-way."),
)
return None
return CouplingWarning(
item_id=item.id, other_id=link.id, other_name=other_name,
relation=link.relation, severity="info",
message=f"Unknown relation {link.relation!r}; not checked.",
)
def annotate(findings: list[Finding], items: dict[str, Item]) -> list[Finding]:
"""Attach coupling warnings and downgrade verdicts that ignore them."""
by_id = {f.item.id: f for f in findings}
for f in findings:
for link in f.item.coupled_with:
warn = _check_one(f.item, link, by_id, items)
if warn:
f.coupling.append(warn)
# Reverse edges: if another item declares a must_match against this
# one, this one inherits the constraint. Otherwise only one half of
# every pair ever gets warned.
for other in items.values():
if other.id == f.item.id:
continue
for link in other.coupled_with:
if link.id != f.item.id or link.relation != "must_match":
continue
if any(c.other_id == other.id for c in f.coupling):
continue
mirrored = Coupling(id=other.id, relation="must_match",
note=link.note)
warn = _check_one(f.item, mirrored, by_id, items)
if warn:
f.coupling.append(warn)
_apply_to_verdict(f, items)
return findings
def _apply_to_verdict(f: Finding, items: dict[str, Item]) -> None:
"""A coupled item must never read as plain 'go'."""
v = f.verdict
if v is None or not f.coupling:
return
blocking = [c for c in f.coupling if c.severity == "blocking"]
warnings = [c for c in f.coupling if c.severity == "warning"]
# Prepended, not appended: the digest truncates the reason list, and a
# dependency that will break the pair matters more than the soak-time
# arithmetic above it.
intro = [f"Coupling: {c.message}" for c in blocking + warnings]
if blocking:
if v.recommendation == "go":
v.recommendation = "wait"
n = len(blocking)
v.headline = (
f"Do not update alone -- {n} version "
f"{'dependencies' if n > 1 else 'dependency'} "
f"must be handled together"
)
# Diminishing: the FIRST blocking dependency is the news. Five
# dependents does not make a platform five times riskier than one
# dependent does -- it just makes it a platform. Stacking linearly
# pinned every OS and DAW row at a flat 100/100, which tells the
# reader nothing and makes the scores incomparable.
v.risk_score = min(
100.0,
v.risk_score + 18.0 + min(18.0, 6.0 * (len(blocking) - 1)),
)
partners = sorted({c.other_name for c in blocking})
intro.append("Plan this as one job with: " + ", ".join(partners) + ".")
elif warnings and v.recommendation == "go":
v.risk_score = min(100.0, v.risk_score + 6.0)
v.headline = v.headline or "Safe to update, but note the dependencies"
v.reasons = intro + list(v.reasons)
def groups(items: dict[str, Item]) -> dict[str, list[Item]]:
"""Items bucketed by update_group, for the report's 'update as a set'
section."""
out: dict[str, list[Item]] = {}
for it in items.values():
if it.update_group:
out.setdefault(it.update_group, []).append(it)
return {k: v for k, v in out.items() if len(v) > 1}
def validate(items: dict[str, Item]) -> list[str]:
"""Structural problems worth reporting at load time."""
from .models import RELATIONS
problems: list[str] = []
for it in items.values():
for link in it.coupled_with:
if link.relation not in RELATIONS:
problems.append(
f"{it.id}: unknown relation {link.relation!r} "
f"(known: {', '.join(sorted(RELATIONS))})"
)
if link.id not in items:
problems.append(
f"{it.id}: coupled_with points at {link.id!r}, "
f"which is not an inventory id"
)
if link.id == it.id:
problems.append(f"{it.id}: coupled to itself")
return problems