Chapters:
Warn if major
Typical workflow
# Preview updates without touching the system
sudo dnf update --assumeno | tee /tmp/updates.txt
# High-only siren with your org’s rules
python3 update-siren-high.py /tmp/updates.txt --rules rules.yml
# Gate a pipeline/cron: exit 2 if HIGH found
sudo dnf update --assumeno | python3 update-siren-high.py --rules rules.yml --exit-code
# High-only siren with rules
python3 update-siren-high.py /tmp/updates.txt --rules rules.yml
#!/usr/bin/env python3
"""
Update Siren (HIGH only) — with rules.yml support.
Usage:
sudo dnf update --assumeno | python3 update-siren-high.py --rules rules.yml
python3 update-siren-high.py /path/to/updates.txt --rules rules.yml
python3 update-siren-high.py ... --json
python3 update-siren-high.py ... --exit-code # exit 2 if HIGH found
rules.yml keys (all optional):
high_impact_patterns: [regex, ...]
minor_series_is_high: true|false
major_bump_is_high: true|false
danger_words: [ 'EOL', 'ABI', ... ]
package_overrides:
<regex>:
always_high: true|false
treat_minor_series_as_major: true|false
treat_patch_as_series: true|false
"""
import sys, re, json, os
from dataclasses import dataclass
from typing import List, Tuple, Dict, Any
DEFAULT_RULES = {
"high_impact_patterns": [
r"^kernel", r"^glibc", r"^openssl", r"^systemd", r"^linux-firmware",
r"^python(3(\.\d+)?)?$", r"^python3(\.\d+)?", r"^pip", r"^setuptools",
r"^java-.*openjdk", r"^jdk", r"^jre", r"^openjdk"
],
"minor_series_is_high": True,
"major_bump_is_high": True,
"danger_words": ["EOL", "end of life", "retire", "kABI", "ABI", "backward-incompatible", "rebase"],
"package_overrides": {
# Examples:
# r"^kernel": {"treat_patch_as_series": False},
# r"^python3": {"treat_minor_series_as_major": True},
}
}
def load_rules(path: str) -> Dict[str, Any]:
if not path:
return DEFAULT_RULES
text = open(path, "r", encoding="utf-8").read()
# Try YAML if available, else JSON, else very simple YAML-ish parser.
try:
import yaml # type: ignore
data = yaml.safe_load(text)
if not isinstance(data, dict):
return DEFAULT_RULES
return merge_rules(DEFAULT_RULES, data)
except Exception:
# Try JSON
try:
import json
data = json.loads(text)
if not isinstance(data, dict):
return DEFAULT_RULES
return merge_rules(DEFAULT_RULES, data)
except Exception:
# ultra-simple parser for a subset of YAML:
return simple_yaml_like_parse(text, DEFAULT_RULES)
def merge_rules(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
tmp = dict(out[k])
tmp.update(v)
out[k] = tmp
else:
out[k] = v
return out
def simple_yaml_like_parse(text: str, defaults: Dict[str, Any]) -> Dict[str, Any]:
# Handles only top-level keys mapping to lists of strings or booleans, plus
# package_overrides as dict of dicts with booleans.
out = dict(defaults)
current_key = None
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith('#'):
continue
if ':' in line and not line.startswith('-'):
k, _, rest = line.partition(':')
current_key = k.strip()
if rest.strip() in ('true', 'false'):
out[current_key] = (rest.strip() == 'true')
elif rest.strip() == '':
if current_key not in out:
out[current_key] = []
else:
out[current_key] = rest.strip()
elif line.startswith('-') and current_key:
val = line[1:].strip().strip('"').strip("'")
if isinstance(out.get(current_key), list):
out[current_key].append(val)
return out
@dataclass
class UpdateItem:
name: str
old: str
new: str
note: str = ""
def parse_lines(lines: List[str]) -> List[UpdateItem]:
items = []
arrow = re.compile(r'(?P<name>[A-Za-z0-9._+-]+)[^0-9]*(?P<old>\d[^>\s]*)\s*->\s*(?P<new>\d[^\s]*)')
rpm_like = re.compile(r'^(?P<name>[A-Za-z0-9._+-]+)-(?P<new>\d[0-9A-Za-z._+-]*)(?:\.[^. ]+){0,2}$')
for raw in lines:
line = raw.strip()
if not line or line.startswith('#'):
continue
m = arrow.search(line)
if m:
items.append(UpdateItem(m.group('name'), m.group('old'), m.group('new')))
continue
m2 = rpm_like.match(line)
if m2:
items.append(UpdateItem(m2.group('name'), '', m2.group('new')))
continue
parts = line.split()
if len(parts) >= 2:
items.append(UpdateItem(parts[0], parts[1] if parts[1] and parts[1][0].isdigit() else '', parts[-1]))
else:
items.append(UpdateItem(line, '', ''))
return items
def version_tuple(v: str) -> Tuple[int, ...]:
if not v:
return tuple()
cleaned = re.split(r'[^0-9]+', v)
nums = [int(x) for x in cleaned if x.isdigit()]
return tuple(nums)
def first_diff(vo: Tuple[int, ...], vn: Tuple[int, ...]) -> int:
for i, (a, b) in enumerate(zip(vo, vn)):
if a != b:
return i
return -1
def compute_bumps(old: str, new: str, overrides: Dict[str, bool]) -> Dict[str, bool]:
vo = version_tuple(old)
vn = version_tuple(new)
if not vo or not vn:
return {"major": False, "series": False, "patch": False}
idx = first_diff(vo, vn)
if idx == -1:
return {"major": False, "series": False, "patch": False}
major = (idx == 0)
series = (idx == 1) or (overrides.get("treat_patch_as_series") and idx == 2)
patch = (idx >= 2)
if overrides.get("treat_minor_series_as_major") and series:
major = True
return {"major": major, "series": series, "patch": patch}
def match_any(patterns: List[str], text: str) -> bool:
return any(re.search(p, text, re.IGNORECASE) for p in patterns or [])
def get_overrides(pkg: str, rules: Dict[str, Any]) -> Dict[str, bool]:
out = {}
for pat, cfg in (rules.get("package_overrides") or {}).items():
try:
if re.search(pat, pkg, re.IGNORECASE):
if isinstance(cfg, dict):
out.update({k: bool(v) for k, v in cfg.items()})
except re.error:
continue
return out
def is_high(item: UpdateItem, rules: Dict[str, Any]) -> bool:
if not item.new and not item.old:
return False
pkg = item.name
hi = match_any(rules.get("high_impact_patterns") or [], pkg)
if not hi:
return False
ov = get_overrides(pkg, rules)
bumps = compute_bumps(item.old, item.new, ov)
if rules.get("major_bump_is_high", True) and bumps["major"]:
return True
if rules.get("minor_series_is_high", True) and bumps["series"]:
return True
if ov.get("always_high"):
return True
if item.note and match_any(rules.get("danger_words") or [], item.note):
return True
return False
def main():
import argparse
ap = argparse.ArgumentParser(description="Update Siren (HIGH only) with rules.yml")
ap.add_argument('input', nargs='?', help="Path to text file with updates (e.g., from 'dnf update --assumeno').")
ap.add_argument('--rules', help="Path to rules.yml (or JSON).")
ap.add_argument('--json', action='store_true', help="Output JSON.")
ap.add_argument('--exit-code', action='store_true', help="Exit 2 if HIGH found, else 0.")
args = ap.parse_args()
rules = load_rules(args.rules) if args.rules else DEFAULT_RULES
lines = sys.stdin.readlines() if not args.input else open(args.input, 'r', encoding='utf-8').readlines()
items = parse_lines(lines)
highs = []
for it in items:
if is_high(it, rules):
highs.append({
'package': it.name,
'old_version': it.old,
'new_version': it.new,
'reason': 'HIGH via rules'
})
if args.json:
print(json.dumps(highs, indent=2))
else:
if highs:
print(f"{'PACKAGE':30} {'OLD':18} {'NEW':18} REASON")
print('-' * 90)
for r in highs:
print(f"{r['package'][:30]:30} {r['old_version'][:18]:18} {r['new_version'][:18]:18} {r['reason']}")
else:
print("(no HIGH-risk items found)")
if args.exit_code:
sys.exit(2 if highs else 0)
if __name__ == '__main__':
main()
Rules
So you can change them
# Update Siren rules.yml — sample
high_impact_patterns:
- '^kernel'
- '^glibc'
- '^openssl'
- '^systemd'
- '^linux-firmware'
- '^python(3(\.[0-9]+)?)?$'
- '^python3(\.[0-9]+)?'
- '^pip'
- '^setuptools'
- '^java-.*openjdk'
- '^jdk'
- '^jre'
- '^openjdk'
minor_series_is_high: true # e.g., Python 3.10 -> 3.11
major_bump_is_high: true # e.g., 8 -> 11
danger_words:
- 'EOL'
- 'end of life'
- 'retire'
- 'kABI'
- 'ABI'
- 'backward-incompatible'
- 'rebase'
package_overrides:
'^kernel':
treat_patch_as_series: false
'^python3':
treat_minor_series_as_major: true # escalate 3.x -> 3.(x+1) to MAJOR