289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""Core data structures shared across scanning, risk analysis and reporting."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass, field, asdict
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
|
||
|
|
def utcnow() -> datetime:
|
||
|
|
return datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
|
||
|
|
def iso(dt: datetime | None) -> str | None:
|
||
|
|
return dt.astimezone(timezone.utc).isoformat() if dt else None
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# Inventory
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
|
||
|
|
RELATIONS = {
|
||
|
|
# This item's version must exactly equal the other's, or they will not
|
||
|
|
# talk to each other. Shure TX/RX, Teradek TX/RX, SoundGrid.
|
||
|
|
"must_match",
|
||
|
|
# This item must be at or above the other's version. Control/config tools
|
||
|
|
# over the devices they configure.
|
||
|
|
"must_be_at_least",
|
||
|
|
# This item is the utility that actually flashes the other. Updating the
|
||
|
|
# carrier is step one; it also gates which image the device is offered.
|
||
|
|
"carries_firmware_for",
|
||
|
|
# This item is the platform the other runs on. OS over drivers, DAW over
|
||
|
|
# plugins, console over its expansion cards.
|
||
|
|
"host_of",
|
||
|
|
# This item is a show file, library, preset or calibration artifact
|
||
|
|
# belonging to the other. Usually forward-only.
|
||
|
|
"content_for",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Update classes where "should we install it?" is the wrong question.
|
||
|
|
UPDATE_CLASSES = {
|
||
|
|
"normal", # we choose when to install
|
||
|
|
"vendor_pushed", # arrives whether we approve or not; warn, do not advise
|
||
|
|
"do_not_touch", # licensed contractor only (life safety, rigging, lifts)
|
||
|
|
"expiry", # fails on a date, not a version (licences, certs)
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Coupling:
|
||
|
|
"""A version dependency on another inventory item."""
|
||
|
|
|
||
|
|
id: str
|
||
|
|
relation: str
|
||
|
|
note: str | None = None
|
||
|
|
# For `host_of`: how big a jump counts as a platform change worth
|
||
|
|
# blocking on. Defaults to "major", which suits semver-ish products.
|
||
|
|
#
|
||
|
|
# Set "any" for vendors who put breaking changes in a low-order digit.
|
||
|
|
# NETGEAR's M4250 is the case that forced this: the SNTP removal, the
|
||
|
|
# forced-HTTPS config migration and the Dante spanning-tree network-loop
|
||
|
|
# warning all landed between 13.0.4.x and 13.0.5.x -- a "patch" by any
|
||
|
|
# normal reading, and the single most disruptive update in this fleet.
|
||
|
|
significant: str = "major" # major | minor | any
|
||
|
|
|
||
|
|
# For `must_match`: the vendor's own utility flashes both sides in one
|
||
|
|
# pass, so nobody needs reminding to do the second one.
|
||
|
|
#
|
||
|
|
# Blackmagic's ATEM Setup is the case that prompted this -- it updates the
|
||
|
|
# switcher, the panel and Software Control together, and the user has
|
||
|
|
# never updated them separately. Telling them to "do these in the same
|
||
|
|
# session" every week is noise about a step they cannot skip.
|
||
|
|
#
|
||
|
|
# A real divergence is still reported, and loudly: if these ever stop
|
||
|
|
# matching it means the automatic update FAILED on one of them -- the
|
||
|
|
# panel was offline, or the flash did not take. That is worth knowing
|
||
|
|
# precisely because it should be impossible.
|
||
|
|
auto: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Item:
|
||
|
|
"""One tracked device or piece of software."""
|
||
|
|
|
||
|
|
id: str
|
||
|
|
name: str
|
||
|
|
vendor: str
|
||
|
|
category: str # video | audio | rf | lighting | plugin | os | network
|
||
|
|
installed: str | None = None # what we believe is running right now
|
||
|
|
criticality: str = "normal" # showstopper | high | normal | low
|
||
|
|
source: dict[str, Any] = field(default_factory=dict) # how to find the latest version
|
||
|
|
risk_queries: list[str] = field(default_factory=list) # extra community search terms
|
||
|
|
github_repo: str | None = None # enables issue-based bug signal
|
||
|
|
check_url: str | None = None # human deep-link for manual verification
|
||
|
|
notes: str | None = None
|
||
|
|
tags: list[str] = field(default_factory=list)
|
||
|
|
freeze: bool = False # never recommend updating (e.g. locked show build)
|
||
|
|
|
||
|
|
# -- added after a review found the model could not express the fleet ---
|
||
|
|
|
||
|
|
# "unconfirmed" means the make/model is a placeholder we inferred rather
|
||
|
|
# than something you told us. Update advice is suppressed entirely for
|
||
|
|
# these: searching on a guessed vendor returns a real release note for
|
||
|
|
# the WRONG product, which is worse than saying nothing.
|
||
|
|
status: str = "confirmed" # confirmed | unconfirmed
|
||
|
|
|
||
|
|
# Version dependencies on other items.
|
||
|
|
coupled_with: list[Coupling] = field(default_factory=list)
|
||
|
|
|
||
|
|
# Items sharing an update_group must be planned, updated and frozen as
|
||
|
|
# one set. Per-item freeze is not enough: freezing the ATEM while its
|
||
|
|
# Software Control keeps moving creates the mismatch the freeze existed
|
||
|
|
# to prevent.
|
||
|
|
update_group: str | None = None
|
||
|
|
|
||
|
|
update_class: str = "normal"
|
||
|
|
|
||
|
|
# For update_class "expiry": when the licence/cert/contract lapses.
|
||
|
|
expires: str | None = None
|
||
|
|
|
||
|
|
# Where a person physically looks to read the running version off this
|
||
|
|
# thing. Printed by `avwatch worksheet`, because "go record your installed
|
||
|
|
# versions" is not an instruction anyone can act on -- "front panel,
|
||
|
|
# Setup, then Global" is.
|
||
|
|
version_hint: str | None = None
|
||
|
|
|
||
|
|
# Which inventory file declared this item. Set by the loader, not by the
|
||
|
|
# YAML. Matters because "your own list" and "things AVWatch proposed" are
|
||
|
|
# different kinds of claim and should never be presented as one list.
|
||
|
|
source_file: str | None = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_user_declared(self) -> bool:
|
||
|
|
"""True for items from the main inventory.yaml -- the user's own gear.
|
||
|
|
False for anything from an inventory-*.yaml expansion file."""
|
||
|
|
return self.source_file == "inventory.yaml"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def source_kind(self) -> str:
|
||
|
|
return self.source.get("kind", "manual")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_showstopper(self) -> bool:
|
||
|
|
return self.criticality in ("showstopper", "high")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_confirmed(self) -> bool:
|
||
|
|
return self.status == "confirmed"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def advisable(self) -> bool:
|
||
|
|
"""Whether it is meaningful to recommend installing an update."""
|
||
|
|
return (
|
||
|
|
self.is_confirmed
|
||
|
|
and not self.freeze
|
||
|
|
and self.update_class == "normal"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
# Scan results
|
||
|
|
# --------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class VersionCheck:
|
||
|
|
"""Outcome of asking 'what is the newest version of this?'"""
|
||
|
|
|
||
|
|
item_id: str
|
||
|
|
latest: str | None = None
|
||
|
|
released: datetime | None = None
|
||
|
|
status: str = "unknown" # ok | error | manual | unsupported
|
||
|
|
detail: str | None = None # error text, or a human note
|
||
|
|
release_notes: str | None = None
|
||
|
|
release_url: str | None = None
|
||
|
|
checked_at: datetime = field(default_factory=utcnow)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def age_days(self) -> float | None:
|
||
|
|
if not self.released:
|
||
|
|
return None
|
||
|
|
return (utcnow() - self.released).total_seconds() / 86400.0
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Evidence:
|
||
|
|
"""A single community or vendor post that says something about a release."""
|
||
|
|
|
||
|
|
source: str # reddit | github-issues | hackernews | web | vendor
|
||
|
|
title: str
|
||
|
|
url: str
|
||
|
|
snippet: str = ""
|
||
|
|
published: datetime | None = None
|
||
|
|
score: int | None = None # upvotes / reactions where available
|
||
|
|
sentiment: str = "unknown" # negative | neutral | positive | unknown
|
||
|
|
severity: str = "info" # brick | crash | regression | annoyance | info
|
||
|
|
matched_terms: list[str] = field(default_factory=list)
|
||
|
|
# True when the post actually names the release under consideration.
|
||
|
|
# Any active project has open crash reports; only the ones naming this
|
||
|
|
# version are evidence about *this* release.
|
||
|
|
mentions_version: bool = False
|
||
|
|
|
||
|
|
def to_dict(self) -> dict:
|
||
|
|
d = asdict(self)
|
||
|
|
d["published"] = iso(self.published)
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Verdict:
|
||
|
|
"""The recommendation, plus how it was reached."""
|
||
|
|
|
||
|
|
recommendation: str # go | wait | avoid | manual-check | unknown
|
||
|
|
confidence: str = "low" # high | medium | low
|
||
|
|
engine: str = "heuristic" # heuristic | claude | claude+heuristic
|
||
|
|
headline: str = ""
|
||
|
|
reasons: list[str] = field(default_factory=list)
|
||
|
|
risk_score: float = 0.0 # 0 (safe) .. 100 (do not touch)
|
||
|
|
heuristic_score: float = 0.0
|
||
|
|
heuristic_recommendation: str = "unknown"
|
||
|
|
cited: list[str] = field(default_factory=list) # URLs the reasoning leaned on
|
||
|
|
suggested_wait_days: int | None = None
|
||
|
|
|
||
|
|
def to_dict(self) -> dict:
|
||
|
|
return asdict(self)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class CouplingWarning:
|
||
|
|
"""A version-dependency problem found by comparing two items."""
|
||
|
|
|
||
|
|
item_id: str
|
||
|
|
other_id: str
|
||
|
|
other_name: str
|
||
|
|
relation: str
|
||
|
|
severity: str # blocking | warning | info
|
||
|
|
message: str
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Finding:
|
||
|
|
"""Everything known about one item at one point in time: the version gap,
|
||
|
|
the evidence, and the verdict. This is the unit the report renders."""
|
||
|
|
|
||
|
|
item: Item
|
||
|
|
check: VersionCheck
|
||
|
|
comparison: str = "unknown" # current | behind | ahead | unknown
|
||
|
|
bump: str = "unknown" # major | minor | patch | none | unknown
|
||
|
|
evidence: list[Evidence] = field(default_factory=list)
|
||
|
|
verdict: Verdict | None = None
|
||
|
|
is_new_since_last_run: bool = False
|
||
|
|
coupling: list[CouplingWarning] = field(default_factory=list)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def has_blocking_coupling(self) -> bool:
|
||
|
|
return any(c.severity == "blocking" for c in self.coupling)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def needs_attention(self) -> bool:
|
||
|
|
return self.comparison == "behind" or self.check.status == "error"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def negative_evidence(self) -> list[Evidence]:
|
||
|
|
return [e for e in self.evidence if e.sentiment == "negative"]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def worst_severity(self) -> str:
|
||
|
|
order = ["info", "annoyance", "regression", "crash", "brick"]
|
||
|
|
worst = "info"
|
||
|
|
for e in self.evidence:
|
||
|
|
if order.index(e.severity) > order.index(worst):
|
||
|
|
worst = e.severity
|
||
|
|
return worst
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class RunSummary:
|
||
|
|
"""One scan run, for the store and the digest header."""
|
||
|
|
|
||
|
|
run_id: str
|
||
|
|
started: datetime
|
||
|
|
finished: datetime | None = None
|
||
|
|
items_checked: int = 0
|
||
|
|
updates_found: int = 0
|
||
|
|
errors: int = 0
|
||
|
|
new_since_last: int = 0
|
||
|
|
http_requests: int = 0
|
||
|
|
cache_hits: int = 0
|
||
|
|
mode: str = "scan" # scan | digest
|