293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""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
|