diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/updates/test_updatesctl.py | 140 | ||||
| -rwxr-xr-x | desktop/modules/updates/updatesctl | 212 |
2 files changed, 351 insertions, 1 deletions
diff --git a/desktop/modules/updates/test_updatesctl.py b/desktop/modules/updates/test_updatesctl.py index 09c3676..b9e3881 100644 --- a/desktop/modules/updates/test_updatesctl.py +++ b/desktop/modules/updates/test_updatesctl.py @@ -18,6 +18,11 @@ never the live system. Plain asserts, no framework. import importlib.machinery import importlib.util +import json +import os +import shutil +import subprocess +import tempfile from pathlib import Path SCRIPT = Path(__file__).resolve().parent / "updatesctl" @@ -39,4 +44,139 @@ assert u.vercmp("9.018.00_6.18.41", "9.018.00") > 0 assert u.vercmp("3.1-659215d", "3.1") > 0 assert u.vercmp("1.0.1", "1.0rc1") > 0 +# --- fixture tree --- +tmp = Path(tempfile.mkdtemp(prefix="updatesctl-test-")) +home = tmp / "home" + + +def w(path, text=""): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def info(repo_rel, version): + w(home / "Programming/GIT/slackbuilds-superrepo" / repo_rel, + f'PRGNAM="x"\nVERSION="{version}"\n') + + +def sbo(rel, version): + w(tmp / "var/lib/sbopkg/SBo-git" / rel, f'VERSION="{version}"\n') + + +def pkgtxt(entries): + return "".join(f"PACKAGE NAME: {full}.txz\nPACKAGE LOCATION: ./{loc}\n\n" + for loc, full in entries) + + +installed = [ + "kitty-0.49.1-x86_64-1_danix", # built 0.49.2 -> upgrade + "gamescope-3.16.30-x86_64-1_danix", # recipe bumped -> build + "discord-1.0.159-x86_64-1_danix", # recipe behind -> upstream + "claude-code-2.1.278-x86_64-1_SBo", # ahead of SBo -> upstream + "nessus-10.12.4-x86_64-1_SBo", # SBo current -> build + "neovim-0.12.6-x86_64-1_SBo", # current -> nothing + "r8125-9.018.00_6.18.41-x86_64-1_danix", # suffix -> nothing + "solvespace-3.1-659215d-x86_64-1_danix", # dash version -> nothing + "glibc-2.44-x86_64-3", # mirror has -4 -> slackware upgrade + "bash-5.3-x86_64-1", # testing/ newer, blacklisted -> nothing + "dhcp-4.4.3-x86_64-1", # removed upstream -> slackware removed + "foo-1.0-x86_64-1_SBo", # blacklisted build -> nothing +] +for f in installed: + w(tmp / "var/log/packages" / f) + +w(home / ".config/nvchecker/new_ver.json", json.dumps({"version": 2, "data": { + "kitty": {"version": "0.49.2"}, + "gamescope": {"version": "3.16.31"}, + "discord": {"version": "1.0.162"}, + "claude-code": {"version": "2.1.300"}, + "nessus": {"version": "10.12.5"}, + "neovim": {"version": "0.12.6"}, + "r8125": {"version": "9.018.00"}, + "solvespace": {"version": "3.1"}, + "exploitdb": {"version": "2026_10_02"}, # not installed, my recipe behind + "obsidian": {"version": "1.14.4"}, # not installed, SBo behind: hidden + "Typora": {"version": "1.14.10"}, # AppImage behind + "GIMP": {"version": "3.0.6"}, # flatpak behind +}})) + +info("my-slackbuilds/kitty-bin/kitty-bin.info", "0.49.2") +info("my-slackbuilds/gamescope/gamescope.info", "3.16.31") +info("my-slackbuilds/network/discord/discord.info", "1.0.161") +info("my-slackbuilds/r8125/r8125.info", "9.018.00") +info("my-slackbuilds/solvespace/solvespace.info", "3.1") +info("sbo-slackbuilds/system/exploitdb/exploitdb.info", "2026_09_12") +sbo("development/claude-code-bin/claude-code-bin.info", "2.1.267") +sbo("network/nessus/nessus.info", "10.12.5") +sbo("development/neovim/neovim.info", "0.12.4") +sbo("office/obsidian/obsidian.info", "1.13.7") + +w(home / ".local/state/appimage-updater/installed.json", + json.dumps({"Typora": {"version": "1.14.9"}})) +w(tmp / "bin/flatpak", "#!/bin/sh\nprintf 'org.gimp.GIMP\\t3.0.4\\n'\n") +(tmp / "bin/flatpak").chmod(0o755) + +w(tmp / "etc/slackpkg/slackpkgplus.conf", "MIRRORPLUS['danix']=dir://repo/\n") +w(tmp / "repo/PACKAGES.TXT", pkgtxt([ + ("devel/kitty", "kitty-0.49.2-x86_64-1_danix"), + ("games/gamescope", "gamescope-3.16.30-x86_64-1_danix"), +])) + +w(tmp / "etc/slackpkg/mirrors", "# a comment\nfile://mnt/mirror/\n") +w(tmp / "etc/slackpkg/blacklist", '"testing/"\n[0-9]+_SBo\n') +w(tmp / "mnt/mirror/PACKAGES.TXT", pkgtxt([ + ("slackware64/l", "glibc-2.44-x86_64-4"), + ("slackware64/a", "bash-5.3-x86_64-1"), + ("testing/packages", "bash-5.4-x86_64-1"), + ("slackware64/a", "foo-1.1-x86_64-1"), + ("slackware64/l", "libtatsu-1.0.5-x86_64-1"), +])) +w(tmp / "mnt/mirror/ChangeLog.txt", """\ +Wed Oct 14 21:48:34 UTC 2026 +l/glibc-2.44-x86_64-4.txz: Rebuilt. +l/libtatsu-1.0.5-x86_64-1.txz: Upgraded. +n/dhcp-4.4.3-x86_64-1.txz: Removed. ++--------------------------+ +Thu Oct 1 10:00:00 UTC 2026 +x/oldthing-1.0-x86_64-1.txz: Removed. +l/libtatsu-1.0.4-x86_64-1.txz: Added. ++--------------------------+ +Tue Sep 1 10:00:00 UTC 2026 +x/oldthing-1.0-x86_64-1.txz: Added. +""") + + +def run(*args, timeout="5"): + env = {"HOME": str(home), "UPDATESCTL_ROOT": str(tmp), + "UPDATESCTL_NFS_TIMEOUT": timeout, + "PATH": f"{tmp}/bin:/usr/bin:/bin"} + r = subprocess.run(["/usr/bin/python3", str(SCRIPT), *args], + capture_output=True, text=True, env=env, timeout=30) + assert r.returncode == 0, r.stderr + return json.loads(r.stdout) + + +def rows(section): + return {x["name"]: (x["repo"], x["from"], x["to"]) for x in section} + + +d = run() +assert d["errors"] == {}, d["errors"] +assert d["checked"] > 0 +assert rows(d["upgrade"]) == {"kitty": ("personal", "0.49.1", "0.49.2")}, d["upgrade"] +assert rows(d["build"]) == { + "gamescope": ("personal", "3.16.30", "3.16.31"), + "nessus": ("SBo", "10.12.4", "10.12.5"), +}, d["build"] +assert rows(d["upstream"]) == { + "discord": ("personal", "1.0.161", "1.0.162"), + "claude-code": ("SBo", "2.1.278", "2.1.300"), + "exploitdb": ("my-sbo", "2026_09_12", "2026_10_02"), +}, d["upstream"] +assert rows(d["apps"]) == { + "Typora": ("AppImage", "1.14.9", "1.14.10"), + "GIMP": ("flatpak", "3.0.4", "3.0.6"), +}, d["apps"] + +shutil.rmtree(tmp) print("ok") diff --git a/desktop/modules/updates/updatesctl b/desktop/modules/updates/updatesctl index c1e69ed..413bbcd 100755 --- a/desktop/modules/updates/updatesctl +++ b/desktop/modules/updates/updatesctl @@ -67,8 +67,218 @@ def vercmp(a, b): return sign if longer[min(len(x), len(y))].isdigit() else -sign +NVCONF = HOME / ".config/nvchecker/nvchecker.toml" +NEWVER = HOME / ".config/nvchecker/new_ver.json" +SUPER = HOME / "Programming/GIT/slackbuilds-superrepo" +SBO = "/var/lib/sbopkg/SBo-git" +AI_STATE = (Path(os.environ.get("XDG_STATE_HOME") or HOME / ".local/state") + / "appimage-updater/installed.json") + +# Repo directory -> the label nvtable shows for it. +REPOS = [("sbo-slackbuilds", "my-sbo"), ("my-slackbuilds", "personal"), + ("Slackware-Pentesting-Suite", "pentesting")] + +# From nvtable. Stanza name -> installed package name(s), where they differ. +ALIAS = { + "kvantum": ["kvantum-qt5", "kvantum-qt6"], + "metasploit-framework": ["metasploit-framework-bin"], + "Typora": ["typora-bin"], +} + +# From nvtable. Stanza name -> SlackBuild directory, where they differ. +REPO_ALIAS = { + "kitty": ["kitty-bin"], + "claude-code": ["claude-code-bin"], + "kvantum": ["kvantum-qt6"], + "metasploit-framework": ["metasploit-framework-bin"], + "Typora": ["typora-bin"], +} + + +def read(path): + try: + return Path(path).read_text(errors="replace") + except OSError: + return "" + + +def nfs_read(path): + """A file on the NFS share, or None. The mount is hard, so a read during + a NAS outage blocks: it runs in a child that the timeout kills.""" + try: + r = subprocess.run(["cat", str(path)], capture_output=True, + timeout=NFS_TIMEOUT) + except subprocess.TimeoutExpired: + return None + return r.stdout.decode(errors="replace") if r.returncode == 0 else None + + +def split(full): + """name-version-arch-build. Right for Slackware's own packages, whose + versions never contain a dash; tracked ones go through version_in().""" + return full.rsplit("-", 3) + + +def version_in(fulls, names): + """The version of the first of names found among package file names. + + Anchored on "<name>-" followed by a digit, so a version containing a + dash (solvespace-3.1-659215d) survives and a longer sibling + (kvantum-qt5 when asked for kvantum) is never taken for a match.""" + for want in names: + for full in fulls: + stem = full.rsplit("-", 2)[0] + if stem.lower().startswith(want.lower() + "-"): + rest = stem[len(want) + 1:] + if rest[:1].isdigit(): + return rest + return "" + + +def packages_txt(text): + """PACKAGES.TXT as (location, full name) pairs, location without ./""" + out, name = [], None + for line in text.splitlines(): + if line.startswith("PACKAGE NAME:"): + name = re.sub(r"\.t[gx]z$", "", line.split(":", 1)[1].strip()) + elif line.startswith("PACKAGE LOCATION:") and name: + out.append((line.split(":", 1)[1].strip().lstrip("./"), name)) + name = None + return out + + +def local_dir(url): + m = re.match(r"(?:file|dir)://(.*)", url.strip()) + return sysp("/" + m.group(1).lstrip("/")) if m else None + + +def repo_dir(): + m = re.search(r"^MIRRORPLUS\['danix'\]=(\S+)", + read(sysp("/etc/slackpkg/slackpkgplus.conf")), re.M) + return local_dir(m.group(1)) if m else None + + +def info_version(path): + m = re.search(r'^VERSION="(.*)"$', read(path), re.M) + return m.group(1) if m else "" + + +def recipes(): + """SlackBuild directory -> (version, label) over the user's repos.""" + out = {} + for d, label in REPOS: + base = SUPER / d + for inf in sorted(list(base.glob("*/*.info")) + list(base.glob("*/*/*.info"))): + v = info_version(inf) + if v: + out.setdefault(inf.stem, (v, label)) + return out + + +def recipe_of(name, mine): + """(version, label) of the recipe: the user's first, then the SBo clone.""" + cands = [name] + REPO_ALIAS.get(name, []) + ALIAS.get(name, []) + for c in cands: + if c in mine: + return mine[c] + for c in cands: + for inf in sysp(SBO).glob(f"*/{c}/{c}.info"): + v = info_version(inf) + if v: + return v, "SBo" + return "", "" + + +def apps(): + """Lowercased app key -> (version, source) for AppImages and flatpaks.""" + out = {} + try: + for k, v in json.loads(read(AI_STATE) or "{}").items(): + out[k.lower()] = (v.get("version", ""), "AppImage") + except (ValueError, AttributeError): + pass + if shutil.which("flatpak"): + r = subprocess.run(["flatpak", "list", "--app", "--columns=application,version"], + capture_output=True, text=True) + for line in r.stdout.splitlines(): + app, _, v = line.partition("\t") + if app: + out[app.rsplit(".", 1)[-1].lower()] = (v, "flatpak") + return out + + +def row(name, repo, frm, to): + return {"name": name, "repo": repo, "from": frm, "to": to} + + +def tracked(installed, errors): + out = {k: [] for k in ("upgrade", "build", "upstream", "apps")} + try: + data = json.loads(NEWVER.read_text()) + except (OSError, ValueError): + errors["nvchecker"] = "no results" + return out + data = data.get("data", data) + + rd = repo_dir() + pk = nfs_read(rd / "PACKAGES.TXT") if rd else None + if pk is None: + errors["repo"] = "unreachable" + built = [full for _, full in packages_txt(pk or "")] + mine, app = recipes(), apps() + + for name in sorted(data, key=str.lower): + v = data[name] + up = v.get("version", "") if isinstance(v, dict) else str(v) + names = ALIAS.get(name, [name]) + + i = version_in(installed, names) + if not i: + hit = next((app[n.lower()] for n in [name] + names if n.lower() in app), None) + if hit: + if vercmp(hit[0], up) < 0: + out["apps"].append(row(name, hit[1], hit[0], up)) + continue + + r, label = recipe_of(name, mine) + b = version_in(built, [name] + names + REPO_ALIAS.get(name, [])) + label = label or "--" + + if i and b and vercmp(b, i) > 0: + out["upgrade"].append(row(name, label, i, b)) + elif i and r and vercmp(r, up) >= 0 and (not b or vercmp(b, up) < 0): + if vercmp(i, up) < 0: + out["build"].append(row(name, label, b or i, up)) + elif i or (r and label != "SBo"): + # ponytail: base is the newest of recipe, build and install; an + # installed version ahead of every recipe still counts as behind + # upstream when upstream moved past it. + base = max((x for x in (r, b, i) if x), + key=cmp_to_key(vercmp), default="") + if base and vercmp(up, base) > 0: + out["upstream"].append(row(name, label, base, up)) + return out + + def main(): - pass + errors = {} + if "--refresh" in sys.argv[1:]: + # Exit status is not checked: nvchecker exits 0 with failed stanzas + # logged, and a partial run still rewrites new_ver.json. + try: + subprocess.run(["nvchecker", "-c", str(NVCONF)], + capture_output=True, timeout=600) + except (OSError, subprocess.SubprocessError): + errors["nvchecker"] = "refresh failed" + try: + installed = os.listdir(sysp("/var/log/packages")) + except OSError: + installed = [] + doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0} + doc.update(tracked(installed, errors)) + doc["errors"] = errors + json.dump(doc, sys.stdout) + print() if __name__ == "__main__": |
