Files
weircon-front/versions.py
T
root 6e5bb5eac1
CD / deploy (push) Successful in 24s
CI / check (push) Successful in 32s
feat(ops): værtens apt-sikkerhedsopdateringer på driftssiden
Proxmox VE får 'nyeste' fra værtens apt-kandidat, ny sektion med
sikkerhedspakker/genstartsbehov, ntfy ved nye sikkerhedsopdateringer.
_norm stripper release-præfiks (qBittorrent).

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
2026-10-09 19:25:57 +02:00

314 lines
13 KiB
Python

"""Version-/opdaterings-tjek for homelab-tjenester. Udvidbart register:
- 'kind' = hvor DIN kørende version hentes (authentik/gitea/matomo/assets/None).
- 'repo' = GitHub owner/repo → 'nyeste' release. None = ingen kilde.
Resultater caches 1 time (versioner ændrer sig sjældent; GitHub-rate-limit 60/t)."""
import asyncio
import json
import re
import httpx
import cache
from config import (API_TOKEN, AUTHENTIK, ASSETS_VERSION_URL, MATOMO_BASE, MATOMO_TOKEN,
DATA, GITHUB_TOKEN, NTFY_URL, NTFY_TOPIC, NTFY_TOKEN)
# Kørende versioner for LXC-tjenester portalen ikke selv kan nå — indsamlet på
# værten af portal-versions.py (cron) og pushet hertil. Nøgler = SERVICES-navne.
LOCAL_VERSIONS = DATA / "versions_local.json"
# Sidst-kendte-gode "nyeste"-tags pr. kilde, persisteret til disk. Et fetch-fejl
# (fx GitHub rate-limit 60/t eller en genstart) blanker dermed ALDRIG kolonnen —
# værdien opdateres kun ved et vellykket nyt opslag.
LATEST_STORE = DATA / "latest_versions.json"
def _load_local():
try:
return json.loads(LOCAL_VERSIONS.read_text("utf-8"))
except Exception:
return {}
def _load_latest_store():
try:
return json.loads(LATEST_STORE.read_text("utf-8"))
except Exception:
return {}
def _save_latest_store(d):
try:
LATEST_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")
except Exception:
pass
# (navn, kind til current-version, github-repo til nyeste). Tilføj en linje = ny tjeneste.
SERVICES = [
("Authentik", "authentik", "goauthentik/authentik"),
("Gitea", "gitea", "go-gitea/gitea"),
("Gitea runner", None, "gitea:gitea.com/gitea/runner"),
("Matomo", "matomo", "matomo-org/matomo"),
("Static-assets", "assets", None),
("NPMplus", None, "ZoeyVid/NPMplus"),
("Immich", None, "immich-app/immich"),
("Mealie", None, "mealie-recipes/mealie"),
("Firefly III", None, "firefly-iii/firefly-iii"),
("Nextcloud", None, "nextcloud/server"),
("Sonarr", None, "Sonarr/Sonarr"),
("Radarr", None, "Radarr/Radarr"),
("Prowlarr", None, "Prowlarr/Prowlarr"),
("Bazarr", None, "morpheus65535/bazarr"),
("qBittorrent", None, "qbittorrent/qBittorrent"),
("Overleaf", None, "gitea-tag:gitea.weircon.dk/agw/overleaf-cep"), # cep-fork bruger tags (ikke releases)
("OpenProject", None, "opf/openproject"),
("SFTPGo", None, "drakkan/sftpgo"),
("Opengist", None, "thomiceli/opengist"),
("Home Assistant OS", None, "home-assistant/operating-system"),
("Comentario", None, "gitlab:comentario/comentario"),
("Emby", None, None),
("MariaDB", None, None),
("Proxmox VE", None, None),
]
def _norm(v):
if not v:
return ""
# behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-/release-præfiks + build-metadata
return re.sub(r"^(release-)?[vV]?", "", str(v).strip()).split("+")[0].strip().lower()
async def _latest(src, client, store):
"""Nyeste release-tag. src er enten 'owner/repo' (GitHub, bagud-kompat) eller
'gitlab:group/proj' / 'gitea:host/owner/repo'. `store` = sidst-kendte-gode-dict
(muteres in-place ved succes; bruges som fallback ved fetch-fejl)."""
if not src:
return None
provider, loc = src.split(":", 1) if ":" in src else ("github", src)
ck = f"ver:{provider}:{loc}"
hit = cache.get(ck)
if hit is not None:
return hit or store.get(src) or None
tag = None
try:
if provider == "github":
h = {"Accept": "application/vnd.github+json", "User-Agent": "weircon-portal"}
if GITHUB_TOKEN:
h["Authorization"] = f"Bearer {GITHUB_TOKEN}"
r = await client.get(f"https://api.github.com/repos/{loc}/releases/latest", headers=h)
tag = r.json().get("tag_name") if r.status_code < 300 else None
elif provider == "gitlab":
import urllib.parse
pid = urllib.parse.quote(loc, safe="")
r = await client.get(f"https://gitlab.com/api/v4/projects/{pid}/releases",
params={"per_page": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["tag_name"] if isinstance(j, list) and j else None
elif provider == "gitea":
host, repo = loc.split("/", 1)
r = await client.get(f"https://{host}/api/v1/repos/{repo}/releases",
params={"limit": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["tag_name"] if isinstance(j, list) and j else None
elif provider == "gitea-tag":
# repos der bruger tags i stedet for releases (fx overleaf-cep). Nyeste tag.
host, repo = loc.split("/", 1)
r = await client.get(f"https://{host}/api/v1/repos/{repo}/tags",
params={"limit": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["name"] if isinstance(j, list) and j else None
except Exception:
tag = None
if tag:
store[src] = tag # opdatér sidst-kendte-gode
cache.put(ck, tag, 3600)
return tag
# fetch fejlede → fald tilbage til sidst-kendte-gode (overlever rate-limit/genstart)
cache.put(ck, "", 180) # prøv live igen snart
return store.get(src) or None
# endoflife.date-produkt pr. tjeneste (kun dem der faktisk er tracket der).
EOL_PRODUCTS = {"Proxmox VE": "proxmox-ve", "Nextcloud": "nextcloud",
"MariaDB": "mariadb", "Matomo": "matomo"}
async def _eol(name, cur, client):
"""Slår kørende version op i endoflife.date → {eol-dato, state}. None hvis ikke tracket."""
prod = EOL_PRODUCTS.get(name)
if not prod or not cur:
return None
ck = f"eol:{prod}"
data = cache.get(ck)
if data is None:
try:
r = await client.get(f"https://endoflife.date/api/{prod}.json",
headers={"User-Agent": "weircon-portal", "Accept": "application/json"})
data = r.json() if r.status_code < 300 else []
except Exception:
data = []
cache.put(ck, data, 43200) # 12t — EOL-data ændrer sig sjældent
v = _norm(cur)
best = None
for c in data:
cyc = str(c.get("cycle"))
if v == cyc or v.startswith(cyc + "."):
if best is None or len(cyc) > len(str(best.get("cycle"))):
best = c
if not best:
return None
cycle = str(best.get("cycle"))
# næste major = cyklussen lige over den kørende (endoflife lister nyeste først)
nxt = None
try:
idx = data.index(best)
if idx > 0:
n = data[idx - 1]
nxt = {"cycle": str(n.get("cycle")), "latest": n.get("latest"),
"eol": n.get("eol") if isinstance(n.get("eol"), str) else None}
except Exception:
nxt = None
out = {"cycle": cycle, "next": nxt}
eol = best.get("eol")
if eol is True:
out.update(date=None, state="eol")
return out
if eol is False or eol is None:
out.update(date=None, state="supported")
return out
try:
import datetime
days = (datetime.date.fromisoformat(str(eol)) - datetime.date.today()).days
out.update(date=str(eol), days=days,
state="eol" if days <= 0 else ("soon" if days <= 183 else "supported"))
return out
except Exception:
out.update(date=str(eol), state="supported")
return out
async def _current(kind, client):
try:
if kind == "authentik":
r = await client.get(f"{AUTHENTIK}/api/v3/admin/version/",
headers={"Authorization": f"Bearer {API_TOKEN}"})
d = r.json()
return d.get("version_current"), d.get("version_latest")
if kind == "gitea":
r = await client.get("https://gitea.weircon.dk/api/v1/version")
return r.json().get("version"), None
if kind == "matomo":
r = await client.get(f"{MATOMO_BASE}/index.php", params={
"module": "API", "method": "API.getMatomoVersion", "format": "JSON", "token_auth": MATOMO_TOKEN})
return r.json().get("value"), None
if kind == "assets":
r = await client.get(ASSETS_VERSION_URL)
return r.text.strip()[:40], None
except Exception:
pass
return None, None
def _level(cur, latest):
"""Klassificér en tilgængelig opdatering: major / minor / patch (bugfix)."""
pa = [int(x) for x in re.findall(r"\d+", _norm(cur))]
pb = [int(x) for x in re.findall(r"\d+", _norm(latest))]
n = max(len(pa), len(pb))
pa += [0] * (n - len(pa))
pb += [0] * (n - len(pb))
if not pa or not pb:
return None
if pa[0] != pb[0]:
return "major"
if n > 1 and pa[1] != pb[1]:
return "minor"
return "patch"
async def _one(name, kind, repo, client, local, store):
cur, lat_builtin = await _current(kind, client)
if not cur:
cur = local.get(name) # vært-indsamlet snapshot for LXC-tjenester
# _latest i snapshot'et = nyeste fra værten selv (fx apt-kandidat for pve-manager)
latest = lat_builtin or await _latest(repo, client, store) or local.get("_latest", {}).get(name)
eol = await _eol(name, cur, client)
level = None
if cur and latest:
if _norm(cur) == _norm(latest):
status = "ok"
else:
status = "update"
level = _level(cur, latest)
elif cur:
status = "unknown" # ingen 'nyeste'-kilde
else:
status = "latest-only" # kender kun nyeste, ikke din version
return {"name": name, "current": cur, "latest": latest,
"status": status, "level": level, "eol": eol}
ALERT_STATE = DATA / "alert_state.json"
async def _maybe_alert(res, client):
"""Push en ntfy-besked når en NY opdatering eller EOL dukker op (ikke ved hver poll).
Første kørsel seedes tavst, så man ikke får alle eksisterende på én gang."""
if not (NTFY_URL and NTFY_TOKEN):
return
cur, detail = set(), {}
for v in res:
if v["status"] == "update":
sig = f"upd:{v['name']}:{_norm(v['latest'])}"
cur.add(sig); detail[sig] = f"⬆ {v['name']} {v['current']} -> {v['latest']}"
e = v.get("eol")
if e and e.get("state") in ("eol", "soon"):
sig = f"eol:{v['name']}:{e.get('date')}"
cur.add(sig); detail[sig] = f"⏳ {v['name']} EOL {e.get('date')}"
h = host_updates()
if h and h.get("security"):
sig = "sec:host:" + ",".join(sorted(f"{p['name']}={p['to']}" for p in h["security_pkgs"]))
cur.add(sig); detail[sig] = f"🔒 Proxmox-vært: {h['security']} sikkerhedsopdateringer"
try:
stored = set(json.loads(ALERT_STATE.read_text("utf-8")))
except Exception:
stored = None
if stored is None: # første kørsel: seed tavst
ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8")
return
new = cur - stored
if new:
body = "\n".join(detail[s] for s in sorted(new))
try:
await client.post(f"{NTFY_URL}/{NTFY_TOPIC}",
headers={"Authorization": f"Bearer {NTFY_TOKEN}",
"Title": f"Weircon ops: {len(new)} nye",
"Tags": "package", "Click": "https://weircon.dk/admin/ops",
"Icon": "https://assets.weircon.dk/static/icons/icon.png"},
content=body.encode("utf-8"), timeout=8)
except Exception:
pass
ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8")
def host_updates():
"""Værtens apt-status (indsamlet af portal-versions.py) eller None."""
return _load_local().get("_host")
async def all_versions():
hit = cache.get("ver:all")
if hit is not None:
return hit
local = _load_local()
store = _load_latest_store()
async with httpx.AsyncClient(timeout=8) as client:
res = await asyncio.gather(*[_one(n, k, r, client, local, store) for n, k, r in SERVICES])
await _maybe_alert(res, client)
_save_latest_store(store) # persistér evt. nye sidst-kendte-gode tags
# Hvis "nyeste" mangler for tjenester der HAR en kilde (typisk rate-limit-blip),
# cache kun kort så det prøver igen snart; ellers fuld 30-min cache.
expects = {n for n, k, r in SERVICES if r or k == "authentik"}
incomplete = any(v["name"] in expects and not v["latest"] for v in res)
cache.put("ver:all", res, 180 if incomplete else 1800)
return res