Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user