SysDeck/bridge/packages.py

510 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Packages Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Wraps the system package manager (pacman on Arch Linux, dnf/yum on
RPM distros, apt on DEB distros) into a unified JSON interface so the
Packages panel can list, search, install, update, and remove packages
without knowing which distro it runs on.
The package manager is invoked as a separate process via subprocess —
the suite (MIT) and the package manager remain independent programs.
No package-manager code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/packages.py list-installed
python3 /usr/lib/sysdeck/bridge/packages.py list-updates
python3 /usr/lib/sysdeck/bridge/packages.py search <term>
python3 /usr/lib/sysdeck/bridge/packages.py info <name>
python3 /usr/lib/sysdeck/bridge/packages.py install <name>
python3 /usr/lib/sysdeck/bridge/packages.py remove <name>
python3 /usr/lib/sysdeck/bridge/packages.py update <name>
python3 /usr/lib/sysdeck/bridge/packages.py update-all
python3 /usr/lib/sysdeck/bridge/packages.py dry-run <action> [name]
python3 /usr/lib/sysdeck/bridge/packages.py summary
v0.0.31: install / remove / update / update-all now ACTUALLY RUN the
package manager via subprocess. The cockpit JS panel passes
{ superuser: 'try' } to cockpit.spawn so the operator authenticates
via polkit (org.sysdeck.packages.modify action, shipped since v0.0.17,
authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf). No `sudo`
shell-out from JS — this is the cockpit way.
The new `dry-run` subcommand preserves the v0.0.30 command-string-only
return shape for the panel's preview-before-confirm flow.
"""
import json
import os
import re
import subprocess
import sys
from typing import Any
PACMAN_LICENSE = "GPL-2.0+ (pacman)"
PACMAN_AUTHOR = "Pacman Development Team"
PACMAN_URL = "https://archlinux.org/pacman/"
# Detect the system package manager once at import time.
# Step-down: prefer pacman (Arch), then dnf (Fedora), then apt (Debian/Ubuntu).
# The detected manager determines which backend functions are used.
def _detect_pkg_manager() -> str:
"""Return 'pacman', 'dnf', or 'apt' based on what is available."""
for cmd in ("pacman", "dnf", "apt"):
try:
subprocess.run([cmd, "--version"], capture_output=True, check=True)
return cmd
except (subprocess.CalledProcessError, FileNotFoundError):
continue
return "unknown"
PKG_MANAGER = _detect_pkg_manager()
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
# ── Pacman backend ──────────────────────────────────────────────────
def _pacman_list_installed() -> list[dict[str, str]]:
"""List installed packages via pacman -Q."""
raw = run(["pacman", "-Q"])
return [
{"name": parts[0], "version": parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _pacman_list_updates() -> list[dict[str, str]]:
"""List available updates via pacman -Qu."""
raw = run(["pacman", "-Qu"])
return [
{"name": parts[0], "current": parts[1], "new": parts[2] if len(parts) > 2 else parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _pacman_search(term: str) -> list[dict[str, str]]:
"""Search packages via pacman -Ss."""
raw = run(["pacman", "-Ss", term])
results: list[dict[str, str]] = []
for line in raw.splitlines():
# Format: "repo/name version [installed]"
if line.startswith(" ") or not line.strip():
continue
parts = line.split()
if len(parts) >= 2:
name_ver = parts[0]
installed = "[installed]" in line
name = name_ver.split("/")[-1] if "/" in name_ver else name_ver
results.append({"name": name, "version": parts[1], "installed": str(installed).lower()})
return results
def _pacman_info(name: str) -> dict[str, Any]:
"""Package info via pacman -Si."""
raw = run(["pacman", "-Si", name])
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace(" ", "_")] = val.strip()
return info
# ── DNF backend ─────────────────────────────────────────────────────
def _dnf_list_installed() -> list[dict[str, str]]:
"""List installed packages via dnf list installed."""
raw = run(["dnf", "list", "installed", "--quiet"])
return _parse_rpm_list(raw)
def _dnf_list_updates() -> list[dict[str, str]]:
"""List available updates via dnf check-update."""
raw = run(["dnf", "check-update", "--quiet"])
return _parse_rpm_update_list(raw)
def _dnf_search(term: str) -> list[dict[str, str]]:
"""Search packages via dnf search."""
raw = run(["dnf", "search", term, "--quiet"])
results: list[dict[str, str]] = []
for line in raw.splitlines():
if ":" in line and not line.startswith(" "):
parts = line.split(":")
if len(parts) >= 2:
name_ver = parts[0].strip()
name = name_ver.split(".")[0] if "." in name_ver else name_ver
results.append({"name": name, "description": parts[1].strip()})
return results
def _dnf_info(name: str) -> dict[str, Any]:
"""Package info via dnf info."""
raw = run(["dnf", "info", name, "--quiet"])
return _parse_rpm_info(raw, name)
# ── APT backend ─────────────────────────────────────────────────────
def _apt_list_installed() -> list[dict[str, str]]:
"""List installed packages via dpkg-query."""
raw = run(["dpkg-query", "-W", "-f=${Package}\\t${Version}\\n"])
return [
{"name": parts[0], "version": parts[1]}
for line in raw.splitlines()
if (parts := line.split("\t")) and len(parts) >= 2
]
def _apt_list_updates() -> list[dict[str, str]]:
"""List available updates via apt list --upgradable."""
raw = run(["apt", "list", "--upgradable", "-qq"])
return [
{"name": parts[0].split("/")[0], "new": parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _apt_search(term: str) -> list[dict[str, str]]:
"""Search packages via apt search."""
raw = run(["apt-cache", "search", term])
results: list[dict[str, str]] = []
for line in raw.splitlines():
if " - " in line:
name_desc = line.split(" - ", 1)
name_ver = name_desc[0].split()
if name_ver:
results.append({"name": name_ver[0], "description": name_desc[1] if len(name_desc) > 1 else ""})
return results
def _apt_info(name: str) -> dict[str, Any]:
"""Package info via apt show."""
raw = run(["apt-cache", "show", name])
return _parse_apt_info(raw, name)
# ── Shared parsers ──────────────────────────────────────────────────
def _parse_rpm_list(raw: str) -> list[dict[str, str]]:
"""Parse 'name.arch version repo' tabular output."""
results: list[dict[str, str]] = []
for line in raw.splitlines():
parts = line.split()
if len(parts) >= 2 and not line.startswith("Last"):
name = parts[0].split(".")[0] if "." in parts[0] else parts[0]
results.append({"name": name, "version": parts[1]})
return results
def _parse_rpm_update_list(raw: str) -> list[dict[str, str]]:
"""Parse dnf check-update output."""
results: list[dict[str, str]] = []
for line in raw.splitlines():
parts = line.split()
if len(parts) >= 2 and not line.startswith("Last") and not line.startswith(" "):
name = parts[0].split(".")[0] if "." in parts[0] else parts[0]
results.append({"name": name, "new": parts[1]})
return results
def _parse_rpm_info(raw: str, name: str) -> dict[str, Any]:
"""Parse dnf info output into key-value pairs."""
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace(" ", "_")] = val.strip()
return info
def _parse_apt_info(raw: str, name: str) -> dict[str, Any]:
"""Parse apt-cache show output into key-value pairs."""
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace("-", "_")] = val.strip()
return info
# ── Dispatch table per package manager ──────────────────────────────
BACKENDS = {
"pacman": {
"list-installed": lambda _args: _pacman_list_installed(),
"list-updates": lambda _args: _pacman_list_updates(),
"search": lambda args: _pacman_search(args[0]) if args else [],
"info": lambda args: _pacman_info(args[0]) if args else {},
},
"dnf": {
"list-installed": lambda _args: _dnf_list_installed(),
"list-updates": lambda _args: _dnf_list_updates(),
"search": lambda args: _dnf_search(args[0]) if args else [],
"info": lambda args: _dnf_info(args[0]) if args else {},
},
"apt": {
"list-installed": lambda _args: _apt_list_installed(),
"list-updates": lambda _args: _apt_list_updates(),
"search": lambda args: _apt_search(args[0]) if args else [],
"info": lambda args: _apt_info(args[0]) if args else {},
},
}
def list_installed() -> list[dict[str, str]]:
"""List installed packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-installed")
return fn([]) if fn else []
def list_updates() -> list[dict[str, str]]:
"""List available updates using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-updates")
return fn([]) if fn else []
def search(args: list[str]) -> list[dict[str, Any]]:
"""Search packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("search")
return fn(args) if fn else []
def info(args: list[str]) -> dict[str, Any]:
"""Get package info using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("info")
return fn(args) if fn else {}
def _pkg_name_ok(pkg: str) -> bool:
"""v0.1.4 SECURITY: package names are passed to the system package
manager as one argv element. A leading dash turns them into manager
OPTIONS (pacman --config=…, dnf --setopt=…) and a URL makes dnf
fetch a remote RPM — argument injection, not shell injection. One
safe component: no leading dash, no whitespace/control chars, no
URL scheme, bounded length."""
return (
isinstance(pkg, str)
and 0 < len(pkg) <= 256
and not pkg.startswith("-")
and "://" not in pkg
and not re.search(r"[\s\x00\x1b]", pkg)
)
def _first_pkg_arg(args: list[str]) -> str | None:
"""v0.1.4 FIX: firewall.py's install-backend used to call this
helper as `packages.py install -- <pkgs…>` (a `--` argv separator,
shell convention) — install() read args[0] == '--' and the
backend-install path has been broken since it shipped. Skip any
leading '--' separators instead of choking on them."""
for a in args:
if a != "--":
return a
return None
def install(args: list[str]) -> dict[str, str]:
"""Install a package — actually runs the package manager via subprocess.
v0.0.31 REWRITE: previously this returned only the command string
that *would* be run, forcing the JS panel to alert("Run this
command with superuser privileges.") and the operator to copy /
sudo / paste / run. The cockpit way is to run the operation via
the cockpit superuser channel: the JS panel calls cockpit.spawn()
with { superuser: 'try' }, which prompts the operator via polkit
for the org.sysdeck.packages.modify action (shipped since v0.0.17)
that authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf.
The bridge runs the package manager via subprocess with check=True
and streams stdout/stderr line-by-line so the JS panel can render
live output.
The command-string preview shape is preserved as the `dry-run`
subcommand for operators who want to see what would be run.
"""
if not args:
return {"error": "No package name provided"}
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
# _pkg_name_ok already rejects leading-dash/URL names (argument
# injection), so no '--' end-of-options separator is needed here —
# pacman in particular does not accept one.
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no install command for {PKG_MANAGER}"}
# Actually run it. The cockpit bridge runs as the cockpit user; the
# JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit
# prompt the operator for auth and run us as root via polkit.
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def remove(args: list[str]) -> dict[str, str]:
"""Remove a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def update(args: list[str]) -> dict[str, str]:
"""Update a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no update command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def update_all() -> dict[str, str]:
"""Update all packages — actually runs the package manager. See install().
v0.0.31: this is the method called by the Packages panel `Update All`
button. Previously it returned only the command string and the panel
showed alert("Run this command with superuser privileges.") — which
defeated the purpose of having a panel. The cockpit way: the JS panel
calls bridge.packages.updateAll() with superuser: 'try', the bridge
runs pacman/apt/dnf via subprocess, and the result includes the
actual stdout/stderr for the panel to render live.
"""
cmd_map = {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "update-all", "manager": PKG_MANAGER,
"success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "update-all", "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def dry_run(args: list[str]) -> dict[str, str]:
"""Return the command that *would* be run — for the operator preview.
v0.0.31: the install/remove/update/update-all subcommands now
actually execute the package manager. This subcommand preserves
the v0.0.30 behavior (return the command string without running)
so the JS panel can show a preview before the operator confirms.
"""
action = args[0] if args else "update-all"
pkg = args[1] if len(args) > 1 else ""
cmd_map = {
"install": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]},
"remove": {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]},
"update": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]},
"update-all": {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]},
}
sub_map = cmd_map.get(action, {})
cmd = sub_map.get(PKG_MANAGER, [])
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd) if cmd else ""}
def summary() -> dict[str, Any]:
"""Aggregate summary: installed count, update count, manager."""
installed = list_installed()
updates = list_updates()
return {
"manager": PKG_MANAGER,
"installedCount": len(installed),
"updateCount": len(updates),
"updates": updates[:20], # Cap at 20 for the summary view
}
COMMANDS = {
"list-installed": lambda _args: list_installed(),
"list-updates": lambda _args: list_updates(),
"search": lambda args: search(args),
"info": lambda args: info(args),
"install": lambda args: install(args),
"remove": lambda args: remove(args),
"update": lambda args: update(args),
"update-all": lambda _args: update_all(),
# v0.0.31: dry-run preserves the v0.0.30 command-string-only shape
# for the panel's preview-before-confirm flow.
"dry-run": lambda args: dry_run(args),
"summary": lambda _args: summary(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))