From 88369532d39b096ea748708f6aa828a20791f398 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Tue, 27 Oct 2026 13:05:50 +0100 Subject: [PATCH 1/4] feat: add optional storage and maintenance module --- .github/workflows/ci.yml | 25 +- .github/workflows/release.yml | 5 +- .gitignore | 5 + CHANGELOG.md | 12 + README.md | 30 +- completions/pman.bash | 2 +- completions/pman.zsh | 1 + docs/maintenance.md | 131 ++++++++ install_dependencies.sh | 279 ++++++++++++------ maintenance/launcher.py | 7 + maintenance/pman-maintenance | 27 ++ maintenance/pman_maintenance/__init__.py | 3 + maintenance/pman_maintenance/actions.py | 265 +++++++++++++++++ maintenance/pman_maintenance/cli.py | 62 ++++ maintenance/pman_maintenance/runner.py | 63 ++++ maintenance/pman_maintenance/sources.py | 94 ++++++ maintenance/pman_maintenance/state.py | 47 +++ maintenance/pman_maintenance/ui.py | 182 ++++++++++++ maintenance/sources.json | 129 ++++++++ .../systemd/pman-maintenance-sources.service | 15 + .../systemd/pman-maintenance-sources.timer | 10 + proxmox-manager.sh | 58 +++- tests/maintenance/test_actions.py | 104 +++++++ tests/maintenance/test_core.py | 30 ++ tests/maintenance/test_installer.py | 92 ++++++ tests/maintenance/test_runner.py | 30 ++ tests/maintenance/test_sources.py | 44 +++ tests/maintenance/test_ui.py | 14 + tools/build-release.sh | 18 ++ 29 files changed, 1677 insertions(+), 107 deletions(-) create mode 100644 docs/maintenance.md create mode 100644 maintenance/launcher.py create mode 100755 maintenance/pman-maintenance create mode 100644 maintenance/pman_maintenance/__init__.py create mode 100644 maintenance/pman_maintenance/actions.py create mode 100644 maintenance/pman_maintenance/cli.py create mode 100644 maintenance/pman_maintenance/runner.py create mode 100644 maintenance/pman_maintenance/sources.py create mode 100644 maintenance/pman_maintenance/state.py create mode 100644 maintenance/pman_maintenance/ui.py create mode 100644 maintenance/sources.json create mode 100644 maintenance/systemd/pman-maintenance-sources.service create mode 100644 maintenance/systemd/pman-maintenance-sources.timer create mode 100644 tests/maintenance/test_actions.py create mode 100644 tests/maintenance/test_core.py create mode 100644 tests/maintenance/test_installer.py create mode 100644 tests/maintenance/test_runner.py create mode 100644 tests/maintenance/test_sources.py create mode 100644 tests/maintenance/test_ui.py create mode 100755 tools/build-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bdc091..ec52988 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: echo "No shell scripts found." exit 0 fi - shellcheck -x "${files[@]}" + shellcheck -x "${files[@]}" maintenance/pman-maintenance - name: Download, verify, and run shfmt env: @@ -50,7 +50,28 @@ jobs: --output "${shfmt_path}" printf '%s %s\n' "${SHFMT_SHA256}" "${shfmt_path}" | sha256sum --check --strict chmod 0755 "${shfmt_path}" - "${shfmt_path}" -d proxmox-manager.sh + "${shfmt_path}" -d proxmox-manager.sh install_dependencies.sh maintenance/pman-maintenance tools/build-release.sh - name: Run tests run: bash tests/run.sh + + maintenance: + name: Maintenance Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.13'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python }} + - name: Native adapter, source-check and installer tests + run: python3 -B -m unittest discover -s tests/maintenance -v + - name: Build and verify release bundle + run: | + bash tools/build-release.sh dist + cd dist + sha256sum -c SHA256SUMS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40f1fad..7d770d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,11 +23,14 @@ jobs: with: persist-credentials: false + - name: Build core and optional module assets + run: bash tools/build-release.sh dist + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" proxmox-manager.sh \ + gh release create "$GITHUB_REF_NAME" dist/* \ --verify-tag \ --title "$GITHUB_REF_NAME" \ --notes "" diff --git a/.gitignore b/.gitignore index 6312583..7b97005 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,8 @@ CLAUDE.md # Misc local artifacts *.bak *.tmp + +# Optional maintenance Python caches and release assets +__pycache__/ +*.py[cod] +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e106181..1a354cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## [2.14.0] - 2026-10-27 + +- Add an optional Python/curses storage and maintenance module with a small Bash + dispatcher (`--maintenance`, main-menu `m`, and guest-menu `m`). +- Add host storage/SMART diagnostics, scoped CT/VM adapters, confirmed TRIM and + Debian/Ubuntu CT package-cache cleanup. No automatic guest starts or broad cleanup. +- Install the module independently with versioned atomic deployment and rollback; + base installation no longer installs unrelated optional packages automatically. +- Track declared Community Scripts reference revisions with a daily metadata-only + checker. Execute native local adapters, never downloaded upstream code. +- Add independent module bundles/checksums and Python/installer regression tests. + ## [Unreleased] ## [2.13.2] - 2026-10-21 diff --git a/README.md b/README.md index d86b4c7..c5d0c1f 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,21 @@ ║ ██████╔╝ ██╔████╔██║ ███████║ ██╔██╗ ██║ ║ ║ ██╔═══╝ ██║╚██╔╝██║ ██╔══██║ ██║╚██╗██║ ║ ║ ██║ ██║ ╚═╝ ██║ ██║ ██║ ██║ ╚████║ ║ -║ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ v2.13.2 ║ +║ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ v2.14.0 ║ ║ ║ ║ Proxmox VM/CT Manager · Single Bash · No Dependencies ║ ╚══════════════════════════════════════════════════════════╝ ``` **Single-file Bash tool for managing Proxmox VMs and containers.** -No daemons. No agents. No dependencies beyond what ships with Proxmox VE. +The core needs no daemon or guest agent installation and no dependencies beyond +what ships with Proxmox VE. The optional maintenance module adds a daily, disableable +public source-metadata check; its diagnostic and maintenance actions use local commands. -**New in v2.13.2:** ⚡ **Stop** now shuts guests down cleanly (`qm|pct shutdown`, +**New in v2.14.0:** optional storage and maintenance TUI (Python standard library), +installed separately with `--with-maintenance`. See [maintenance module](docs/maintenance.md). + +**v2.13.2:** ⚡ **Stop** now shuts guests down cleanly (`qm|pct shutdown`, force stop only after the timeout) instead of pulling the plug. **v2.13.0:** 🩺 health view per guest and 🔔 ntfy / e-mail alerts from a cron check — [set up alerts in 3 steps](#-quick-start-alerts-in-3-steps). @@ -170,7 +175,7 @@ from cron. └───────────────────────────────────────────────────────┘ ``` -> ✅ No network calls (unless alerts are configured) · ✅ No background process · +> ✅ Core: no network calls (unless alerts are configured) · ✅ No background process · > ✅ Bash ≥ 4.0 · ✅ PVE 7.x / 8.x / 9.x --- @@ -201,6 +206,15 @@ chmod +x proxmox-manager.sh ./install_dependencies.sh ``` +The installer defaults to the core in noninteractive mode and asks whether to add +the maintenance module in a terminal. It does not automatically install optional +packages such as `virt-viewer`, `jq` or development tools. + +```bash +./install_dependencies.sh --with-maintenance +pman --maintenance +``` + Installs an atomic, root-owned copy at `/usr/local/bin/pman` so later edits to the checkout cannot change the privileged executable. @@ -219,6 +233,7 @@ the checkout cannot change the privileged executable. | `pman --timeout 30` | Graceful shutdown timeout before force stop, in seconds (default: 60) | | `pman --no-clear` | Don't clear screen in interactive mode | | `pman --once` | Run a single interactive refresh cycle (useful for TTY recording) | +| `pman --maintenance` | Optional arrow-key storage/maintenance TUI for the host; guest menus open the selected CT/VM | | `pman --health` | Health overview: CPU / MEM / DISK %, uptime and level per guest (`--list`, `--json` combinable) | | `pman --check` | Health check for cron: alerts on changes, exit `0` OK · `1` WARN · `2` CRIT · `3` UNKNOWN | | `pman --check --dry-run` | Show the check result and the message that would be sent; nothing is sent or saved | @@ -500,7 +515,12 @@ cp completions/pman.zsh ~/.zsh/completions/_pman ## 📋 Changelog -### 🆕 [v2.13.2](CHANGELOG.md) — 2026-10-21 +### 🆕 [v2.14.0](CHANGELOG.md) — 2026-10-27 + +> Optional arrow-key storage and maintenance module, scoped CT/VM actions, +> explicit confirmations, source provenance and daily metadata-only checks. + +### [v2.13.2](CHANGELOG.md) — 2026-10-21 > **Stop** is a graceful shutdown (`qm|pct shutdown --timeout --forceStop 1`); > Proxmox force-stops only after `--timeout`. Previously `qm|pct stop` cut the diff --git a/completions/pman.bash b/completions/pman.bash index 3aade28..f4f30cd 100644 --- a/completions/pman.bash +++ b/completions/pman.bash @@ -11,7 +11,7 @@ _pman() { prev="${COMP_WORDS[COMP_CWORD-1]}" } - local all_flags="--list --json --filter --name --no-clear --once --timeout --force --health --check --dry-run --test-notify --version --help" + local all_flags="--list --json --filter --name --no-clear --once --timeout --force --maintenance --health --check --dry-run --test-notify --version --help" case "$prev" in --filter) diff --git a/completions/pman.zsh b/completions/pman.zsh index f45633a..2bd180e 100644 --- a/completions/pman.zsh +++ b/completions/pman.zsh @@ -21,6 +21,7 @@ _pman() { '--timeout[Shutdown timeout in seconds before force stop]:seconds:(10 30 60 120 300)' \ '--name[Filter by VM/CT name (ERE substring-match)]:pattern:' \ '--force[Skip all confirmation prompts]' \ + '--maintenance[Open optional host maintenance module]' \ '--health[Show CPU/memory/disk health of local VMs/CTs]' \ '--check[Run health checks for cron (exit 0/1/2/3)]' \ '--dry-run[With --check: print result and message, send nothing]' \ diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..2afa5f8 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,131 @@ +# Optional storage and maintenance module + +The `pman` core remains a standalone Bash file. The optional module runs on the +Proxmox host using Python 3.9+ and its standard `curses` library. It installs no +software inside guests and uses no pip packages. The existing health cron is +not changed. + +## Install and start + +From the matching repository checkout or unpacked release bundle: + +```bash +./install_dependencies.sh --with-maintenance +pman --maintenance +``` + +Interactive installation asks about the module, defaulting to **No**. Without a +terminal, installation defaults to the core. `--without-maintenance` installs the +core and leaves an existing module alone. The installer performs no `apt` package +installation. If SMART support is missing, the UI explains that `smartmontools` +is optional and must be installed by the administrator. + +From the main menu, press `m` for host maintenance. From a VM/CT action menu, +press `m` to open maintenance for exactly that guest. The module can also run +independently: + +```bash +pman-maintenance --scope ct --id 101 +pman-maintenance --scope vm --id 100 +pman-maintenance --scope host --report storage +pman-maintenance --scope ct --id 101 --report volumes --json +``` + +Use arrow keys to select, Enter to open, Page Up/Down to scroll, and Esc to +cancel/back. `q` exits. CT directory scans use Tab to select `/var`, `/home`, +`/opt`, or `/srv`; the scan stays within the selected filesystem. Resizing and +`NO_COLOR=1` are supported. A UTF-8 terminal is required; the module restores a +UTF-8 locale even when the Bash core runs with `LC_ALL=C`. + +## Available actions + +| Scope | Diagnostics | Confirmed changes | +| --- | --- | --- | +| Host | Storage/filesystems/inodes, LVM thin pools and metadata, ZFS if installed, SMART history, TRIM schedules/logs, kernel packages, backup storage | None | +| CT | Configured volumes resolved through `pvesm path`, filesystem/inode use, bounded directory scans | `pct fstrim ID`; Debian/Ubuntu `apt-get clean` | +| VM | Configured disks/storage/discard, filesystem information via QEMU Guest Agent | Guest-agent TRIM on recognised Linux distributions | + +CT/VM operations verify the selected guest and running state immediately before +execution. Guests are never automatically started. Missing commands, an inactive +QEMU Guest Agent and unsupported guest operating systems are reported as +unavailable. VM filesystem output can include read-only filesystems at 100%; it +is diagnostic information and is not automatically interpreted as an alarm. + +The UI shows the target, exact command and impact before a change. Only `j` +confirms it; the core's `--force` does not bypass this. Cancelling a running +change is not a rollback. A cancelled CT directory scan may continue inside the +guest until its independent 55-second timeout; the outer command is also bounded. +Changes are serialized by a private lock. There is no unattended mutation CLI. + +TRIM reports unused blocks to lower storage layers. Its reported byte count is +not a guarantee of newly available pool space. Guest filesystem use, thin-volume +allocation and pool data/metadata use are displayed separately. Volume names +are read from the actual guest configuration, not guessed from the VMID. + +No broad log deletion, kernel removal, backup deletion or filesystem/partition +reconfiguration is provided in this first version. + +## Community Scripts provenance and freshness + +All adapters are independent local implementations using native commands. +Community Scripts are declared as reviewed functional references where relevant; +no upstream script, runtime library or telemetry is downloaded or executed. +`maintenance/sources.json` records each adapter's origin and reference sources, +reviewed commits, Git blob checksums and upstream licence. The UI's provenance +view exposes these details and the last source-check result. + +The installed `pman-maintenance-sources.timer` runs daily with random delay. +It requests public GitHub **directory listings containing file metadata**, not +script contents. It compares relevant file blob hashes with the reviewed +manifest, including referenced Core dependencies. ETag requests reduce traffic. +No credentials, guest data or SMART history are sent. Results include timestamps +and one of: `geprüfter Stand`, `Upstream geändert`, `Quelle fehlt`, or +`Prüfung fehlgeschlagen`. A failed network/API request never means "up to date". +A missing file triggers review, not an automatic search for a replacement URL. + +```bash +pman-maintenance --check-sources +pman-maintenance --sources +systemctl disable --now pman-maintenance-sources.timer +``` + +To install without enabling the timer: + +```bash +./install_dependencies.sh --with-maintenance --no-source-timer +``` + +A manually disabled timer stays disabled on upgrades. To re-enable it explicitly: + +```bash +systemctl enable --now pman-maintenance-sources.timer +``` + +An upstream change is a review signal. Code and reviewed checksums are updated +only in a new module release after compatibility review; checksums are an +integrity identifier, not a security assessment. Ordinary maintenance actions +work offline. The core's existing opt-in notification networking is separate. + +## Updates, rollback and removal + +Use a reviewed matching checkout/release and rerun the installation command. +Packages are root-owned under `/usr/local/lib/pman/maintenance/releases/`; +`current` switches atomically and `previous` supports rollback. The isolated +Python launcher ignores `PYTHONPATH` and checks ownership and write permissions. +An incompatible interface is rejected by the core. + +```bash +./install_dependencies.sh --rollback-maintenance +./install_dependencies.sh --uninstall-maintenance +``` + +Removal stops/removes only the module and its source-check service/timer. It +retains the core, health configuration and private diagnostic state under +`/var/lib/pman-maintenance` (SMART baseline and source-check cache). + +Release assets include the core, installer, optional module bundle and +`SHA256SUMS`. The bundle contains its matching core and installer for deployment. +Verify downloaded assets against the release checksums before installation. +For development, run `python3 -I -B maintenance/launcher.py --interface` or start +the same launcher as root on a Proxmox test host. `--destdir` stages installation +files without touching services; it is intended for packaging/tests. diff --git a/install_dependencies.sh b/install_dependencies.sh index befdb2e..9cef19a 100755 --- a/install_dependencies.sh +++ b/install_dependencies.sh @@ -1,115 +1,204 @@ #!/usr/bin/env bash -# install_dependencies.sh — install optional helpers and global pman command for proxmox-manager -# Idempotent install script for Debian/Proxmox VE nodes. -# -# Optional packages installed: -# curl — general HTTP client -# git — version control -# sc-shellcheck — Bash static analysis (CI/dev) -# virt-viewer — SPICE/VNC viewer (for --spice workflow) -# jq — JSON processor (for --json output) - +# Install pman core and optionally its independent maintenance package. set -euo pipefail - -# --------------------------------------------------------------------------- -# Root check — must run as root to install system packages -# --------------------------------------------------------------------------- -if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then - printf 'Error: %s must be run as root.\n' "$(basename "$0")" >&2 - printf 'Try: sudo %s\n' "$0" >&2 +umask 022 +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +WITH_MAINTENANCE=auto +TIMER=auto +ACTION=install +DESTDIR='' +usage() { + cat <<'EOF' +Usage: ./install_dependencies.sh [--with-maintenance|--without-maintenance] + [--no-source-timer] [--uninstall-maintenance|--rollback-maintenance] + [--destdir DIRECTORY] +No package installation is performed. --destdir stages files only (no services). +EOF +} +while (($#)); do + case "$1" in + --with-maintenance) WITH_MAINTENANCE=yes ;; + --without-maintenance) WITH_MAINTENANCE=no ;; + --no-source-timer) TIMER=0 ;; + --uninstall-maintenance) ACTION=uninstall ;; + --rollback-maintenance) ACTION=rollback ;; + --destdir) + (($# >= 2)) || { + usage + exit 2 + } + DESTDIR="$2" + if [[ "$DESTDIR" != /* || "$DESTDIR" == / || "$DESTDIR" != "$(realpath -m -- "$DESTDIR")" ]]; then + echo 'Use a normalized absolute staging directory, not / or a path alias.' >&2 + exit 2 + fi + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; + esac + shift +done +if [[ -z "$DESTDIR" && "${EUID:-$(id -u)}" != 0 ]]; then + echo 'Run as root on the Proxmox host, or use --destdir for staging.' >&2 exit 1 fi - -# --------------------------------------------------------------------------- -# Logging helpers -# --------------------------------------------------------------------------- -_log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; } -_ok() { printf '[%s] OK: %s\n' "$(date '+%H:%M:%S')" "$*"; } -_err() { printf '[%s] Error: %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } - -# --------------------------------------------------------------------------- -# Detect package manager -# --------------------------------------------------------------------------- -if ! command -v apt-get >/dev/null 2>&1; then - _err "apt-get not found. This script requires a Debian-based system." +if [[ -z "$DESTDIR" ]] && ! command -v pveversion >/dev/null 2>&1; then + echo 'No Proxmox VE host detected.' >&2 exit 1 fi - -# --------------------------------------------------------------------------- -# Check which packages are already installed -# --------------------------------------------------------------------------- -PKGS=(curl git shellcheck virt-viewer jq) -MISSING=() - -for pkg in "${PKGS[@]}"; do - if dpkg -s "$pkg" >/dev/null 2>&1; then - _log "Already installed: $pkg" - else - _log "Will install: $pkg" - MISSING+=("$pkg") +# Validate every installation parent before writing, including existing staging paths. +secure_dir() { + local path="$1" owner mode + [[ "$path" == / ]] || secure_dir "$(dirname -- "$path")" + if [[ ! -e "$path" && ! -L "$path" ]]; then + mkdir -- "$path" fi -done - -# --------------------------------------------------------------------------- -# Install missing packages -# --------------------------------------------------------------------------- -if [[ "${#MISSING[@]}" -eq 0 ]]; then - _ok "All optional packages are already installed." -else - _log "Updating package lists..." - if ! apt-get update -qq; then - _err "apt-get update failed. Check your network connection or /etc/apt/sources.list." + [[ -d "$path" && ! -L "$path" ]] || { + echo "Unsafe directory: $path" >&2 exit 1 + } + if [[ -z "$DESTDIR" ]]; then + owner=$(stat -Lc %u -- "$path") + mode=$(stat -Lc %a -- "$path") + if [[ "$owner" != 0 || ! "$mode" =~ ^[0-7]{3,4}$ ]] || (((8#$mode & 8#022) != 0)); then + echo "Unsafe permissions: $path" >&2 + exit 1 + fi fi +} - _log "Installing: ${MISSING[*]}" - if ! apt-get install -y "${MISSING[@]}"; then - _err "Package installation failed for: ${MISSING[*]}" +base="$DESTDIR/usr/local/lib/pman/maintenance" +bin="$DESTDIR/usr/local/bin" +units="$DESTDIR/etc/systemd/system" +# Validate all destination chains before any install, rollback or removal. +secure_dir "$bin" +secure_dir "$units" +secure_dir "$(dirname -- "$base")" +if [[ -e "$base" || -L "$base" ]]; then secure_dir "$base"; fi +atomic_file() { + local src="$1" dst="$2" mode="$3" temp + secure_dir "$(dirname -- "$dst")" + temp=$(mktemp "$(dirname -- "$dst")/.pman-install.XXXXXX") + if ! install -m "$mode" -- "$src" "$temp" || ! cmp -s -- "$src" "$temp"; then + rm -f -- "$temp" exit 1 fi - _ok "Installed: ${MISSING[*]}" + mv -fT -- "$temp" "$dst" +} +link_package() { + local target="$1" name="$2" + [[ "$target" =~ ^releases/[A-Za-z0-9._-]+$ && -d "$base/$target" && ! -L "$base/$target" ]] || { + echo 'Invalid module release.' >&2 + exit 1 + } + ln -s -- "$target" "$base/.$name.$$" + mv -fT -- "$base/.$name.$$" "$base/$name" +} +# Preserve a manually disabled checker on upgrades; enable it on first installation. +TIMER_MASKED=0 +if [[ -L "$units/pman-maintenance-sources.timer" && "$(readlink -- "$units/pman-maintenance-sources.timer")" == /dev/null ]]; then + TIMER_MASKED=1 fi - -# --------------------------------------------------------------------------- -# Install pman — root-owned copy in /usr/local/bin -# --------------------------------------------------------------------------- -SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" -PMAN_SRC="${SCRIPT_DIR}/proxmox-manager.sh" -PMAN_DIR="/usr/local/bin" -PMAN_DST="/usr/local/bin/pman" - -if [[ ! -f "$PMAN_SRC" ]]; then - _err "proxmox-manager.sh not found at ${PMAN_SRC}. Run install_dependencies.sh from the repo root." - exit 1 +if [[ "$TIMER" == auto ]]; then + TIMER=1 + if [[ -z "$DESTDIR" && -f "$units/pman-maintenance-sources.timer" ]]; then + if ! systemctl is-enabled --quiet pman-maintenance-sources.timer; then TIMER=0; fi + fi fi - -if [[ ! -d "$PMAN_DIR" || -L "$PMAN_DIR" ]]; then - _err "${PMAN_DIR} must be an existing regular directory." - exit 1 +if [[ "$ACTION" == uninstall ]]; then + if [[ -z "$DESTDIR" ]]; then + systemctl disable --now pman-maintenance-sources.timer || true + systemctl stop pman-maintenance-sources.service || true + fi + secure_dir "$(dirname -- "$base")" + [[ ! -L "$base" ]] || { + echo 'Unsafe module directory.' >&2 + exit 1 + } + rm -rf -- "$base" + rm -f -- "$bin/pman-maintenance" "$units/pman-maintenance-sources.service" "$units/pman-maintenance-sources.timer" + [[ -n "$DESTDIR" ]] || systemctl daemon-reload + echo 'Maintenance module removed; pman core, health cron and diagnostic state retained.' + exit 0 fi - -PMAN_DIR_OWNER="$(stat -Lc '%u' -- "$PMAN_DIR" 2>/dev/null || printf 'invalid')" -PMAN_DIR_MODE="$(stat -Lc '%a' -- "$PMAN_DIR" 2>/dev/null || printf 'invalid')" -if [[ "$PMAN_DIR_OWNER" != "0" || ! "$PMAN_DIR_MODE" =~ ^[0-7]{3,4}$ ]] || ((8#$PMAN_DIR_MODE & 8#022)); then - _err "${PMAN_DIR} must be root-owned and not group/world-writable." - exit 1 +if [[ "$ACTION" == rollback ]]; then + secure_dir "$base" + previous=$(readlink -- "$base/previous") + current=$(readlink -- "$base/current") + link_package "$previous" current + link_package "$current" previous + echo 'Maintenance module rolled back.' + exit 0 fi - -PMAN_TMP="$(mktemp -p "$PMAN_DIR" '.pman.XXXXXX')" || { - _err "Could not create a temporary install file in ${PMAN_DIR}." +if [[ "$WITH_MAINTENANCE" == auto ]]; then + WITH_MAINTENANCE=no + if [[ -t 0 && -t 1 ]]; then + read -r -p 'Speicher- und Wartungsmodul zusätzlich installieren? [j/N] ' answer + [[ "$answer" =~ ^[jJyY]$ ]] && WITH_MAINTENANCE=yes + fi +fi +command -v python3 >/dev/null || { + echo 'python3 is required (bundled with PVE).' >&2 exit 1 } - -if ! install -o root -g root -m 0755 "$PMAN_SRC" "$PMAN_TMP" || ! cmp -s "$PMAN_SRC" "$PMAN_TMP"; then - rm -f -- "$PMAN_TMP" - _err "Failed to stage a verified pman executable." - exit 1 +if [[ "$WITH_MAINTENANCE" == yes ]]; then + python3 -I -B -c 'import curses' || { + echo 'Python curses support missing; module not installed.' >&2 + exit 1 + } fi -if ! mv -fT -- "$PMAN_TMP" "$PMAN_DST"; then - rm -f -- "$PMAN_TMP" - _err "Failed to install ${PMAN_DST}." - exit 1 +atomic_file "$SCRIPT_DIR/proxmox-manager.sh" "$bin/pman" 0755 +if [[ "$WITH_MAINTENANCE" == yes ]]; then + version=$(python3 -I -B "$SCRIPT_DIR/maintenance/launcher.py" --version) + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + secure_dir "$base/releases" + stage=$(mktemp -d "$base/releases/.stage.XXXXXX") + trap '[[ -z "${stage:-}" ]] || rm -rf -- "$stage"' EXIT + install -m 0644 "$SCRIPT_DIR/maintenance/launcher.py" "$SCRIPT_DIR/maintenance/sources.json" "$stage/" + mkdir "$stage/pman_maintenance" + for source in "$SCRIPT_DIR"/maintenance/pman_maintenance/*.py; do + install -m 0644 "$source" "$stage/pman_maintenance/" + done + chmod 0755 "$stage" "$stage/pman_maintenance" + python3 -I -B "$stage/launcher.py" --interface >/dev/null + # A content digest permits multiple development builds without modifying an installed release. + digest=$(cd "$stage" && find . -type f \( -name '*.py' -o -name sources.json \) -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12) + release="$version-$digest" + if [[ ! -e "$base/releases/$release" ]]; then + mv -T -- "$stage" "$base/releases/$release" + else + rm -rf -- "$stage" + fi + stage='' + if [[ -L "$base/current" ]]; then + old=$(readlink -- "$base/current") + [[ "$old" == "releases/$release" ]] || link_package "$old" previous + fi + link_package "releases/$release" current + atomic_file "$SCRIPT_DIR/maintenance/pman-maintenance" "$bin/pman-maintenance" 0755 + secure_dir "$DESTDIR/var/lib/pman-maintenance" + chmod 0700 "$DESTDIR/var/lib/pman-maintenance" + for name in service timer; do + if [[ "$name" == timer && "$TIMER_MASKED" == 1 ]]; then continue; fi + atomic_file "$SCRIPT_DIR/maintenance/systemd/pman-maintenance-sources.$name" "$units/pman-maintenance-sources.$name" 0644 + done + if [[ -z "$DESTDIR" ]]; then + systemctl daemon-reload + if ((TIMER_MASKED)); then + echo 'Source metadata timer remains masked.' + elif ((TIMER)); then + systemctl enable --now pman-maintenance-sources.timer + else + systemctl disable --now pman-maintenance-sources.timer + fi + fi + echo 'Maintenance module installed. Disable daily metadata checks with systemctl disable --now pman-maintenance-sources.timer.' fi -_ok "Installed root-owned executable: ${PMAN_DST}" - -_ok "Done. All optional dependencies are available. Run 'pman' to start." +echo 'Installed pman. Existing health cron and guest settings were not changed.' diff --git a/maintenance/launcher.py b/maintenance/launcher.py new file mode 100644 index 0000000..0eb9570 --- /dev/null +++ b/maintenance/launcher.py @@ -0,0 +1,7 @@ +#!/usr/bin/python3 +"""Installed with an isolated interpreter; load only the adjacent package.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pman_maintenance.cli import main +raise SystemExit(main()) diff --git a/maintenance/pman-maintenance b/maintenance/pman-maintenance new file mode 100755 index 0000000..3031897 --- /dev/null +++ b/maintenance/pman-maintenance @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Load only a trusted, installed package with an isolated Python interpreter. +set -euo pipefail +root=/usr/local/lib/pman/maintenance +trusted() { + local path="$1" uid mode + while [[ "$path" != / ]]; do + uid=$(stat -Lc %u -- "$path") || return 1 + mode=$(stat -Lc %a -- "$path") || return 1 + [[ ! -L "$path" && "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 + (((8#$mode & 8#022) == 0)) || return 1 + path=$(dirname -- "$path") + done +} +package=$(readlink -e -- "$root/current") || exit 1 +[[ "$package" == "$root"/releases/* && -d "$package" ]] || exit 1 +if ! trusted "$root" || ! trusted "$package"; then + echo 'Unsafe maintenance installation.' >&2 + exit 1 +fi +while IFS= read -r -d '' file; do + if [[ -L "$file" ]] || ! trusted "$file"; then + echo 'Unsafe maintenance package.' >&2 + exit 1 + fi +done < <(find "$package" -mindepth 1 -print0) +exec /usr/bin/python3 -I -B "$package/launcher.py" "$@" diff --git a/maintenance/pman_maintenance/__init__.py b/maintenance/pman_maintenance/__init__.py new file mode 100644 index 0000000..3ef7b71 --- /dev/null +++ b/maintenance/pman_maintenance/__init__.py @@ -0,0 +1,3 @@ +"""Optional, local-only maintenance adapters for pman.""" +VERSION = '1.0.0' +INTERFACE = 1 diff --git a/maintenance/pman_maintenance/actions.py b/maintenance/pman_maintenance/actions.py new file mode 100644 index 0000000..b8b1e7d --- /dev/null +++ b/maintenance/pman_maintenance/actions.py @@ -0,0 +1,265 @@ +"""Fixed native adapters. Community sources are references, never executed.""" +import json +import re +import shlex +import shutil +from dataclasses import dataclass +from pathlib import PurePosixPath +from .runner import CommandError +from .state import STATE, read_json, write_json + + +@dataclass(frozen=True) +class Context: + scope: str + id: int = None + mount: str = '/var' + + def __post_init__(self): + if self.scope not in ('host', 'ct', 'vm'): + raise ValueError('Ungültiger Zieltyp.') + if self.scope != 'host' and (type(self.id) is not int or not 100 <= self.id <= 999999999): + raise ValueError('Ungültige VMID.') + if self.scope == 'host' and self.id is not None: + raise ValueError('Host benötigt keine VMID.') + if self.mount not in ('/var', '/home', '/opt', '/srv'): + raise ValueError('Nur /var, /home, /opt oder /srv sind als Scan-Ziel erlaubt.') + + +@dataclass(frozen=True) +class Action: + id: str + title: str + scopes: tuple + mutation: bool = False + source: str = 'Eigene Implementierung' + + +ACTIONS = ( + Action('storage', 'Storage, Dateisysteme, Inodes', ('host',)), + Action('smart', 'SMART und Fehlerverlauf', ('host',)), + Action('maintenance', 'TRIM-Zeitplanung, Kernel, Backups', ('host',)), + Action('volumes', 'Volumes und Storage-Zuordnung', ('ct', 'vm')), + Action('filesystems', 'Dateisystembelegung', ('ct', 'vm')), + Action('scan', 'Große Verzeichnisse im gewählten Pfad', ('ct',)), + Action('trim', 'Ungenutzte Blöcke trimmen', ('ct', 'vm'), True, + 'Eigener Adapter; Community Scripts als Referenz'), + Action('cache-clean', 'APT-Paketcache bereinigen', ('ct',), True, + 'Eigener Adapter; Community Scripts als Referenz'), +) + + +def registry(ctx): + return [action for action in ACTIONS if ctx.scope in action.scopes] + + +def action_for(key, ctx): + for action in registry(ctx): + if action.id == key: + return action + raise CommandError('Aktion für dieses Ziel nicht verfügbar.') + + +def guest_running(ctx, runner): + tool = 'pct' if ctx.scope == 'ct' else 'qm' + output = runner.run([tool, 'status', str(ctx.id)]).text + if not re.search(r'^status:\s+running\s*$', output, re.M): + raise CommandError('Gast {} läuft nicht; kein automatischer Start.'.format(ctx.id)) + return tool + + +def qga(ctx, runner, command): + guest_running(ctx, runner) + help_text = runner.run(['qm', 'help', 'guest', 'cmd']).text + if not re.search(r'\b' + re.escape(command) + r'\b', help_text): + raise CommandError('Guest-Agent-Befehl auf dieser Proxmox-Version nicht verfügbar.') + runner.run(['qm', 'guest', 'cmd', str(ctx.id), 'ping']) + return ['qm', 'guest', 'cmd', str(ctx.id), command] + + +def cache_plan(ctx, runner): + guest_running(ctx, runner) + raw = runner.run(['pct', 'exec', str(ctx.id), '--', 'cat', '/etc/os-release']).text + match = re.search(r'^ID=["\']?([a-z0-9_-]+)', raw, re.M) + if not match or match.group(1) not in ('debian', 'ubuntu'): + raise CommandError('Cache-Bereinigung nur für Debian und Ubuntu verfügbar.') + size = runner.run(['pct', 'exec', str(ctx.id), '--', 'du', '-sxh', '/var/cache/apt/archives']).text + command = ['pct', 'exec', str(ctx.id), '--', 'apt-get', 'clean'] + return command, 'APT-Cache: ' + size + '\nEntfernt heruntergeladene Paketarchive; keine Pakete oder Nutzdaten.' + + +def prepare(key, ctx, runner): + action = action_for(key, ctx) + if not action.mutation: + raise CommandError('Aktion benötigt keine Änderungsfreigabe.') + if key == 'cache-clean': + command, impact = cache_plan(ctx, runner) + elif ctx.scope == 'ct': + guest_running(ctx, runner) + command = ['pct', 'fstrim', str(ctx.id)] + impact = 'Meldet freie Blöcke. Gemeldete Menge ist kein garantiert zurückgewonnener Speicherplatz.' + else: + command = qga(ctx, runner, 'fstrim') + info_command = qga(ctx, runner, 'get-osinfo') + os_info = json.loads(runner.run(info_command).text) + if os_info.get('id') not in ('debian', 'ubuntu', 'fedora', 'centos', 'rhel', 'rocky', 'almalinux', 'arch', 'opensuse-leap'): + raise CommandError('TRIM für dieses Gastbetriebssystem nicht freigegeben; nur lesende Diagnose verfügbar.') + impact = 'TRIM über QEMU Guest Agent; Discard muss im vollständigen Storage-Pfad unterstützt sein.' + return {'target': '{} {}'.format(ctx.scope.upper(), ctx.id), 'command': command, + 'impact': impact, 'display': shlex.join(command)} + + +def execute(key, ctx, runner, confirmed=False): + action = action_for(key, ctx) + if action.mutation: + if not confirmed: + raise CommandError('Explizite Bestätigung erforderlich.') + if key == 'trim' and ctx.scope == 'ct': + guest_running(ctx, runner) + command = ['pct', 'fstrim', str(ctx.id)] + else: + command = prepare(key, ctx, runner)['command'] + return runner.run(command, timeout=180).text + handlers = {'storage': do_storage, 'smart': do_smart, 'maintenance': do_maintenance, + 'volumes': do_volumes, 'filesystems': do_filesystems, 'scan': do_scan} + return handlers[key](ctx, runner) + + +def do_storage(ctx, runner): + sections = [('Proxmox Storage-Pools', ['pvesm', 'status']), + ('Host-Dateisysteme (nicht Thin-Pool-Belegung)', ['df', '-hT']), + ('Host-Inodes', ['df', '-hi']), + ('Blockgeräte', ['lsblk', '-o', 'NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS'])] + if shutil.which('lvs'): + sections.append(('LVM: Thin-Pool / Thin-Volume gesondert lesen', + ['lvs', '-a', '-o', 'vg_name,lv_name,lv_attr,lv_size,pool_lv,data_percent,metadata_percent'])) + if shutil.which('zpool'): + sections.extend([('ZFS-Pools', ['zpool', 'list']), ('ZFS-Zustand', ['zpool', 'status', '-x'])]) + return sections_report(sections, runner) + + +def sections_report(sections, runner): + output = [] + for title, command in sections: + try: + text = runner.run(command).text + except CommandError as exc: + if hasattr(runner, 'cancel') and runner.cancel.is_set(): + raise + text = 'Nicht verfügbar: ' + str(exc) + output.extend([title, text, '']) + return '\n'.join(output) + + +def do_volumes(ctx, runner): + tool = 'pct' if ctx.scope == 'ct' else 'qm' + raw = runner.run([tool, 'config', str(ctx.id)]).text + output = ['Konfigurierte Volumes (keine Zuordnung anhand geratener LV-Namen):'] + for line in raw.splitlines(): + key, sep, value = line.partition(':') + if not sep or not re.fullmatch(r'rootfs|mp\d+|(?:ide|sata|scsi|virtio)\d+', key): + continue + volume = value.strip().split(',')[0] + output.append(line) + if re.fullmatch(r'[A-Za-z0-9_-]+:[A-Za-z0-9_./-]+', volume): + try: + output.append(' Aufgelöst: ' + runner.run(['pvesm', 'path', volume]).text.strip()) + except CommandError as exc: + output.append(' Auflösung nicht verfügbar: ' + str(exc)) + else: + output.append(' Bind-Mount, Gerät oder leeres Laufwerk; kein geratenes Storage-Volume.') + if ctx.scope == 'vm': + output.append(' Discard: ' + ('aktiv' if re.search(r',discard=on(?:,|$)', value) else 'nicht als on konfiguriert')) + return '\n'.join(output) + + +def do_filesystems(ctx, runner): + if ctx.scope == 'vm': + command = qga(ctx, runner, 'get-fsinfo') + filesystems = json.loads(runner.run(command).text) + if not isinstance(filesystems, list): + raise CommandError('Unerwartete Guest-Agent-Dateisystemausgabe.') + lines = ['Gast-Dateisystem | Typ | Belegt / Gesamt | Anteil', + 'Nur Diagnose; Image-Dateisysteme mit 100% sind kein Speicherplatzalarm.'] + for entry in filesystems: + total = entry.get('total-bytes') + used = entry.get('used-bytes') + valid = isinstance(total, (int, float)) and total > 0 and isinstance(used, (int, float)) + size = '{:.2f} / {:.2f} GiB'.format(used / 2**30, total / 2**30) if valid else 'nicht verfügbar' + percent = '{:.1f}%'.format(100 * used / total) if valid else 'n/a' + image = ' (schreibgeschütztes Image)' if entry.get('type') in ('erofs', 'squashfs', 'iso9660') else '' + lines.append('{} | {}{} | {} | {}'.format(entry.get('mountpoint', '?'), entry.get('type', '?'), image, size, percent)) + return '\n'.join(lines) + guest_running(ctx, runner) + return sections_report([('Gast-Dateisysteme', ['pct', 'exec', str(ctx.id), '--', 'df', '-hT']), + ('Gast-Inodes', ['pct', 'exec', str(ctx.id), '--', 'df', '-hi'])], runner) + + +def do_scan(ctx, runner): + guest_running(ctx, runner) + # Resolve the selected directory first; du -x never follows directory symlinks. + resolved = runner.run(['pct', 'exec', str(ctx.id), '--', 'readlink', '-e', ctx.mount]).text.strip() + if resolved != ctx.mount or not PurePosixPath(resolved).is_absolute(): + raise CommandError('Scan-Ziel fehlt oder ist ein Symlink; Scan abgelehnt.') + text = runner.run(['pct', 'exec', str(ctx.id), '--', 'timeout', '-k', '2', '55', 'du', '-x', '-h', '--max-depth=1', '--', ctx.mount], + timeout=60).text + return 'Nur gewählter Pfad {}, keine fremden Dateisysteme; maximal 60 s.\n{}'.format(ctx.mount, text) + + +def do_smart(ctx, runner): + if not shutil.which('smartctl'): + raise CommandError('smartctl fehlt. Optionales Paket smartmontools durch den Administrator installieren.') + disks = json.loads(runner.run(['lsblk', '-J', '-d', '-o', 'NAME,TYPE,MODEL,SIZE']).text) + previous = read_json(STATE / 'smart.json', {}) + current = {} + lines = ['SMART-Rohattribute sind herstellerspezifisch; PASSED ist keine Ausfallgarantie.'] + for disk in disks.get('blockdevices', []): + if disk.get('type') != 'disk' or not re.fullmatch(r'[A-Za-z0-9_-]+', disk.get('name', '')): + continue + dev = '/dev/' + disk['name'] + data = json.loads(runner.run(['smartctl', '-a', '-j', dev], allowed=None).text) + # Identify history by serial internally, never display or transmit it. + identity = data.get('serial_number') or dev + values = {} + for row in data.get('ata_smart_attributes', {}).get('table', []): + values[row.get('name', str(row.get('id')))] = row.get('raw', {}).get('value') + lines.extend(['', '{} {} {}'.format(dev, disk.get('model', ''), disk.get('size', '')), + 'Health: {}'.format(data.get('smart_status', {}).get('passed', 'nicht verfügbar')), + 'Temperatur: {}'.format(data.get('temperature', {}).get('current', 'nicht verfügbar')), + 'Betriebsstunden: {}'.format(data.get('power_on_time', {}).get('hours', 'nicht verfügbar'))]) + for name, value in values.items(): + if any(word in name.lower() for word in ('realloc', 'pending', 'uncorrect', 'crc', 'wear', 'temperature', 'power_on')): + old = previous.get(identity, {}).get(name) + delta = ' (vorher {})'.format(old) if old is not None else ' (erste Messung)' + lines.append('{}: {}{}'.format(name, value, delta)) + nvme = data.get('nvme_smart_health_information_log') + if nvme: + lines.append('NVMe: ' + json.dumps(nvme, ensure_ascii=False)) + current[identity] = values + write_json(STATE / 'smart.json', current) + return '\n'.join(lines) + + +def do_maintenance(ctx, runner): + result = sections_report([ + ('TRIM-Timer', ['systemctl', 'list-timers', '--all', '--no-pager', '*fstrim*']), + ('fstrim Service/Timer', ['systemctl', 'cat', 'fstrim.timer', 'fstrim.service']), + ('Letzte TRIM-Meldungen', ['journalctl', '-u', 'fstrim.service', '-n', '30', '--no-pager']), + ('Kernel-Pakete (nur Anzeige)', ['dpkg-query', '-W', '-f=${Package}\t${Version}\n', 'proxmox-kernel-*', 'pve-kernel-*']), + ('Storage-Belegung einschließlich Backup-Storage', ['pvesm', 'status', '--content', 'backup']), + ], runner) + # Show only scheduler lines mentioning trim; do not dump unrelated cron contents. + from pathlib import Path + cron = [Path('/etc/crontab'), Path('/var/spool/cron/crontabs/root')] + cron.extend(Path('/etc/cron.d').glob('*')) + for path in cron: + if path.is_file() and not path.is_symlink(): + for line in path.read_text(errors='replace').splitlines(): + if re.search(r'\b(?:fstrim|pct\s+fstrim)\b', line): + result += '\n{}: {}'.format(path, line) + log = Path('/var/log/fstrim.log') + if log.is_file() and not log.is_symlink(): + with log.open('rb') as stream: + stream.seek(max(0, log.stat().st_size - 12000)) + result += '\nLetzter Ausschnitt fstrim.log:\n' + stream.read(12000).decode('utf-8', 'replace') + return result diff --git a/maintenance/pman_maintenance/cli.py b/maintenance/pman_maintenance/cli.py new file mode 100644 index 0000000..67ca181 --- /dev/null +++ b/maintenance/pman_maintenance/cli.py @@ -0,0 +1,62 @@ +"""CLI and noninteractive diagnostics for the optional module.""" +import argparse +import json +import os +import sys +from . import INTERFACE, VERSION +from .actions import Context, action_for, execute +from .runner import Runner +from .sources import check_sources, source_report + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Optionales pman Speicher- und Wartungsmodul') + parser.add_argument('--interface', action='store_true', help='Maschinenlesbare Schnittstellenversion') + parser.add_argument('--version', action='version', version=VERSION) + parser.add_argument('--scope', choices=('host', 'ct', 'vm'), default='host') + parser.add_argument('--id', type=int) + parser.add_argument('--scan-path', choices=('/var', '/home', '/opt', '/srv'), default='/var') + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--check-sources', action='store_true', help='Öffentliche Quellmetadaten prüfen') + mode.add_argument('--sources', action='store_true', help='Herkunft und gespeicherten Status anzeigen') + mode.add_argument('--report', metavar='ACTION', help='Nur lesende Aktion ohne TUI ausführen') + parser.add_argument('--json', action='store_true', help='Diagnose als JSON ausgeben') + args = parser.parse_args(argv) + if args.interface: + print(json.dumps({'interface': INTERFACE, 'version': VERSION})) + return 0 + if os.geteuid() != 0: + parser.error('Als root auf dem Proxmox-Host ausführen.') + try: + ctx = Context(args.scope, args.id, args.scan_path) + if args.check_sources: + value = check_sources() + print(json.dumps(value, ensure_ascii=False, indent=2)) + return 1 if any(r['status'] in ('Quelle fehlt', 'Prüfung fehlgeschlagen') for r in value['references']) else 0 + if args.sources: + text = source_report() + elif args.report: + if action_for(args.report, ctx).mutation: + parser.error('Änderungen sind nur in der TUI mit Bestätigung möglich.') + text = execute(args.report, ctx, Runner()) + else: + if args.json: + parser.error('--json benötigt --report oder --sources.') + if not sys.stdin.isatty() or not sys.stdout.isatty(): + parser.error('TUI benötigt ein Terminal; --report für lesende CLI-Ausgaben verwenden.') + from .ui import run_tui + run_tui(ctx) + return 0 + if args.json: + print(json.dumps({'scope': ctx.scope, 'id': ctx.id, 'report': text}, ensure_ascii=False)) + else: + from .ui import clean_text + print(clean_text(text)) + return 0 + except (OSError, RuntimeError, ValueError) as exc: + from .ui import clean_text + print('Fehler: {}'.format(clean_text(exc)), file=sys.stderr) + return 1 + except KeyboardInterrupt: + print('Abgebrochen.', file=sys.stderr) + return 130 diff --git a/maintenance/pman_maintenance/runner.py b/maintenance/pman_maintenance/runner.py new file mode 100644 index 0000000..1e8a590 --- /dev/null +++ b/maintenance/pman_maintenance/runner.py @@ -0,0 +1,63 @@ +"""Bounded, cancellable process execution without a command shell.""" +import os +import signal +import subprocess +import tempfile +import threading +import time +from dataclasses import dataclass + + +class CommandError(RuntimeError): + pass + + +@dataclass +class Result: + argv: list + returncode: int + text: str + + +class Runner: + def __init__(self, cancel=None): + self.cancel = cancel or threading.Event() + + def run(self, argv, timeout=30, allowed=(0,)): + argv = [str(a) for a in argv] + if self.cancel.is_set(): + raise CommandError('Abgebrochen.') + env = dict(os.environ, LC_ALL='C', LANG='C', PATH='/usr/sbin:/usr/bin:/sbin:/bin') + with tempfile.TemporaryFile() as output: + try: + process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=output, + stderr=subprocess.STDOUT, start_new_session=True, env=env) + except OSError as exc: + raise CommandError('{}: {}'.format(argv[0], exc)) from exc + deadline = time.monotonic() + timeout + reason = '' + try: + while process.poll() is None: + if self.cancel.wait(.05): + reason = 'Abgebrochen; bereits ausgeführte Änderungen werden nicht rückgängig gemacht.' + elif time.monotonic() > deadline: + reason = 'Zeitlimit erreicht.' + elif os.fstat(output.fileno()).st_size > 2 * 1024 * 1024: + reason = 'Ausgabelimit erreicht.' + if reason: + break + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(2) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + output.seek(0) + text = output.read(2 * 1024 * 1024).decode('utf-8', 'replace') + if reason: + raise CommandError(reason) + if allowed is not None and process.returncode not in allowed: + raise CommandError('{} (Exit {}): {}'.format(argv[0], process.returncode, text[:4000])) + return Result(argv, process.returncode, text) diff --git a/maintenance/pman_maintenance/sources.py b/maintenance/pman_maintenance/sources.py new file mode 100644 index 0000000..c374f8f --- /dev/null +++ b/maintenance/pman_maintenance/sources.py @@ -0,0 +1,94 @@ +"""Public GitHub metadata checks. No source downloads, imports or execution.""" +import datetime +import json +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from .state import STATE, read_json, write_json + +MANIFEST = Path(__file__).resolve().parent.parent / 'sources.json' + + +def fetch_metadata(url, etag=''): + headers = {'Accept': 'application/vnd.github+json', 'User-Agent': 'pman-maintenance/1', + 'X-GitHub-Api-Version': '2022-11-28'} + if etag: + headers['If-None-Match'] = etag + request = urllib.request.Request(url, headers=headers) + # Proxies are intentionally ignored: only the fixed public API is contacted. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(request, timeout=8) as response: + body = response.read(1024 * 1024 + 1) + if len(body) > 1024 * 1024: + raise ValueError('API-Antwort zu groß.') + return response.status, json.loads(body), response.headers.get('ETag', '') + except urllib.error.HTTPError as exc: + return exc.code, {}, '' + + +def check_reference(reference, fetch=fetch_metadata, previous=None): + previous = previous or {} + result = {'repository': reference['repository'], 'path': reference['path'], + 'checked_at': datetime.datetime.now(datetime.timezone.utc).isoformat()} + directory, _, filename = reference['path'].rpartition('/') + path = urllib.parse.quote(directory, safe='/') + url = 'https://api.github.com/repos/{}/contents/{}'.format(reference['repository'], path) + try: + status, data, etag = fetch(url, previous.get('etag', '')) + if status == 304 and previous.get('observed_sha'): + sha = previous['observed_sha'] + elif status == 200 and isinstance(data, list): + if any(not isinstance(row, dict) for row in data): + raise ValueError('Ungültige Metadatenliste.') + entry = next((row for row in data if row.get('name') == filename and row.get('type') == 'file'), None) + if not entry: + result['status'] = 'Quelle fehlt' + return result + sha = entry.get('sha') + if not isinstance(sha, str) or not sha: + raise ValueError('Fehlende Git-Blob-Prüfsumme.') + elif status == 404: + result['status'] = 'Quelle fehlt' + return result + else: + raise ValueError('GitHub HTTP {}'.format(status)) + result.update(observed_sha=sha, etag=etag or previous.get('etag', ''), + status='geprüfter Stand' if sha == reference['blob_sha'] else 'Upstream geändert') + except (OSError, ValueError, TimeoutError) as exc: + result.update(status='Prüfung fehlgeschlagen', error=str(exc)[:200]) + return result + + +def check_sources(fetch=fetch_metadata): + manifest = json.loads(MANIFEST.read_text()) + previous = read_json(STATE / 'source-status.json', {}) or {} + old = {(r['repository'], r['path']): r for r in previous.get('references', [])} + results = [check_reference(r, fetch, old.get((r['repository'], r['path']))) + for r in manifest['references']] + value = {'checked_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'references': results} + write_json(STATE / 'source-status.json', value) + return value + + +def source_report(): + manifest = json.loads(MANIFEST.read_text()) + status = read_json(STATE / 'source-status.json', {}) or {} + checked = {(r['repository'], r['path']): r for r in status.get('references', [])} + lines = ['Quellenprüfung: ' + status.get('checked_at', 'Noch nicht geprüft'), + manifest['notice'], '', 'Referenzquellen (keine ausführbaren Downloads):'] + for reference in manifest['references']: + row = checked.get((reference['repository'], reference['path']), {}) + lines.extend([ + '{} / {}: {}'.format(reference['repository'], reference['path'], row.get('status', 'Noch nicht geprüft')), + ' Prüfzeitpunkt: ' + row.get('checked_at', '—'), + ' Geprüfter Commit: ' + reference['reviewed_commit'], + ' Git-Blob SHA-1: ' + reference['blob_sha'], + ]) + if row.get('error'): + lines.append(' Fehler: ' + row['error']) + lines.extend(['', 'Adapter-Herkunft:']) + for name, adapter in manifest['adapters'].items(): + lines.append('{}: {} | Referenz: {}'.format(name, adapter['origin'], adapter.get('reference') or 'keine')) + return '\n'.join(lines) diff --git a/maintenance/pman_maintenance/state.py b/maintenance/pman_maintenance/state.py new file mode 100644 index 0000000..fda8141 --- /dev/null +++ b/maintenance/pman_maintenance/state.py @@ -0,0 +1,47 @@ +"""Private, atomic state files; never source stored data as code.""" +import json +import os +import stat +import tempfile +from pathlib import Path + +STATE = Path('/var/lib/pman-maintenance') + + +def private_dir(path): + path = Path(path) + if not path.exists(): + path.mkdir(mode=0o700, parents=False) + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022: + raise RuntimeError('Unsicheres Zustandsverzeichnis: {}'.format(path)) + return path + + +def read_json(path, default=None): + path = Path(path) + try: + if path.parent.exists() or path.parent.is_symlink(): + private_dir(path.parent) + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o022: + raise RuntimeError('Unsichere Zustandsdatei: {}'.format(path)) + if info.st_size > 2 * 1024 * 1024: + raise RuntimeError('Zustandsdatei zu groß.') + return json.loads(path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return default + + +def write_json(path, value): + path = Path(path) + private_dir(path.parent) + fd, temporary = tempfile.mkstemp(prefix='.state-', dir=str(path.parent)) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write('\n') + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/maintenance/pman_maintenance/ui.py b/maintenance/pman_maintenance/ui.py new file mode 100644 index 0000000..08470b3 --- /dev/null +++ b/maintenance/pman_maintenance/ui.py @@ -0,0 +1,182 @@ +"""Small curses frontend; workers never access terminal state.""" +import curses +import fcntl +import os +import locale +import queue +import re +import threading +import textwrap +from .actions import Context, execute, prepare, registry +from .runner import Runner +from .sources import source_report +from .state import STATE, private_dir + + +def clean_text(value): + value = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', str(value)) + return ''.join(c for c in value if c in '\n\t' or (c.isprintable() and c != '\x1b')) + + +def navigate(index, direction, count): + return (index + direction) % count if count else 0 + + +def mutate(key, ctx, runner): + private_dir(STATE) + flags = os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW + fd = os.open(str(STATE / 'actions.lock'), flags, 0o600) + try: + info = os.fstat(fd) + if info.st_uid != os.geteuid() or info.st_mode & 0o022: + raise RuntimeError('Unsichere Wartungssperre.') + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return execute(key, ctx, runner, confirmed=True) + finally: + os.close(fd) + + +def run_tui(ctx): + # pman uses LC_ALL=C for parsing. The optional Unicode UI needs UTF-8. + try: + locale.setlocale(locale.LC_ALL, 'C.UTF-8') + except locale.Error: + locale.setlocale(locale.LC_ALL, '') + if not locale.getpreferredencoding(False).lower().replace('-', '').startswith('utf8'): + raise RuntimeError('Für die Oberfläche ist eine UTF-8-Locale erforderlich.') + try: + curses.wrapper(_screen, ctx) + except curses.error as exc: + raise RuntimeError('Terminal unterstützt die Oberfläche nicht; TERM und Fenstergröße prüfen.') from exc + + +def _screen(screen, ctx): + curses.curs_set(0) + screen.keypad(True) + screen.timeout(100) + color = 0 + if curses.has_colors() and 'NO_COLOR' not in os.environ: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) + color = curses.color_pair(1) + selected = 0 + scroll = 0 + output = ['Diagnosen ändern keine Gastdaten. Änderungen erfordern eine eigene Bestätigung.', + 'Quelle: eigene Adapter; Community Scripts dienen als gekennzeichnete Referenz.', + 'CT-Scanpfad mit Tab umschalten: /var, /home, /opt, /srv.'] + pending = None + busy = False + cancel = threading.Event() + messages = queue.Queue() + worker = None + status = 'Bereit' + + def start(kind, action=None): + nonlocal busy, status, worker + cancel.clear() + busy = True + status = 'Prüfung läuft – Esc bricht ab' + def job(): + try: + runner = Runner(cancel) + if kind == 'prepare': + value = prepare(action.id, ctx, runner) + elif kind == 'sources': + value = source_report() + elif kind == 'mutate': + value = mutate(action.id, ctx, runner) + else: + value = execute(action.id, ctx, runner) + messages.put((kind, value, action)) + except Exception as exc: + messages.put(('error', str(exc), action)) + worker = threading.Thread(target=job, daemon=True) + worker.start() + + try: + while True: + try: + kind, value, action = messages.get_nowait() + busy = False + scroll = 0 + if kind == 'prepare': + pending = action + output = ['Ziel: ' + value['target'], 'Befehl: ' + value['display'], value['impact'], + '', 'Änderung ausführen? j = bestätigen, Esc = abbrechen', + 'Abbruch eines laufenden Befehls ist kein Rollback.'] + status = 'Bestätigung erforderlich' + else: + output = clean_text(value).expandtabs(4).splitlines() or ['Keine Ausgabe.'] + status = 'Fehler / nicht verfügbar' if kind == 'error' else 'Abgeschlossen' + except queue.Empty: + pass + screen.erase() + height, width = screen.getmaxyx() + def put(y, text, attr=0): + if 0 <= y < height and width > 1: + try: + screen.addnstr(y, 0, clean_text(text), width - 1, attr) + except curses.error: + pass + if height < 12 or width < 45: + put(0, 'Terminal mindestens 45 Spalten und 12 Zeilen.') + put(1, 'Vergrößern oder q zum Beenden.') + else: + actions = registry(ctx) + menu = [a.title + (' [Änderung]' if a.mutation else '') for a in actions] + menu.append('Herkunft und Aktualitätsstatus') + menu_height = min(len(menu), max(3, height // 3)) + first = max(0, selected - menu_height + 1) + put(0, 'pman Wartung | {} {} | Scan: {}'.format(ctx.scope.upper(), ctx.id or '', ctx.mount), color | curses.A_BOLD) + for row, index in enumerate(range(first, min(len(menu), first + menu_height)), 2): + put(row, ('> ' if index == selected else ' ') + menu[index], + curses.A_REVERSE if index == selected else 0) + start_y = menu_height + 3 + put(start_y, '─' * (width - 1), color) + page = max(1, height - start_y - 4) + wrapped = [piece for line in output for piece in (textwrap.wrap(clean_text(line), max(1, width - 2), replace_whitespace=False) or [''])] + scroll = min(scroll, max(0, len(wrapped) - page)) + for row, line in enumerate(wrapped[scroll:scroll + page], start_y + 1): + put(row, line) + put(height - 2, '↑↓ Menü | Enter öffnen | Bild↑↓ Ausgabe | Tab Scanpfad | Esc zurück/Abbruch | q Ende', color) + put(height - 1, status, color) + screen.refresh() + key = screen.getch() + if key == -1: + continue + if busy: + if key in (27, ord('q')): + cancel.set() + status = 'Abbruch angefordert; CT-Scan kann bis zum Gast-Zeitlimit (55 s) weiterlaufen.' + continue + if pending: + if key in (ord('j'), ord('J')): + action = pending + pending = None + start('mutate', action) + elif key in (27, ord('n'), ord('N'), ord('q')): + pending = None + output = ['Änderung abgebrochen.'] + status = 'Bereit' + continue + if key in (ord('q'), 27): + return + if key in (curses.KEY_UP, curses.KEY_DOWN): + selected = navigate(selected, -1 if key == curses.KEY_UP else 1, len(registry(ctx)) + 1) + elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE): + scroll = max(0, scroll + (-5 if key == curses.KEY_PPAGE else 5)) + elif key == 9 and ctx.scope == 'ct': + mounts = ['/var', '/home', '/opt', '/srv'] + ctx = Context(ctx.scope, ctx.id, mounts[(mounts.index(ctx.mount) + 1) % len(mounts)]) + elif key in (10, 13, curses.KEY_ENTER): + actions = registry(ctx) + if selected == len(actions): + start('sources') + else: + action = actions[selected] + start('prepare' if action.mutation else 'execute', action) + finally: + cancel.set() + if worker and worker.is_alive(): + worker.join(4) diff --git a/maintenance/sources.json b/maintenance/sources.json new file mode 100644 index 0000000..3a49b57 --- /dev/null +++ b/maintenance/sources.json @@ -0,0 +1,129 @@ +{ + "format": 1, + "reviewed_at": "2026-10-27", + "notice": "Alle Adapter sind eigene Implementierungen mit nativen Proxmox-/Linux-Befehlen. Community Scripts dienen als fachliche Referenz. Kein Community-Code und keine Telemetrie werden ausgeführt.", + "adapters": { + "storage": { + "origin": "own", + "reference": null, + "modifications": "Independent implementation; no upstream code copied" + }, + "smart": { + "origin": "own", + "reference": "tools/pve/disk-health.sh", + "modifications": "Independent implementation; no upstream code copied" + }, + "maintenance": { + "origin": "own", + "reference": "tools/pve/kernel-clean.sh", + "modifications": "Independent implementation; no upstream code copied" + }, + "volumes": { + "origin": "own", + "reference": null, + "modifications": "Independent implementation; no upstream code copied" + }, + "filesystems": { + "origin": "own", + "reference": null, + "modifications": "Independent implementation; no upstream code copied" + }, + "scan": { + "origin": "own", + "reference": null, + "modifications": "Independent implementation; no upstream code copied" + }, + "trim": { + "origin": "own", + "reference": "tools/pve/fstrim.sh", + "modifications": "Independent implementation; no upstream code copied" + }, + "cache-clean": { + "origin": "own", + "reference": "tools/pve/clean-lxcs.sh", + "modifications": "Independent implementation; no upstream code copied" + } + }, + "references": [ + { + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/fstrim.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "b63b4267c97ce3c62df4ad26c5e85131562f5950", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/disk-health.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "fa12746aedce484fe760bcccf4d1bcb3de58501b", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/clean-lxcs.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "e54a307a980130c43134c68d28a8c7cfcdf6b260", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/kernel-clean.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "98351a487f14ed77df99f8945a9f45bf0b1870c0", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "api/api.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "c160e52ffc417187fc818e58572f1df23f3b7703", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "core/core.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "14c9ae0cc6ebce5a105b5c6e180f26ce1b2ed190", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "api/exitcodes.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "ad83c87885f34339f6f6808852ede750cc91587e", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "api/errorlog.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "c894d3be3a85fbbf5956ee5d2b40aa5f659847d3", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "api/sysinfo.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "9256905983d5a94722755959591345d1136bcc82", + "license": "MIT", + "role": "upstream reference; not executed" + }, + { + "repository": "community-scripts/core", + "path": "api/telemetry.func", + "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", + "blob_sha": "899149cabf680abe5d1dcd60fa6cd425ab71459c", + "license": "MIT", + "role": "upstream reference; not executed" + } + ] +} diff --git a/maintenance/systemd/pman-maintenance-sources.service b/maintenance/systemd/pman-maintenance-sources.service new file mode 100644 index 0000000..cf38a3e --- /dev/null +++ b/maintenance/systemd/pman-maintenance-sources.service @@ -0,0 +1,15 @@ +[Unit] +Description=pman maintenance public source metadata check +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/pman-maintenance --check-sources +TimeoutStartSec=120 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/pman-maintenance diff --git a/maintenance/systemd/pman-maintenance-sources.timer b/maintenance/systemd/pman-maintenance-sources.timer new file mode 100644 index 0000000..ff06bb3 --- /dev/null +++ b/maintenance/systemd/pman-maintenance-sources.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily pman maintenance source metadata check + +[Timer] +OnCalendar=daily +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/proxmox-manager.sh b/proxmox-manager.sh index bd65ff1..23a9a1a 100755 --- a/proxmox-manager.sh +++ b/proxmox-manager.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Proxmox VM/CT Management Tool -# Version 2.13.2 — 2026-10-21 +# Version 2.14.0 — 2026-10-27 # - fix: stop action runs qm/pct shutdown --timeout --forceStop 1 (clean shutdown, force stop only after the timeout) instead of qm/pct stop, which pulled the plug # - feat: health view (--health, menu key h) and cron checks (--check) with ntfy/e-mail alerts # - fix: action and snapshot menus draw a closed frame (right border, padded rows) @@ -18,6 +18,8 @@ export LC_ALL=C # ============================================================================= CLEAR_SCREEN=1 MODE="interactive" +MAINTENANCE_FLAG=0 +_MAINTENANCE_EXEC=/usr/local/bin/pman-maintenance RUN_ONCE=0 LIST_FLAG=0 JSON_FLAG=0 @@ -538,6 +540,7 @@ Options: --once Run a single interactive refresh (useful for TTY recording) --timeout SECS Shutdown timeout before force stop in seconds (default: 60) --force Skip all confirmation prompts (use with care) + --maintenance Open the optional host maintenance module --health Show CPU/memory/disk health of local VMs/CTs (combine with --list for plain text or --json; --filter/--name apply) --check Run health checks for cron; alert on changes; exit 0/1/2/3 @@ -611,6 +614,9 @@ parse_args() { --force) FORCE_MODE=1 ;; + --maintenance) + MAINTENANCE_FLAG=1 + ;; --health) HEALTH_FLAG=1 ;; @@ -657,6 +663,14 @@ parse_args() { # _resolve_mode — derive MODE from the health flags and reject invalid combinations. _resolve_mode() { + if ((MAINTENANCE_FLAG == 1)); then + if ((LIST_FLAG || JSON_FLAG || HEALTH_FLAG || CHECK_FLAG || TEST_NOTIFY_FLAG || DRY_RUN)) || [[ -n "$FILTER_STATUS$FILTER_NAME" ]]; then + err "--maintenance cannot be combined with other output modes or filters." + exit "$FATAL_EXIT" + fi + MODE="maintenance" + return 0 + fi if ((DRY_RUN == 1 && CHECK_FLAG == 0)); then err "Option --dry-run requires --check." exit "$FATAL_EXIT" @@ -2609,12 +2623,44 @@ confirm() { [[ "$ans" =~ ^[yY]$ ]] } +# The optional package has its own confirmation flow; never forward --force. +maintenance_open() { + local scope="${1:-host}" id="${2:-}" info + if [[ ! -f "$_MAINTENANCE_EXEC" || -L "$_MAINTENANCE_EXEC" || ! -x "$_MAINTENANCE_EXEC" ]]; then + err "Maintenance module missing. Install with ./install_dependencies.sh --with-maintenance" + return 1 + fi + local path="$_MAINTENANCE_EXEC" owner mode + while [[ "$path" != / ]]; do + owner=$(stat -Lc %u -- "$path") || return 1 + mode=$(stat -Lc %a -- "$path") || return 1 + if [[ -L "$path" || "$owner" != 0 || ! "$mode" =~ ^[0-7]{3,4}$ ]] || (((8#$mode & 8#022) != 0)); then + err "Unsafe maintenance executable or parent directory." + return 1 + fi + path=$(dirname -- "$path") + done + info=$("$_MAINTENANCE_EXEC" --interface) || return 1 + if ! printf '%s' "$info" | python3 -I -B -c 'import json,re,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("interface")==1 and re.fullmatch(r"1\.\d+\.\d+", str(d.get("version",""))) else 1)' 2>/dev/null; then + err "Incompatible maintenance module; reinstall a matching release." + return 1 + fi + case "$scope" in + host) "$_MAINTENANCE_EXEC" --scope host ;; + ct | vm) + validate_vmid "$id" || return 1 + "$_MAINTENANCE_EXEC" --scope "$scope" --id "$id" + ;; + *) return 1 ;; + esac +} + main_menu() { header if ! print_table; then return 1; fi echo printf ' %b%s%b %s\n' "${BOLD}" "Keys:" "${NC}" \ - " = open action menu ${BOLD}h${NC} = health ${BOLD}r${NC} = refresh ${BOLD}q${NC} = quit" + " = open action menu ${BOLD}h${NC} = health ${BOLD}m${NC} = host maintenance ${BOLD}r${NC} = refresh ${BOLD}q${NC} = quit" printf ' %b→%b ' "${CYAN_BRIGHT}" "${NC}" local choice read_line choice @@ -2622,6 +2668,7 @@ main_menu() { q | Q) exit 0 ;; r | R | '') return 0 ;; h | H) health_overview ;; + m | M) maintenance_open host ;; *) if [[ "$choice" =~ ^[0-9]+$ ]]; then if ! validate_vmid "$choice"; then @@ -2696,6 +2743,7 @@ action_menu() { fi _box_content "${CYAN}" "${LINE_V}" $W "$row" + _box_content "${CYAN}" "${LINE_V}" $W " m) Storage & maintenance (optional)" _draw_line_bot $W echo if [[ "$ty" == "VM" ]]; then @@ -2706,6 +2754,7 @@ action_menu() { local opt read_line opt case "$opt" in + m | M) maintenance_open "${ty,,}" "$id" ;; 1) do_action "$id" "$ty" start "$name" ;; 2) do_action "$id" "$ty" stop "$name" ;; 3) do_action "$id" "$ty" restart "$name" ;; @@ -3343,7 +3392,7 @@ main() { err "PROXMOX_MANAGER_SPICE_ADDR contains unsupported characters." exit "$FATAL_EXIT" fi - if ((FORCE_MODE == 1)); then + if ((FORCE_MODE == 1)) && [[ "$MODE" != maintenance ]]; then case "$MODE" in json | health_json | check) warn "--force active: all confirmation prompts will be skipped automatically." >&2 ;; *) warn "--force active: all confirmation prompts will be skipped automatically." ;; @@ -3366,6 +3415,9 @@ main() { ;; esac case "$MODE" in + maintenance) + maintenance_open host + ;; list) if print_table; then exit 0 diff --git a/tests/maintenance/test_actions.py b/tests/maintenance/test_actions.py new file mode 100644 index 0000000..f0a063e --- /dev/null +++ b/tests/maintenance/test_actions.py @@ -0,0 +1,104 @@ +import sys +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) +from pman_maintenance.actions import Context, execute, prepare, registry +from pman_maintenance.runner import Result, CommandError + +class FakeRunner: + def __init__(self, responses=None): + self.calls = [] + self.responses = responses or {} + def run(self, argv, **kwargs): + self.calls.append(argv) + value = self.responses.get(tuple(argv), '') + if isinstance(value, Exception): + raise value + return Result(argv, 0, value) + +class ActionsTest(unittest.TestCase): + def test_stopped_ct_cannot_trim(self): + runner = FakeRunner({('pct', 'status', '101'): 'status: stopped'}) + with self.assertRaises(CommandError): + execute('trim', Context('ct', 101), runner, confirmed=True) + self.assertEqual(runner.calls, [['pct', 'status', '101']]) + def test_unconfirmed_mutation_never_runs(self): + runner = FakeRunner() + with self.assertRaises(CommandError): + execute('trim', Context('ct', 101), runner) + self.assertEqual(runner.calls, []) + def test_trim_only_selected_running_ct(self): + runner = FakeRunner({('pct', 'status', '101'): 'status: running'}) + execute('trim', Context('ct', 101), runner, confirmed=True) + self.assertEqual(runner.calls, [['pct', 'status', '101'], ['pct', 'fstrim', '101']]) + def test_host_actions_not_available_for_vm(self): + self.assertNotIn('smart', [a.id for a in registry(Context('vm', 100))]) + def test_unknown_guest_id_rejected(self): + with self.assertRaises(ValueError): + Context('ct', 0) + def test_cache_clean_rejects_unknown_os(self): + runner = FakeRunner({('pct', 'status', '101'): 'status: running', + ('pct', 'exec', '101', '--', 'cat', '/etc/os-release'): 'ID=alpine'}) + with self.assertRaises(CommandError): + prepare('cache-clean', Context('ct', 101), runner) + self.assertFalse(any('clean' in c for c in runner.calls)) + +if __name__ == '__main__': unittest.main() + +class BackendTest(unittest.TestCase): + def test_storage_has_separate_thin_metadata_and_zfs(self): + from unittest.mock import patch + runner = FakeRunner() + with patch('pman_maintenance.actions.shutil.which', return_value='/usr/sbin/tool'): + report = execute('storage', Context('host'), runner) + self.assertIn(['zpool','status','-x'], runner.calls) + self.assertTrue(any('vg_name,lv_name,lv_attr,lv_size,pool_lv,data_percent,metadata_percent' in call for call in runner.calls)) + self.assertIn('Thin-Pool', report) + def test_storage_skips_missing_optional_tools(self): + from unittest.mock import patch + runner = FakeRunner() + with patch('pman_maintenance.actions.shutil.which', return_value=None): + execute('storage', Context('host'), runner) + self.assertFalse(any(call[0] in ('lvs','zpool') for call in runner.calls)) + def test_backend_error_does_not_hide_other_sections(self): + runner = FakeRunner({('pvesm','status'): CommandError('offline')}) + report = execute('storage', Context('host'), runner) + self.assertIn('offline', report) + self.assertIn(['df','-hi'], runner.calls) + def test_vm_missing_agent_never_trims(self): + runner = FakeRunner({('qm','status','100'):'status: running', + ('qm','help','guest','cmd'): 'ping fstrim get-osinfo', + ('qm','guest','cmd','100','ping'): CommandError('agent is not running')}) + with self.assertRaises(CommandError): + execute('trim',Context('vm',100),runner,confirmed=True) + self.assertNotIn(['qm','guest','cmd','100','fstrim'],runner.calls) + def test_vm_unknown_os_never_trims(self): + runner = FakeRunner({('qm','status','100'):'status: running', + ('qm','help','guest','cmd'): 'ping fstrim get-osinfo', + ('qm','guest','cmd','100','get-osinfo'): '{"id":"haos"}'}) + with self.assertRaises(CommandError): + execute('trim',Context('vm',100),runner,confirmed=True) + self.assertNotIn(['qm','guest','cmd','100','fstrim'],runner.calls) + def test_vm_image_filesystem_explicitly_labelled(self): + runner = FakeRunner({('qm','status','100'):'status: running', + ('qm','help','guest','cmd'): 'ping get-fsinfo', + ('qm','guest','cmd','100','get-fsinfo'): '[{"mountpoint":"/","type":"erofs","total-bytes":100,"used-bytes":100}]'}) + report = execute('filesystems',Context('vm',100),runner) + self.assertIn('schreibgeschütztes Image',report) + self.assertIn('100.0%',report) + def test_volume_resolves_configured_name(self): + runner = FakeRunner({('pct','config','101'): 'rootfs: custom:unusual-volume,size=10G\n', + ('pvesm','path','custom:unusual-volume'): '/dev/custom/real'}) + report = execute('volumes',Context('ct',101),runner) + self.assertIn('/dev/custom/real',report) + self.assertNotIn('vm-101-disk-0',report) + def test_stopped_ct_cache_never_executes_guest_command(self): + runner = FakeRunner({('pct','status','101'):'status: stopped'}) + with self.assertRaises(CommandError): + prepare('cache-clean',Context('ct',101),runner) + self.assertEqual(runner.calls,[['pct','status','101']]) + def test_running_ct_cache_clean_targets_selected_guest(self): + runner = FakeRunner({('pct','status','101'):'status: running', + ('pct','exec','101','--','cat','/etc/os-release'):'ID=debian'}) + execute('cache-clean',Context('ct',101),runner,confirmed=True) + self.assertEqual(runner.calls[-1],['pct','exec','101','--','apt-get','clean']) diff --git a/tests/maintenance/test_core.py b/tests/maintenance/test_core.py new file mode 100644 index 0000000..c16d889 --- /dev/null +++ b/tests/maintenance/test_core.py @@ -0,0 +1,30 @@ +import json +import shlex +import subprocess +import tempfile +import unittest +from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] + +class CoreTest(unittest.TestCase): + def test_maintenance_mode_conflicts_rejected(self): + result = subprocess.run(['bash', str(ROOT/'proxmox-manager.sh'), '--maintenance', '--json'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('cannot be combined', result.stderr) + def test_missing_module_has_install_hint(self): + script = 'source <(sed \'/^main "\\$@"$/d\' '+shlex.quote(str(ROOT/'proxmox-manager.sh'))+'); _MAINTENANCE_EXEC=/nonexistent/pman-maintenance; maintenance_open host' + result = subprocess.run(['bash','-c',script],capture_output=True,text=True) + self.assertNotEqual(result.returncode,0) + self.assertIn('--with-maintenance',result.stderr) + def test_selected_guest_passed_without_force(self): + with tempfile.TemporaryDirectory() as directory: + executable = Path(directory)/'module' + log = Path(directory)/'args' + executable.write_text('#!/bin/bash\nif [[ "$1" == --interface ]]; then echo \'{"interface":1,"version":"1.0.0"}\'; else printf "%s\\n" "$@" > '+shlex.quote(str(log))+'; fi\n') + executable.chmod(0o755) + script = 'source <(sed \'/^main "\\$@"$/d\' '+shlex.quote(str(ROOT/'proxmox-manager.sh'))+'); stat() { case "$2" in %u) echo 0;; %a) echo 755;; esac; }; _MAINTENANCE_EXEC='+shlex.quote(str(executable))+'; FORCE_MODE=1; maintenance_open ct 101' + result = subprocess.run(['bash','-c',script],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(log.read_text().splitlines(),['--scope','ct','--id','101']) + +if __name__ == '__main__': unittest.main() diff --git a/tests/maintenance/test_installer.py b/tests/maintenance/test_installer.py new file mode 100644 index 0000000..bcda342 --- /dev/null +++ b/tests/maintenance/test_installer.py @@ -0,0 +1,92 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +class InstallerTest(unittest.TestCase): + def install(self, stage, *flags): + return subprocess.run(['bash', str(ROOT / 'install_dependencies.sh'), '--destdir', str(stage), *flags], + stdin=subprocess.DEVNULL, capture_output=True, text=True) + def test_default_installs_only_core(self): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) / 'stage' + result = self.install(stage) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((stage / 'usr/local/bin/pman').is_file()) + self.assertFalse((stage / 'usr/local/bin/pman-maintenance').exists()) + def test_optional_install_and_uninstall_preserve_core(self): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) / 'stage' + result = self.install(stage, '--with-maintenance') + self.assertEqual(result.returncode, 0, result.stderr) + package = stage / 'usr/local/lib/pman/maintenance/current' + self.assertTrue(package.is_symlink()) + self.assertTrue((package / 'sources.json').is_file()) + self.assertFalse(list(package.rglob('__pycache__'))) + result = self.install(stage, '--uninstall-maintenance') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((stage / 'usr/local/bin/pman').is_file()) + self.assertFalse(package.exists()) + def test_intermediate_symlink_cannot_escape_staging(self): + for flag in ('--without-maintenance', '--uninstall-maintenance'): + with self.subTest(flag=flag), tempfile.TemporaryDirectory() as directory: + stage = Path(directory) / 'stage' + outside = Path(directory) / 'outside' + stage.mkdir(); outside.mkdir() + (stage / 'usr').symlink_to(outside, target_is_directory=True) + result = self.install(stage, flag) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + def test_staging_root_symlink_rejected(self): + with tempfile.TemporaryDirectory() as directory: + outside = Path(directory) / 'outside'; outside.mkdir() + stage = Path(directory) / 'stage'; stage.symlink_to(outside, target_is_directory=True) + self.assertNotEqual(self.install(stage).returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + +if __name__ == '__main__': unittest.main() + +class RollbackTest(unittest.TestCase): + def test_updates_switch_atomically_and_rollback(self): + import shutil + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / 'source'; source.mkdir() + shutil.copy2(ROOT / 'install_dependencies.sh', source) + shutil.copy2(ROOT / 'proxmox-manager.sh', source) + shutil.copytree(ROOT / 'maintenance', source / 'maintenance', ignore=shutil.ignore_patterns('__pycache__')) + stage = root / 'stage' + def install(*args): + result = subprocess.run(['bash', str(source/'install_dependencies.sh'), '--destdir', str(stage), *args], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + install('--with-maintenance') + package = stage / 'usr/local/lib/pman/maintenance' + old = os.readlink(package/'current') + version = source/'maintenance/pman_maintenance/__init__.py' + version.write_text(version.read_text().replace('1.0.0','1.0.1')) + install('--with-maintenance') + new = os.readlink(package/'current') + self.assertNotEqual(new, old) + self.assertEqual(os.readlink(package/'previous'), old) + install('--rollback-maintenance') + self.assertEqual(os.readlink(package/'current'), old) + self.assertEqual(os.readlink(package/'previous'), new) + +class PathAliasTest(unittest.TestCase): + def test_root_aliases_rejected_before_any_operation(self): + for alias in ('/tmp/..', '//', '/tmp/../', '/usr/..', '/tmp/a/../b'): + with self.subTest(alias=alias): + result = subprocess.run(['bash', str(ROOT/'install_dependencies.sh'), '--destdir', alias], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn('normalized absolute',result.stderr) + def test_masked_timer_not_replaced(self): + with tempfile.TemporaryDirectory() as directory: + stage=Path(directory)/'stage' + units=stage/'etc/systemd/system'; units.mkdir(parents=True) + timer=units/'pman-maintenance-sources.timer'; timer.symlink_to('/dev/null') + result=subprocess.run(['bash',str(ROOT/'install_dependencies.sh'),'--destdir',str(stage),'--with-maintenance'],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(os.readlink(timer),'/dev/null') diff --git a/tests/maintenance/test_runner.py b/tests/maintenance/test_runner.py new file mode 100644 index 0000000..708e69d --- /dev/null +++ b/tests/maintenance/test_runner.py @@ -0,0 +1,30 @@ +import sys +import threading +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) +from pman_maintenance.runner import Runner, CommandError + +class RunnerTest(unittest.TestCase): + def test_executes_without_shell(self): + text = Runner().run([sys.executable, '-c', 'import sys; print(sys.argv[1])', '$(echo unsafe)']).text + self.assertEqual(text.strip(), '$(echo unsafe)') + def test_failure_reported(self): + with self.assertRaises(CommandError): + Runner().run([sys.executable, '-c', 'raise SystemExit(7)']) + def test_timeout(self): + with self.assertRaisesRegex(CommandError, 'Zeitlimit'): + Runner().run([sys.executable, '-c', 'import time; time.sleep(30)'], timeout=.1) + def test_running_job_can_be_cancelled(self): + cancel = threading.Event() + timer = threading.Timer(.1, cancel.set); timer.start() + try: + with self.assertRaisesRegex(CommandError, 'Abgebrochen'): + Runner(cancel).run([sys.executable, '-c', 'import time; time.sleep(30)']) + finally: + timer.join() + def test_missing_executable_reported(self): + with self.assertRaises(CommandError): + Runner().run(['/no/such/pman-test-command']) + +if __name__ == '__main__': unittest.main() diff --git a/tests/maintenance/test_sources.py b/tests/maintenance/test_sources.py new file mode 100644 index 0000000..ffad770 --- /dev/null +++ b/tests/maintenance/test_sources.py @@ -0,0 +1,44 @@ +import sys +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) +from pman_maintenance.sources import check_reference + +class SourcesTest(unittest.TestCase): + def setUp(self): self.ref = {'repository': 'community-scripts/ProxmoxVE', 'path': 'tools/pve/fstrim.sh', 'blob_sha': 'abc'} + def test_unchanged(self): + result = check_reference(self.ref, lambda url, etag: (200, [{'name': 'fstrim.sh', 'type': 'file', 'sha': 'abc'}], 'tag')) + self.assertEqual(result['status'], 'geprüfter Stand') + def test_changed(self): + self.assertEqual(check_reference(self.ref, lambda *a: (200, [{'name': 'fstrim.sh', 'type': 'file', 'sha': 'def'}], ''))['status'], 'Upstream geändert') + def test_missing(self): + self.assertEqual(check_reference(self.ref, lambda *a: (404, {}, ''))['status'], 'Quelle fehlt') + def test_rate_limit_not_current(self): + self.assertEqual(check_reference(self.ref, lambda *a: (403, {}, ''))['status'], 'Prüfung fehlgeschlagen') + def test_timeout_not_current(self): + def timeout(*args): raise TimeoutError() + self.assertEqual(check_reference(self.ref, timeout)['status'], 'Prüfung fehlgeschlagen') + def test_304_requires_cached_success(self): + result = check_reference(self.ref, lambda *a: (304, {}, '')) + self.assertEqual(result['status'], 'Prüfung fehlgeschlagen') + +if __name__ == '__main__': unittest.main() + +class CacheTest(unittest.TestCase): + def test_cached_304_unchanged(self): + result = check_reference({'repository':'r/r','path':'a/b','blob_sha':'abc'}, lambda *a:(304,{},''), {'observed_sha':'abc','etag':'tag'}) + self.assertEqual(result['status'],'geprüfter Stand') + def test_cached_304_changed(self): + result = check_reference({'repository':'r/r','path':'a/b','blob_sha':'abc'}, lambda *a:(304,{},''), {'observed_sha':'def','etag':'tag'}) + self.assertEqual(result['status'],'Upstream geändert') + def test_missing_file_in_directory_listing(self): + result = check_reference({'repository':'r/r','path':'a/b','blob_sha':'abc'}, lambda *a:(200,[],'')) + self.assertEqual(result['status'],'Quelle fehlt') + def test_malformed_metadata_fails(self): + result = check_reference({'repository':'r/r','path':'a/b','blob_sha':'abc'}, lambda *a:(200,{'content':'must not request file contents'},'')) + self.assertEqual(result['status'],'Prüfung fehlgeschlagen') + +class MalformedTest(unittest.TestCase): + def test_missing_sha_does_not_crash(self): + result = check_reference({'repository':'r/r','path':'a/b','blob_sha':'abc'}, lambda *a:(200,[{'name':'b','type':'file'}],'')) + self.assertEqual(result['status'],'Prüfung fehlgeschlagen') diff --git a/tests/maintenance/test_ui.py b/tests/maintenance/test_ui.py new file mode 100644 index 0000000..cc88e40 --- /dev/null +++ b/tests/maintenance/test_ui.py @@ -0,0 +1,14 @@ +import sys +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) +from pman_maintenance.ui import clean_text, navigate + +class UITest(unittest.TestCase): + def test_terminal_escape_removed(self): + self.assertEqual(clean_text('a\x1b[31mred\x1b[0m\x07'), 'ared') + def test_navigation_wraps(self): + self.assertEqual(navigate(0, -1, 4), 3) + self.assertEqual(navigate(3, 1, 4), 0) + +if __name__ == '__main__': unittest.main() diff --git a/tools/build-release.sh b/tools/build-release.sh new file mode 100755 index 0000000..301474c --- /dev/null +++ b/tools/build-release.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build matching core and optional-module assets without executing module actions. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output="${1:-$root/dist}" +mkdir -p -- "$output" +output=$(cd "$output" && pwd) +version=$(bash "$root/proxmox-manager.sh" --version | awk '{print $2}') +cp -- "$root/proxmox-manager.sh" "$output/proxmox-manager.sh" +cp -- "$root/install_dependencies.sh" "$output/install_dependencies.sh" +tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \ + --exclude=__pycache__ --exclude='*.pyc' -czf "$output/pman-maintenance-v$version.tar.gz" \ + -C "$root" proxmox-manager.sh install_dependencies.sh LICENSE docs/maintenance.md maintenance +( + cd "$output" + sha256sum proxmox-manager.sh install_dependencies.sh "pman-maintenance-v$version.tar.gz" >SHA256SUMS +) +printf 'Release assets: %s\n' "$output" From f4ae45f0174a285fd4e52cb698c94c17baa80f39 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Tue, 27 Oct 2026 15:37:42 +0100 Subject: [PATCH 2/4] fix: make maintenance TUI reports usable --- docs/maintenance.md | 10 +- maintenance/pman_maintenance/__init__.py | 2 +- maintenance/pman_maintenance/actions.py | 91 ++++++++-- maintenance/pman_maintenance/sources.py | 31 ++-- maintenance/pman_maintenance/ui.py | 20 +- tests/maintenance/test_installer.py | 2 +- tests/maintenance/test_report_regressions.py | 182 +++++++++++++++++++ 7 files changed, 306 insertions(+), 32 deletions(-) create mode 100644 tests/maintenance/test_report_regressions.py diff --git a/docs/maintenance.md b/docs/maintenance.md index 2afa5f8..6e5482b 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -31,8 +31,9 @@ pman-maintenance --scope host --report storage pman-maintenance --scope ct --id 101 --report volumes --json ``` -Use arrow keys to select, Enter to open, Page Up/Down to scroll, and Esc to -cancel/back. `q` exits. CT directory scans use Tab to select `/var`, `/home`, +Use arrow keys to select, Enter to open, Page Up/Down to move by one visible +page, and Esc to cancel/back. The output pane shows its current line range. +`q` exits on a fresh shell line. CT directory scans use Tab to select `/var`, `/home`, `/opt`, or `/srv`; the scan stays within the selected filesystem. Resizing and `NO_COLOR=1` are supported. A UTF-8 terminal is required; the module restores a UTF-8 locale even when the Bash core runs with `LC_ALL=C`. @@ -51,6 +52,11 @@ QEMU Guest Agent and unsupported guest operating systems are reported as unavailable. VM filesystem output can include read-only filesystems at 100%; it is diagnostic information and is not automatically interpreted as an alarm. +SMART output presents the overall health, temperature and power-on hours, plus +common error counters. Vendor-specific raw fields are not presented as +standardized measurements. The provenance view summarizes source status and +shows abbreviated reviewed/upstream commits. + The UI shows the target, exact command and impact before a change. Only `j` confirms it; the core's `--force` does not bypass this. Cancelling a running change is not a rollback. A cancelled CT directory scan may continue inside the diff --git a/maintenance/pman_maintenance/__init__.py b/maintenance/pman_maintenance/__init__.py index 3ef7b71..169c2f9 100644 --- a/maintenance/pman_maintenance/__init__.py +++ b/maintenance/pman_maintenance/__init__.py @@ -1,3 +1,3 @@ """Optional, local-only maintenance adapters for pman.""" -VERSION = '1.0.0' +VERSION = '1.0.1' INTERFACE = 1 diff --git a/maintenance/pman_maintenance/actions.py b/maintenance/pman_maintenance/actions.py index b8b1e7d..268cda7 100644 --- a/maintenance/pman_maintenance/actions.py +++ b/maintenance/pman_maintenance/actions.py @@ -212,7 +212,15 @@ def do_smart(ctx, runner): disks = json.loads(runner.run(['lsblk', '-J', '-d', '-o', 'NAME,TYPE,MODEL,SIZE']).text) previous = read_json(STATE / 'smart.json', {}) current = {} - lines = ['SMART-Rohattribute sind herstellerspezifisch; PASSED ist keine Ausfallgarantie.'] + lines = ['SMART ist herstellerspezifisch; PASSED ist keine Ausfallgarantie.'] + metric_groups = [ + ('Reallocation-Attribut (herstellerspezifisch)', ('Reallocated_Sector_Ct', 'Reallocate_NAND_Blk_Cnt', 'Reallocated_Event_Count')), + ('Pending-Attribut (herstellerspezifisch)', ('Current_Pending_Sector', 'Current_Pending_ECC_Cnt')), + ('Nicht korrigierbare Fehler', ('Offline_Uncorrectable', 'Reported_Uncorrect', 'Uncorrectable_Error_Cnt')), + ('CRC-Fehler', ('UDMA_CRC_Error_Count',)), + ('Verschleißattribut', ('Wear_Leveling_Count', 'Percent_Lifetime_Remain', 'Percent_Lifetime_Used', + 'Media_Wearout_Indicator', 'SSD_Life_Left')), + ] for disk in disks.get('blockdevices', []): if disk.get('type') != 'disk' or not re.fullmatch(r'[A-Za-z0-9_-]+', disk.get('name', '')): continue @@ -220,22 +228,75 @@ def do_smart(ctx, runner): data = json.loads(runner.run(['smartctl', '-a', '-j', dev], allowed=None).text) # Identify history by serial internally, never display or transmit it. identity = data.get('serial_number') or dev - values = {} + attributes = {} for row in data.get('ata_smart_attributes', {}).get('table', []): - values[row.get('name', str(row.get('id')))] = row.get('raw', {}).get('value') - lines.extend(['', '{} {} {}'.format(dev, disk.get('model', ''), disk.get('size', '')), - 'Health: {}'.format(data.get('smart_status', {}).get('passed', 'nicht verfügbar')), - 'Temperatur: {}'.format(data.get('temperature', {}).get('current', 'nicht verfügbar')), - 'Betriebsstunden: {}'.format(data.get('power_on_time', {}).get('hours', 'nicht verfügbar'))]) - for name, value in values.items(): - if any(word in name.lower() for word in ('realloc', 'pending', 'uncorrect', 'crc', 'wear', 'temperature', 'power_on')): - old = previous.get(identity, {}).get(name) - delta = ' (vorher {})'.format(old) if old is not None else ' (erste Messung)' - lines.append('{}: {}{}'.format(name, value, delta)) - nvme = data.get('nvme_smart_health_information_log') + attributes[row.get('name', str(row.get('id')))] = row.get('raw', {}) + passed = data.get('smart_status', {}).get('passed') + health = 'PASSED' if passed is True else ('FEHLER' if passed is False else 'nicht verfügbar') + temperature = data.get('temperature', {}).get('current') + if temperature is None: + raw_temperature = attributes.get('Temperature_Celsius', {}).get('string', '') + match = re.match(r'\s*(-?\d+)', str(raw_temperature)) + temperature = match.group(1) if match else None + temp_text = '{} °C'.format(temperature) if temperature is not None else 'nicht verfügbar' + hours = data.get('power_on_time', {}).get('hours') + hours_text = '{} h'.format(hours) if hours is not None else 'nicht verfügbar' + lines.extend(['', '{} {} ({})'.format(dev, disk.get('model') or 'unbekanntes Modell', disk.get('size', '?')), + 'SMART-Status: {} | Temperatur: {} | Betriebsstunden: {}'.format(health, temp_text, hours_text)]) + current_values = {} + counters = [] + for label, names in metric_groups[:4]: + found = next(((name, attributes[name]) for name in names if name in attributes), None) + if found is None: + counters.append('{}: nicht gemeldet'.format(label)) + continue + name, raw = found + value = raw.get('string') or raw.get('value') + current_values[name] = value + display = str(value).split(' (', 1)[0] if value is not None else 'nicht verfügbar' + old = previous.get(identity, {}).get(name) + try: + delta = int(display) - int(str(old).split(' (', 1)[0]) if old is not None else 0 + except ValueError: + delta = 0 + change = ' (+{} seit letzter Messung)'.format(delta) if delta > 0 else '' + counters.append('{}: {}{}'.format(label, display, change)) + lines.extend(counters) + wear = next(((name, attributes[name]) for name in metric_groups[4][1] if name in attributes), None) + if wear: + name, raw = wear + value = raw.get('string') or raw.get('value') + current_values[name] = value + display = str(value).split(' (', 1)[0] if value is not None else 'nicht verfügbar' + lines.append('Verschleißattribut {}: {} (herstellerspezifisch)'.format(name, display)) + else: + lines.append('Verschleißattribut: nicht gemeldet') + for name, raw in attributes.items(): + if name not in current_values and name in {item for _, names in metric_groups for item in names}: + current_values[name] = raw.get('string') or raw.get('value') + nvme = data.get('nvme_smart_health_information_log') or {} if nvme: - lines.append('NVMe: ' + json.dumps(nvme, ensure_ascii=False)) - current[identity] = values + lines.append('NVMe-Gesundheit:') + nvme_fields = ( + ('critical_warning', 'Kritische Warnung'), + ('temperature', 'Temperatur'), + ('percentage_used', 'Verschleiß'), + ('available_spare', 'Reserve'), + ('media_errors', 'Medienfehler'), + ('num_err_log_entries', 'Fehlerprotokolleinträge'), + ) + for key, label in nvme_fields: + value = nvme.get(key) + if value is None: + continue + suffix = ' °C' if key == 'temperature' else ('%' if key in ('percentage_used', 'available_spare') else '') + lines.append('{}: {}{}'.format(label, value, suffix)) + warning = nvme.get('critical_warning') + if warning is not None and warning != 0: + lines.append('NVMe meldet kritische Warnungsbits: {}'.format(warning)) + if not any(nvme.get(key) is not None for key, _ in nvme_fields): + lines.append('Keine bekannten NVMe-Gesundheitswerte gemeldet.') + current[identity] = current_values write_json(STATE / 'smart.json', current) return '\n'.join(lines) diff --git a/maintenance/pman_maintenance/sources.py b/maintenance/pman_maintenance/sources.py index c374f8f..260a40c 100644 --- a/maintenance/pman_maintenance/sources.py +++ b/maintenance/pman_maintenance/sources.py @@ -1,6 +1,7 @@ """Public GitHub metadata checks. No source downloads, imports or execution.""" import datetime import json +from collections import Counter import urllib.error import urllib.parse import urllib.request @@ -76,19 +77,27 @@ def source_report(): manifest = json.loads(MANIFEST.read_text()) status = read_json(STATE / 'source-status.json', {}) or {} checked = {(r['repository'], r['path']): r for r in status.get('references', [])} - lines = ['Quellenprüfung: ' + status.get('checked_at', 'Noch nicht geprüft'), - manifest['notice'], '', 'Referenzquellen (keine ausführbaren Downloads):'] + rows = [checked.get((reference['repository'], reference['path']), {}) + for reference in manifest['references']] + counts = Counter(row.get('status', 'Noch nicht geprüft') for row in rows) + lines = ['Zuletzt geprüft: ' + status.get('checked_at', 'Noch nicht geprüft'), + '{} Quellen | geprüft: {} | geändert: {} | fehlen: {} | Fehler: {} | offen: {}'.format( + len(rows), counts['geprüfter Stand'], counts['Upstream geändert'], counts['Quelle fehlt'], + counts['Prüfung fehlgeschlagen'], counts['Noch nicht geprüft']), + 'Eigene Adapter; Community-Code und Telemetrie werden nicht ausgeführt.', + '', 'Referenzen:'] for reference in manifest['references']: row = checked.get((reference['repository'], reference['path']), {}) - lines.extend([ - '{} / {}: {}'.format(reference['repository'], reference['path'], row.get('status', 'Noch nicht geprüft')), - ' Prüfzeitpunkt: ' + row.get('checked_at', '—'), - ' Geprüfter Commit: ' + reference['reviewed_commit'], - ' Git-Blob SHA-1: ' + reference['blob_sha'], - ]) + status_text = row.get('status', 'Noch nicht geprüft') + reviewed = reference['reviewed_commit'][:8] + observed = row.get('observed_sha') + if status_text == 'geprüfter Stand': + detail = 'geprüft: {}'.format(reviewed) + elif status_text == 'Upstream geändert' and observed: + detail = 'Referenz {} → aktuell {}'.format(reviewed, observed[:8]) + else: + detail = status_text + lines.append('{} / {} | {}'.format(reference['repository'], reference['path'], detail)) if row.get('error'): lines.append(' Fehler: ' + row['error']) - lines.extend(['', 'Adapter-Herkunft:']) - for name, adapter in manifest['adapters'].items(): - lines.append('{}: {} | Referenz: {}'.format(name, adapter['origin'], adapter.get('reference') or 'keine')) return '\n'.join(lines) diff --git a/maintenance/pman_maintenance/ui.py b/maintenance/pman_maintenance/ui.py index 08470b3..35e3341 100644 --- a/maintenance/pman_maintenance/ui.py +++ b/maintenance/pman_maintenance/ui.py @@ -22,6 +22,12 @@ def navigate(index, direction, count): return (index + direction) % count if count else 0 +def page_offset(offset, direction, page_size, line_count): + last = max(0, line_count - max(1, page_size)) + target = offset + (1 if direction > 0 else -1) * max(1, page_size) + return min(last, max(0, target)) + + def mutate(key, ctx, runner): private_dir(STATE) flags = os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW @@ -48,6 +54,9 @@ def run_tui(ctx): curses.wrapper(_screen, ctx) except curses.error as exc: raise RuntimeError('Terminal unterstützt die Oberfläche nicht; TERM und Fenstergröße prüfen.') from exc + finally: + # curses restores terminal mode but leaves the cursor at the last status row. + print() def _screen(screen, ctx): @@ -139,7 +148,13 @@ def put(y, text, attr=0): scroll = min(scroll, max(0, len(wrapped) - page)) for row, line in enumerate(wrapped[scroll:scroll + page], start_y + 1): put(row, line) - put(height - 2, '↑↓ Menü | Enter öffnen | Bild↑↓ Ausgabe | Tab Scanpfad | Esc zurück/Abbruch | q Ende', color) + first_line = scroll + 1 if wrapped else 0 + last_line = min(len(wrapped), scroll + page) + put(height - 3, 'Ausgabe Zeilen {}–{} von {}'.format(first_line, last_line, len(wrapped)), color) + help_text = '↑↓ Menü | Enter öffnen | Bild↑↓ blättern | Tab Pfad | Esc zurück | q Ende' + if width < 74: + help_text = '↑↓ Menü | Enter | PgUp/PgDn | Esc | q Ende' + put(height - 2, help_text, color) put(height - 1, status, color) screen.refresh() key = screen.getch() @@ -165,7 +180,8 @@ def put(y, text, attr=0): if key in (curses.KEY_UP, curses.KEY_DOWN): selected = navigate(selected, -1 if key == curses.KEY_UP else 1, len(registry(ctx)) + 1) elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE): - scroll = max(0, scroll + (-5 if key == curses.KEY_PPAGE else 5)) + if height >= 12 and width >= 45: + scroll = page_offset(scroll, -1 if key == curses.KEY_PPAGE else 1, page, len(wrapped)) elif key == 9 and ctx.scope == 'ct': mounts = ['/var', '/home', '/opt', '/srv'] ctx = Context(ctx.scope, ctx.id, mounts[(mounts.index(ctx.mount) + 1) % len(mounts)]) diff --git a/tests/maintenance/test_installer.py b/tests/maintenance/test_installer.py index bcda342..ac5f799 100644 --- a/tests/maintenance/test_installer.py +++ b/tests/maintenance/test_installer.py @@ -66,7 +66,7 @@ def install(*args): package = stage / 'usr/local/lib/pman/maintenance' old = os.readlink(package/'current') version = source/'maintenance/pman_maintenance/__init__.py' - version.write_text(version.read_text().replace('1.0.0','1.0.1')) + version.write_text(version.read_text().replace("VERSION = '1.0.1'", "VERSION = '1.0.2'")) install('--with-maintenance') new = os.readlink(package/'current') self.assertNotEqual(new, old) diff --git a/tests/maintenance/test_report_regressions.py b/tests/maintenance/test_report_regressions.py new file mode 100644 index 0000000..990030f --- /dev/null +++ b/tests/maintenance/test_report_regressions.py @@ -0,0 +1,182 @@ +import json +import sys +import unittest +from pathlib import Path +from contextlib import redirect_stdout +from io import StringIO +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) + +from pman_maintenance.actions import Context, do_smart +from pman_maintenance.runner import Result +from pman_maintenance import ui, sources + + +class FakeRunner: + def __init__(self, responses): + self.responses = responses + + def run(self, argv, **kwargs): + return Result(argv, 0, self.responses[tuple(argv)]) + + +class SmartReportRegressionTest(unittest.TestCase): + def run_report(self, smart, previous=None): + devices = {'blockdevices': [ + {'name': 'sdb', 'type': 'disk', 'model': 'Test SSD', 'size': '1G'} + ]} + runner = FakeRunner({ + ('lsblk', '-J', '-d', '-o', 'NAME,TYPE,MODEL,SIZE'): json.dumps(devices), + ('smartctl', '-a', '-j', '/dev/sdb'): json.dumps(smart), + }) + with patch('pman_maintenance.actions.shutil.which', return_value='/usr/sbin/smartctl'), \ + patch('pman_maintenance.actions.read_json', return_value=previous or {}), \ + patch('pman_maintenance.actions.write_json'): + return do_smart(Context('host'), runner) + + def test_crucial_temperature_uses_human_readable_value_and_curated_health(self): + devices = {'blockdevices': [ + {'name': 'sdb', 'type': 'disk', 'model': 'CT240BX500SSD1', 'size': '223.6G'} + ]} + smart = { + 'model_name': 'CT240BX500SSD1', + 'serial_number': 'must-not-be-displayed', + 'smart_status': {'passed': True}, + 'temperature': {'current': 40}, + 'power_on_time': {'hours': 19517}, + 'ata_smart_attributes': {'table': [ + {'id': 5, 'name': 'Reallocate_NAND_Blk_Cnt', 'raw': { + 'value': 0, 'string': '0'}}, + {'id': 194, 'name': 'Temperature_Celsius', 'raw': { + 'value': 322123595816, 'string': '40 (Min/Max 16/75)'}}, + {'id': 197, 'name': 'Current_Pending_ECC_Cnt', 'raw': { + 'value': 0, 'string': '0'}}, + {'id': 198, 'name': 'Offline_Uncorrectable', 'raw': { + 'value': 0, 'string': '0'}}, + {'id': 199, 'name': 'UDMA_CRC_Error_Count', 'raw': { + 'value': 5, 'string': '5'}}, + ]}, + } + runner = FakeRunner({ + ('lsblk', '-J', '-d', '-o', 'NAME,TYPE,MODEL,SIZE'): json.dumps(devices), + ('smartctl', '-a', '-j', '/dev/sdb'): json.dumps(smart), + }) + with patch('pman_maintenance.actions.shutil.which', return_value='/usr/sbin/smartctl'), \ + patch('pman_maintenance.actions.read_json', return_value={}), \ + patch('pman_maintenance.actions.write_json'): + report = do_smart(Context('host'), runner) + + self.assertIn('PASSED', report) + self.assertIn('40 °C', report) + self.assertIn('19517', report) + self.assertIn('5', report) + self.assertNotIn('322123595816', report) + self.assertNotIn('Health: True', report) + self.assertNotIn('erste Messung', report) + self.assertNotIn('must-not-be-displayed', report) + self.assertIn('Verschleißattribut', report) + + def test_nvme_health_values_are_curated_and_visible(self): + report = self.run_report({ + 'serial_number': 'private', 'smart_status': {'passed': True}, + 'nvme_smart_health_information_log': { + 'critical_warning': 0, 'temperature': 37, 'available_spare': 100, + 'percentage_used': 12, 'media_errors': 2, 'num_err_log_entries': 4, + }, + }) + self.assertIn('NVMe-Gesundheit', report) + self.assertIn('Temperatur: 37 °C', report) + self.assertIn('Verschleiß: 12%', report) + self.assertIn('Medienfehler: 2', report) + self.assertNotIn('private', report) + + def test_nvme_nonzero_warning_is_explicit(self): + report = self.run_report({'nvme_smart_health_information_log': { + 'critical_warning': 2, 'temperature': 40, + }}) + self.assertIn('NVMe meldet kritische Warnungsbits: 2', report) + + def test_temperature_falls_back_to_smart_attribute_string(self): + report = self.run_report({'ata_smart_attributes': {'table': [ + {'name': 'Temperature_Celsius', 'raw': {'value': 322123595807, 'string': '40 (Min/Max 16/75)'}} + ]}}) + self.assertIn('Temperatur: 40 °C', report) + self.assertNotIn('322123595807', report) + + def test_failed_smart_status_is_not_truthified(self): + report = self.run_report({'smart_status': {'passed': False}}) + self.assertIn('SMART-Status: FEHLER', report) + + def test_positive_counter_delta_is_reported(self): + report = self.run_report({'ata_smart_attributes': {'table': [ + {'name': 'UDMA_CRC_Error_Count', 'raw': {'value': 6, 'string': '6'}} + ]}}, previous={'/dev/sdb': {'UDMA_CRC_Error_Count': '5'}}) + self.assertIn('CRC-Fehler: 6 (+1 seit letzter Messung)', report) + + +class SourceReportRegressionTest(unittest.TestCase): + def test_source_report_is_a_compact_status_summary(self): + manifest = { + 'notice': 'Eigene Adapter; Community-Code wird nicht ausgeführt.', + 'adapters': {'smart': {'origin': 'own'}}, + 'references': [ + {'repository': 'community-scripts/ProxmoxVE', 'path': 'tools/pve/fstrim.sh', + 'reviewed_commit': 'long-commit', 'blob_sha': 'long-blob'}, + {'repository': 'community-scripts/ProxmoxVE', 'path': 'tools/pve/disk-health.sh', + 'reviewed_commit': 'long-commit', 'blob_sha': 'long-blob'}, + ], + } + state = {'checked_at': '2026-10-27T12:00:00Z', 'references': [ + {'repository': 'community-scripts/ProxmoxVE', 'path': 'tools/pve/fstrim.sh', 'status': 'geprüfter Stand'}, + {'repository': 'community-scripts/ProxmoxVE', 'path': 'tools/pve/disk-health.sh', 'status': 'Upstream geändert'}, + ]} + with patch.object(sources, 'MANIFEST') as manifest_path, \ + patch.object(sources, 'read_json', return_value=state): + manifest_path.read_text.return_value = json.dumps(manifest) + report = sources.source_report() + + self.assertIn('2 Quellen', report) + self.assertIn('geprüft: 1', report) + self.assertIn('geändert: 1', report) + self.assertIn('Upstream geändert', report) + self.assertNotIn('long-commit', report) + self.assertNotIn('long-blob', report) + self.assertLessEqual(len(report.splitlines()), 8) + + +class PageNavigationRegressionTest(unittest.TestCase): + def test_page_down_moves_one_viewport_and_clamps_to_last_page(self): + self.assertTrue(hasattr(ui, 'page_offset'), 'missing viewport-based paging helper') + self.assertEqual(ui.page_offset(0, 1, 12, 100), 12) + self.assertEqual(ui.page_offset(90, 1, 12, 100), 88) + + def test_page_up_moves_one_viewport_and_never_goes_negative(self): + self.assertTrue(hasattr(ui, 'page_offset'), 'missing viewport-based paging helper') + self.assertEqual(ui.page_offset(30, -1, 12, 100), 18) + self.assertEqual(ui.page_offset(4, -1, 12, 100), 0) + + def test_exit_returns_shell_to_a_fresh_line(self): + class Screen: + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): return ord('q') + + screen = Screen() + output = StringIO() + with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ + patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.locale, 'setlocale'), \ + patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'), \ + redirect_stdout(output): + ui.run_tui(Context('host')) + self.assertEqual(output.getvalue(), '\n') + + +if __name__ == '__main__': + unittest.main() From 304c5436187af6833f45b320d9bef5f6b0cc5aa5 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Tue, 27 Oct 2026 17:59:14 +0100 Subject: [PATCH 3/4] feat: launch verified community PVE tools --- docs/maintenance.md | 28 +++-- maintenance/pman_maintenance/__init__.py | 2 +- maintenance/pman_maintenance/community.py | 126 ++++++++++++++++++++++ maintenance/pman_maintenance/sources.py | 2 +- maintenance/pman_maintenance/ui.py | 102 ++++++++++++++++-- maintenance/sources.json | 66 +++++------- tests/maintenance/test_community_tools.py | 104 ++++++++++++++++++ tests/maintenance/test_installer.py | 2 +- 8 files changed, 375 insertions(+), 57 deletions(-) create mode 100644 maintenance/pman_maintenance/community.py create mode 100644 tests/maintenance/test_community_tools.py diff --git a/docs/maintenance.md b/docs/maintenance.md index 6e5482b..58f38bd 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -63,6 +63,18 @@ change is not a rollback. A cancelled CT directory scan may continue inside the guest until its independent 55-second timeout; the outer command is also bounded. Changes are serialized by a private lock. There is no unattended mutation CLI. +The host menu also has **Community PVE-Werkzeuge** with eight selected Host +Management tools: Disk Health, LXC Filesystem Trim, LXC Cleaner, Kernel Clean, +Clean Orphaned LVM, Host Backup, CPU Scaling Governor and LXC Updater. These are +the upstream scripts, clearly marked as Community Scripts; their own interactive +prompts and target selection remain intact. Before launch, pman checks the live +GitHub metadata and downloads only the reviewed commit, then verifies the Git +blob SHA-1. A changed, missing or unreachable upstream source blocks launch. +The core runtime library is also pinned and checked. Its optional telemetry API +is intentionally omitted. A helper can still install packages, delete selected +logs/volumes, change host settings or update guests as described in its impact +text; pman asks for a separate confirmation before starting it. + TRIM reports unused blocks to lower storage layers. Its reported byte count is not a guarantee of newly available pool space. Guest filesystem use, thin-volume allocation and pool data/metadata use are displayed separately. Volume names @@ -73,17 +85,17 @@ reconfiguration is provided in this first version. ## Community Scripts provenance and freshness -All adapters are independent local implementations using native commands. -Community Scripts are declared as reviewed functional references where relevant; -no upstream script, runtime library or telemetry is downloaded or executed. -`maintenance/sources.json` records each adapter's origin and reference sources, -reviewed commits, Git blob checksums and upstream licence. The UI's provenance -view exposes these details and the last source-check result. +The standard maintenance actions are independent local implementations using +native commands. `maintenance/sources.json` records their references and the +reviewed Community tools, commits, Git blob checksums and upstream licence. The +UI's provenance view exposes these details and the last source-check result. +Only the eight tools listed above are launchable; other category entries are not +exposed as arbitrary shell commands. The installed `pman-maintenance-sources.timer` runs daily with random delay. It requests public GitHub **directory listings containing file metadata**, not -script contents. It compares relevant file blob hashes with the reviewed -manifest, including referenced Core dependencies. ETag requests reduce traffic. +script contents. It compares file blob hashes with the reviewed manifest, +including launchable tools and their pinned Core dependency. ETag requests reduce traffic. No credentials, guest data or SMART history are sent. Results include timestamps and one of: `geprüfter Stand`, `Upstream geändert`, `Quelle fehlt`, or `Prüfung fehlgeschlagen`. A failed network/API request never means "up to date". diff --git a/maintenance/pman_maintenance/__init__.py b/maintenance/pman_maintenance/__init__.py index 169c2f9..e4a0e6d 100644 --- a/maintenance/pman_maintenance/__init__.py +++ b/maintenance/pman_maintenance/__init__.py @@ -1,3 +1,3 @@ """Optional, local-only maintenance adapters for pman.""" -VERSION = '1.0.1' +VERSION = '1.0.2' INTERFACE = 1 diff --git a/maintenance/pman_maintenance/community.py b/maintenance/pman_maintenance/community.py new file mode 100644 index 0000000..2d15c68 --- /dev/null +++ b/maintenance/pman_maintenance/community.py @@ -0,0 +1,126 @@ +"""Pinned Community Scripts launchers with fail-closed source verification.""" +import hashlib +import json +import os +import pwd +import re +import subprocess +import tempfile +import urllib.request +from pathlib import Path + +from . import sources + +REPOSITORY = 'community-scripts/ProxmoxVE' +REVIEWED_COMMIT = '28fd647e6aed3db6d771cade4fb7c8c5deb48af3' +CORE_REPOSITORY = 'community-scripts/core' +CORE_COMMIT = 'af0fe772dd02bed10fd0124e3aacc86f2bc900d7' +CORE_SHA = '0e18236d58a4aec4b200c231c8a4a2387e378bbb' + +# These are host tools selected from the Host Management category. +TOOLS = ( + {'id': 'disk-health', 'title': 'PVE Disk Health Tool', + 'path': 'tools/pve/disk-health.sh', 'sha': 'fa12746aedce484fe760bcccf4d1bcb3de58501b', + 'summary': 'SMART-Übersicht; bietet optional einen kurzen SMART-Selbsttest an.', + 'impact': 'Nur Diagnose bis auf Nachfrage. Kann smartmontools und nvme-cli per APT installieren.'}, + {'id': 'fstrim', 'title': 'PVE LXC Filesystem Trim', + 'path': 'tools/pve/fstrim.sh', 'sha': 'b63b4267c97ce3c62df4ad26c5e85131562f5950', + 'summary': 'TRIM für LXC-Dateisysteme auf SSD- und Thin-Provisioning-Speichern.', + 'impact': 'Ändert keine Gastdateien; meldet freie Blöcke an Storage. Betrifft ausgewählte CTs gemäß Helper-Menü.'}, + {'id': 'clean-lxcs', 'title': 'PVE LXC Cleaner', + 'path': 'tools/pve/clean-lxcs.sh', 'sha': 'e54a307a980130c43134c68d28a8c7cfcdf6b260', + 'summary': 'Bereinigt Paketcache und auf Wunsch Logs in ausgewählten LXC-Gästen.', + 'impact': 'Löscht gewählte Cache-/Log-Dateien in den bestätigten CTs; Logs sind danach nicht mehr verfügbar.'}, + {'id': 'kernel-clean', 'title': 'PVE Kernel Clean', + 'path': 'tools/pve/kernel-clean.sh', 'sha': '98351a487f14ed77df99f8945a9f45bf0b1870c0', + 'summary': 'Entfernt nicht mehr benötigte Kernelpakete vom Proxmox-Host.', + 'impact': 'Paketentfernung auf dem Host. Vor Bestätigung installierte und aktive Kernel prüfen.'}, + {'id': 'clean-orphaned-lvm', 'title': 'PVE Clean Orphaned LVM', + 'path': 'tools/pve/clean-orphaned-lvm.sh', 'sha': '18548a37a27a74a0bd7510b58efc0205618b2f64', + 'summary': 'Findet nicht zugeordnete LVM-Volumes und bietet interaktives Entfernen an.', + 'impact': 'Kann LVM-Volumes dauerhaft löschen. Helper-Auswahl sorgfältig prüfen; kein Papierkorb.'}, + {'id': 'host-backup', 'title': 'PVE Host Backup', + 'path': 'tools/pve/host-backup.sh', 'sha': '4a579cd1cb14dbe0623608bd238ea92e3bc84b07', + 'summary': 'Erstellt ein benutzerdefiniertes Backup ausgewählter Host-Dateien und Verzeichnisse.', + 'impact': 'Schreibt ein Archiv am gewählten Ort. Ein Backup auf demselben Host schützt nicht gegen Host-Ausfall.'}, + {'id': 'scaling-governor', 'title': 'PVE CPU Scaling Governor', + 'path': 'tools/pve/scaling-governor.sh', 'sha': '79622844c5051349a05acdd46490a5242cfb656c', + 'summary': 'Konfiguriert den CPU-Frequenz-Governor des Hosts.', + 'impact': 'Ändert Host-Energie-/Leistungsverhalten und möglicherweise Startkonfiguration.'}, + {'id': 'update-lxcs', 'title': 'PVE LXC Updater', + 'path': 'tools/pve/update-lxcs.sh', 'sha': 'ec3dd34f2ff1b1df3c4ce7e664aab5958f9dfca5', + 'summary': 'Aktualisiert Betriebssystempakete in ausgewählten LXC-Gästen.', + 'impact': 'Installiert Updates in bestätigten CTs; kann Dienste oder Gäste beeinflussen.'}, +) + + +def git_blob_sha(data): + return hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() + + +def _fetch(url, limit=2 * 1024 * 1024): + request = urllib.request.Request(url, headers={ + 'Accept': 'application/vnd.github+json', 'User-Agent': 'pman-maintenance/1', + 'X-GitHub-Api-Version': '2022-11-28', + }) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=12) as response: + body = response.read(limit + 1) + if len(body) > limit: + raise RuntimeError('Upstream-Datei überschreitet die erlaubte Größe.') + return body + + +def _reference(repository, path): + manifest = json.loads(sources.MANIFEST.read_text()) + return next((item for item in manifest['references'] + if item['repository'] == repository and item['path'] == path), None) + + +def _check_current(repository, path, expected): + reference = _reference(repository, path) + if not reference or reference['blob_sha'] != expected: + raise RuntimeError('Werkzeug fehlt in der geprüften Quellenliste.') + result = sources.check_reference(reference) + if result.get('status') != 'geprüfter Stand': + raise RuntimeError('Upstream ist nicht als geprüfter Stand bestätigt ({}). Start abgebrochen.'.format( + result.get('status', 'Prüfung fehlgeschlagen'))) + + +def _download_pinned(repository, commit, path, expected): + url = 'https://raw.githubusercontent.com/{}/{}/{}'.format(repository, commit, path) + data = _fetch(url) + if git_blob_sha(data) != expected: + raise RuntimeError('Prüfsumme von {} passt nicht zum geprüften Stand.'.format(path)) + return data + + +def run_tool(tool_id): + tool = next((item for item in TOOLS if item['id'] == tool_id), None) + if not tool: + raise ValueError('Unbekanntes Community-Werkzeug.') + # Check live upstream metadata immediately before fetching/running pinned bytes. + _check_current(REPOSITORY, tool['path'], tool['sha']) + core_path = 'core/core.func' + _check_current(CORE_REPOSITORY, core_path, CORE_SHA) + script = _download_pinned(REPOSITORY, REVIEWED_COMMIT, tool['path'], tool['sha']) + core = _download_pinned(CORE_REPOSITORY, CORE_COMMIT, core_path, CORE_SHA) + with tempfile.TemporaryDirectory(prefix='pman-community-') as directory: + root = Path(directory) + (root / 'core').mkdir(mode=0o700) + script_path = root / 'tool.sh' + script_path.write_bytes(script) + script_path.chmod(0o700) + (root / 'core' / 'core.func').write_bytes(core) + term = os.environ.get('TERM', 'xterm') + if not re.fullmatch(r'[A-Za-z0-9._+-]{1,64}', term): + term = 'xterm' + env = { + 'PATH': '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'HOME': pwd.getpwuid(0).pw_dir, + 'LANG': 'C.UTF-8', + 'TERM': term, + 'COMMUNITY_SCRIPTS_CORE_URL': root.as_uri(), + } + # Deliberately do not supply api/api.func: optional upstream telemetry is disabled. + return subprocess.run(['bash', str(script_path)], cwd='/', env=env, check=False).returncode diff --git a/maintenance/pman_maintenance/sources.py b/maintenance/pman_maintenance/sources.py index 260a40c..cc9b1df 100644 --- a/maintenance/pman_maintenance/sources.py +++ b/maintenance/pman_maintenance/sources.py @@ -84,7 +84,7 @@ def source_report(): '{} Quellen | geprüft: {} | geändert: {} | fehlen: {} | Fehler: {} | offen: {}'.format( len(rows), counts['geprüfter Stand'], counts['Upstream geändert'], counts['Quelle fehlt'], counts['Prüfung fehlgeschlagen'], counts['Noch nicht geprüft']), - 'Eigene Adapter; Community-Code und Telemetrie werden nicht ausgeführt.', + 'Eigene Adapter; geprüfte Community-Tools nur auf Auswahl und mit Versionsprüfung.', '', 'Referenzen:'] for reference in manifest['references']: row = checked.get((reference['repository'], reference['path']), {}) diff --git a/maintenance/pman_maintenance/ui.py b/maintenance/pman_maintenance/ui.py index 35e3341..cf3f7d3 100644 --- a/maintenance/pman_maintenance/ui.py +++ b/maintenance/pman_maintenance/ui.py @@ -11,6 +11,7 @@ from .runner import Runner from .sources import source_report from .state import STATE, private_dir +from . import community def clean_text(value): @@ -72,9 +73,12 @@ def _screen(screen, ctx): selected = 0 scroll = 0 output = ['Diagnosen ändern keine Gastdaten. Änderungen erfordern eine eigene Bestätigung.', - 'Quelle: eigene Adapter; Community Scripts dienen als gekennzeichnete Referenz.', + 'Community PVE-Werkzeuge sind separat im Host-Menü verfügbar; Start nur nach Bestätigung.', 'CT-Scanpfad mit Tab umschalten: /var, /home, /opt, /srv.'] pending = None + pending_community = None + community_mode = False + community_selected = 0 busy = False cancel = threading.Event() messages = queue.Queue() @@ -133,14 +137,22 @@ def put(y, text, attr=0): put(1, 'Vergrößern oder q zum Beenden.') else: actions = registry(ctx) - menu = [a.title + (' [Änderung]' if a.mutation else '') for a in actions] - menu.append('Herkunft und Aktualitätsstatus') + if community_mode: + menu = [tool['title'] for tool in community.TOOLS] + ['← Zurück zum Wartungsmenü'] + active_selected = community_selected + else: + menu = [a.title + (' [Änderung]' if a.mutation else '') for a in actions] + if ctx.scope == 'host': + menu.append('Community PVE-Werkzeuge ({})'.format(len(community.TOOLS))) + menu.append('Herkunft und Aktualitätsstatus') + active_selected = selected menu_height = min(len(menu), max(3, height // 3)) - first = max(0, selected - menu_height + 1) - put(0, 'pman Wartung | {} {} | Scan: {}'.format(ctx.scope.upper(), ctx.id or '', ctx.mount), color | curses.A_BOLD) + first = max(0, active_selected - menu_height + 1) + title = 'pman Wartung | COMMUNITY PVE-WERKZEUGE' if community_mode else 'pman Wartung | {} {} | Scan: {}'.format(ctx.scope.upper(), ctx.id or '', ctx.mount) + put(0, title, color | curses.A_BOLD) for row, index in enumerate(range(first, min(len(menu), first + menu_height)), 2): - put(row, ('> ' if index == selected else ' ') + menu[index], - curses.A_REVERSE if index == selected else 0) + put(row, ('> ' if index == active_selected else ' ') + menu[index], + curses.A_REVERSE if index == active_selected else 0) start_y = menu_height + 3 put(start_y, '─' * (width - 1), color) page = max(1, height - start_y - 4) @@ -152,8 +164,12 @@ def put(y, text, attr=0): last_line = min(len(wrapped), scroll + page) put(height - 3, 'Ausgabe Zeilen {}–{} von {}'.format(first_line, last_line, len(wrapped)), color) help_text = '↑↓ Menü | Enter öffnen | Bild↑↓ blättern | Tab Pfad | Esc zurück | q Ende' + if community_mode: + help_text = '↑↓ Werkzeug | Enter Details/Start | Esc zurück | q Ende' if width < 74: help_text = '↑↓ Menü | Enter | PgUp/PgDn | Esc | q Ende' + if community_mode: + help_text = '↑↓ Werkzeug | Enter | Esc zurück | q Ende' put(height - 2, help_text, color) put(height - 1, status, color) screen.refresh() @@ -165,6 +181,35 @@ def put(y, text, attr=0): cancel.set() status = 'Abbruch angefordert; CT-Scan kann bis zum Gast-Zeitlimit (55 s) weiterlaufen.' continue + if pending_community: + if key in (ord('j'), ord('J')): + tool = pending_community + pending_community = None + status = 'Prüfe Quellstand und starte Helper Script …' + put(height - 1, status, color) + screen.refresh() + try: + curses.endwin() + exit_code = community.run_tool(tool['id']) + output = ['Community Script beendet.', 'Exit-Code: {}'.format(exit_code), + 'Quelle: community-scripts/ProxmoxVE @ {}'.format(community.REVIEWED_COMMIT[:12])] + status = 'Abgeschlossen' if exit_code == 0 else 'Helper Script mit Fehler beendet' + except Exception as exc: + output = ['Start abgebrochen:', clean_text(exc)] + status = 'Quelle geändert, nicht verfügbar oder Start fehlgeschlagen' + finally: + try: + curses.reset_prog_mode() + screen.keypad(True) + screen.refresh() + except curses.error: + pass + continue + if key in (27, ord('n'), ord('N'), ord('q')): + pending_community = None + output = ['Start abgebrochen; es wurde kein Helper Script ausgeführt.'] + status = 'Bereit' + continue if pending: if key in (ord('j'), ord('J')): action = pending @@ -176,9 +221,25 @@ def put(y, text, attr=0): status = 'Bereit' continue if key in (ord('q'), 27): + if community_mode: + community_mode = False + selected = len(registry(ctx)) + output = ['Community-Werkzeugmenü geschlossen.'] + status = 'Bereit' + continue return if key in (curses.KEY_UP, curses.KEY_DOWN): - selected = navigate(selected, -1 if key == curses.KEY_UP else 1, len(registry(ctx)) + 1) + if community_mode: + community_selected = navigate(community_selected, -1 if key == curses.KEY_UP else 1, + len(community.TOOLS) + 1) + if community_selected < len(community.TOOLS): + tool = community.TOOLS[community_selected] + output = [tool['summary'], '', 'Risiko / Auswirkung:', tool['impact'], '', + 'Quelle: {} @ {}'.format(tool['path'], community.REVIEWED_COMMIT[:12]), + 'Lizenz: MIT; Helper wird als Community-Code ausgeführt.'] + else: + selected = navigate(selected, -1 if key == curses.KEY_UP else 1, + len(registry(ctx)) + (2 if ctx.scope == 'host' else 1)) elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE): if height >= 12 and width >= 45: scroll = page_offset(scroll, -1 if key == curses.KEY_PPAGE else 1, page, len(wrapped)) @@ -187,7 +248,30 @@ def put(y, text, attr=0): ctx = Context(ctx.scope, ctx.id, mounts[(mounts.index(ctx.mount) + 1) % len(mounts)]) elif key in (10, 13, curses.KEY_ENTER): actions = registry(ctx) - if selected == len(actions): + if community_mode: + if community_selected == len(community.TOOLS): + community_mode = False + selected = len(actions) + output = ['Zurück im Wartungsmenü.'] + status = 'Bereit' + else: + tool = community.TOOLS[community_selected] + pending_community = tool + output = ['Werkzeug: ' + tool['title'], tool['summary'], '', + 'Auswirkung: ' + tool['impact'], + 'Geprüfte Quelle: {} @ {}'.format(tool['path'], community.REVIEWED_COMMIT), + 'Der Live-Upstream muss weiterhin exakt diesem Stand entsprechen.', '', + 'Mit j starten; Esc bricht ab.'] + status = 'Community-Code ausführen?' + elif ctx.scope == 'host' and selected == len(actions): + community_mode = True + community_selected = 0 + tool = community.TOOLS[0] + output = [tool['summary'], '', 'Risiko / Auswirkung:', tool['impact'], '', + 'Jeder Start prüft den aktuellen Upstream und die Skript-Prüfsumme.', + 'Die optionale Upstream-Telemetrie wird nicht geladen.'] + status = 'Community Scripts: {} geprüfte Host-Werkzeuge'.format(len(community.TOOLS)) + elif selected == len(actions) + (1 if ctx.scope == 'host' else 0): start('sources') else: action = actions[selected] diff --git a/maintenance/sources.json b/maintenance/sources.json index 3a49b57..a3ca0c5 100644 --- a/maintenance/sources.json +++ b/maintenance/sources.json @@ -1,7 +1,7 @@ { "format": 1, "reviewed_at": "2026-10-27", - "notice": "Alle Adapter sind eigene Implementierungen mit nativen Proxmox-/Linux-Befehlen. Community Scripts dienen als fachliche Referenz. Kein Community-Code und keine Telemetrie werden ausgeführt.", + "notice": "Wartungsadapter sind eigenständig. Ausgewählte Community-Host-Tools können im Menü ausdrücklich gestartet werden; der aktuelle Upstream und der geprüfte Git-Blob werden unmittelbar davor geprüft. Der optionale Telemetrie-Baustein wird nicht geladen.", "adapters": { "storage": { "origin": "own", @@ -51,7 +51,7 @@ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", "blob_sha": "b63b4267c97ce3c62df4ad26c5e85131562f5950", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; only starts after live hash verification" }, { "repository": "community-scripts/ProxmoxVE", @@ -59,7 +59,7 @@ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", "blob_sha": "fa12746aedce484fe760bcccf4d1bcb3de58501b", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; only starts after live hash verification" }, { "repository": "community-scripts/ProxmoxVE", @@ -67,7 +67,7 @@ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", "blob_sha": "e54a307a980130c43134c68d28a8c7cfcdf6b260", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; only starts after live hash verification" }, { "repository": "community-scripts/ProxmoxVE", @@ -75,55 +75,47 @@ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", "blob_sha": "98351a487f14ed77df99f8945a9f45bf0b1870c0", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; only starts after live hash verification" }, { - "repository": "community-scripts/core", - "path": "api/api.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "c160e52ffc417187fc818e58572f1df23f3b7703", - "license": "MIT", - "role": "upstream reference; not executed" - }, - { - "repository": "community-scripts/core", - "path": "core/core.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "14c9ae0cc6ebce5a105b5c6e180f26ce1b2ed190", + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/clean-orphaned-lvm.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "18548a37a27a74a0bd7510b58efc0205618b2f64", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; interactive LVM deletion" }, { - "repository": "community-scripts/core", - "path": "api/exitcodes.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "ad83c87885f34339f6f6808852ede750cc91587e", + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/host-backup.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "4a579cd1cb14dbe0623608bd238ea92e3bc84b07", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; writes a user-selected archive" }, { - "repository": "community-scripts/core", - "path": "api/errorlog.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "c894d3be3a85fbbf5956ee5d2b40aa5f659847d3", + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/scaling-governor.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "79622844c5051349a05acdd46490a5242cfb656c", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; changes host CPU policy" }, { - "repository": "community-scripts/core", - "path": "api/sysinfo.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "9256905983d5a94722755959591345d1136bcc82", + "repository": "community-scripts/ProxmoxVE", + "path": "tools/pve/update-lxcs.sh", + "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3", + "blob_sha": "ec3dd34f2ff1b1df3c4ce7e664aab5958f9dfca5", "license": "MIT", - "role": "upstream reference; not executed" + "role": "verified launchable helper; updates selected containers" }, { "repository": "community-scripts/core", - "path": "api/telemetry.func", - "reviewed_commit": "e889c3a6a56201fd47d500954cc44b88fccebf94", - "blob_sha": "899149cabf680abe5d1dcd60fa6cd425ab71459c", + "path": "core/core.func", + "reviewed_commit": "af0fe772dd02bed10fd0124e3aacc86f2bc900d7", + "blob_sha": "0e18236d58a4aec4b200c231c8a4a2387e378bbb", "license": "MIT", - "role": "upstream reference; not executed" + "role": "pinned runtime dependency; optional telemetry API intentionally omitted" } ] } diff --git a/tests/maintenance/test_community_tools.py b/tests/maintenance/test_community_tools.py new file mode 100644 index 0000000..d9f8a06 --- /dev/null +++ b/tests/maintenance/test_community_tools.py @@ -0,0 +1,104 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import patch +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'maintenance')) + +from pman_maintenance import community +from pman_maintenance import ui +from pman_maintenance.actions import Context + + +class CommunityToolsTest(unittest.TestCase): + def test_catalog_is_curated_host_tool_allowlist(self): + ids = {tool['id'] for tool in community.TOOLS} + self.assertEqual(len(ids), 8) + self.assertIn('fstrim', ids) + self.assertIn('clean-orphaned-lvm', ids) + self.assertNotIn('execute', ids) + + def test_git_blob_digest_matches_git_format(self): + self.assertEqual(community.git_blob_sha(b'hello\n'), + 'ce013625030ba8dba906f756967f9e9ca394464a') + + def test_changed_upstream_blocks_before_download_or_execution(self): + expected = next(tool['sha'] for tool in community.TOOLS if tool['id'] == 'fstrim') + with patch.object(community, '_reference', return_value={'blob_sha': expected}), \ + patch.object(community.sources, 'check_reference', return_value={'status': 'Upstream geändert'}), \ + patch.object(community, '_download_pinned') as download, \ + patch.object(community.subprocess, 'run') as run: + with self.assertRaisesRegex(RuntimeError, 'Upstream'): + community.run_tool('fstrim') + download.assert_not_called() + run.assert_not_called() + + def test_unknown_helper_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'Unbekanntes'): + community.run_tool('arbitrary-shell-command') + + def test_runner_uses_verified_download_and_does_not_provide_telemetry_api(self): + with patch.object(community, '_check_current'), \ + patch.object(community, '_download_pinned', side_effect=[b'#!/bin/bash\n', b'# core\n']) as download, \ + patch.object(community.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run: + self.assertEqual(community.run_tool('fstrim'), 0) + self.assertEqual(download.call_count, 2) + args, kwargs = run.call_args + env = kwargs['env'] + self.assertTrue(env['COMMUNITY_SCRIPTS_CORE_URL'].startswith('file://')) + self.assertFalse((Path(env['COMMUNITY_SCRIPTS_CORE_URL'].replace('file://', '')) / 'api' / 'api.func').exists()) + self.assertNotIn('BASH_ENV', env) + self.assertNotIn('LD_PRELOAD', env) + self.assertEqual(args[0][0:1], ['bash']) + + def test_tui_opens_curated_community_submenu_without_running_a_tool(self): + class Screen: + def __init__(self): + self.keys = [ui.curses.KEY_DOWN] * 3 + [10, ord('q'), ord('q')] + self.rows = [] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, row, col, value, *args): self.rows.append(value) + def refresh(self): pass + def getch(self): return self.keys.pop(0) + + screen = Screen() + with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ + patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.locale, 'setlocale'), \ + patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'), \ + patch.object(community, 'run_tool') as run: + ui.run_tui(Context('host')) + self.assertTrue(any('COMMUNITY PVE-WERKZEUGE' in row for row in screen.rows)) + run.assert_not_called() + + def test_declining_helper_start_never_calls_runner(self): + class Screen: + def __init__(self): + self.keys = [ui.curses.KEY_DOWN] * 3 + [10, 10, ord('n'), ord('q'), ord('q')] + self.rows = [] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, row, col, value, *args): self.rows.append(value) + def refresh(self): pass + def getch(self): return self.keys.pop(0) + + screen = Screen() + with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ + patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.locale, 'setlocale'), \ + patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'), \ + patch.object(community, 'run_tool') as run: + ui.run_tui(Context('host')) + run.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/maintenance/test_installer.py b/tests/maintenance/test_installer.py index ac5f799..889b59f 100644 --- a/tests/maintenance/test_installer.py +++ b/tests/maintenance/test_installer.py @@ -66,7 +66,7 @@ def install(*args): package = stage / 'usr/local/lib/pman/maintenance' old = os.readlink(package/'current') version = source/'maintenance/pman_maintenance/__init__.py' - version.write_text(version.read_text().replace("VERSION = '1.0.1'", "VERSION = '1.0.2'")) + version.write_text(version.read_text().replace("VERSION = '1.0.2'", "VERSION = '1.0.3'")) install('--with-maintenance') new = os.readlink(package/'current') self.assertNotEqual(new, old) From 42b6e367219151d11be3f5a4cd01e0f67e48b130 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 05:03:09 +0100 Subject: [PATCH 4/4] feat: polish optional maintenance TUI --- CHANGELOG.md | 9 + README.de.md | 94 ++++++++++ README.md | 47 ++++- docs/maintenance.md | 19 +- maintenance/pman_maintenance/__init__.py | 2 +- maintenance/pman_maintenance/community.py | 11 +- maintenance/pman_maintenance/ui.py | 206 +++++++++++++++++----- tests/maintenance/test_community_tools.py | 103 ++++++++++- tests/maintenance/test_installer.py | 2 +- tests/maintenance/test_terminal_pty.py | 106 +++++++++++ tests/maintenance/test_ui.py | 182 ++++++++++++++++++- 11 files changed, 724 insertions(+), 57 deletions(-) create mode 100644 README.de.md create mode 100644 tests/maintenance/test_terminal_pty.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a354cd..9cb9938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [2.14.0] - 2026-10-27 +### Maintenance module 1.0.3 + +- Refine the optional maintenance TUI with responsive action/detail views, + clearer status and contextual keys, safe confirmation paging, and terminal + restoration coverage. +- Check community source metadata before confirmation; pinned hashes and a + second live check remain required immediately before launch. +- Fix community back-entry details and discard keys queued while a worker runs. + - Add an optional Python/curses storage and maintenance module with a small Bash dispatcher (`--maintenance`, main-menu `m`, and guest-menu `m`). - Add host storage/SMART diagnostics, scoped CT/VM adapters, confirmed TRIM and diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..bb71019 --- /dev/null +++ b/README.de.md @@ -0,0 +1,94 @@ +# Proxmox Manager + +**Sprache: [English](README.md) | Deutsch** + +Proxmox Manager verwaltet Proxmox-VMs und -Container mit einem eigenständigen +Bash-Skript. Die optionale Speicher- und Wartungsoberfläche verwendet Python 3 +und die Standardbibliothek; der Bash-Kern kann ohne sie installiert werden. Die +vollständige Befehls- und Konfigurationsreferenz steht im [englischen README](README.md). + +## Installation + +Auf einem Proxmox-Host als Administrator aus einem passenden Checkout: + +```bash +git clone https://github.com/TimInTech/proxmox-manager.git +cd proxmox-manager +./install_dependencies.sh +``` + +Der Installer fragt interaktiv nach dem optionalen Modul und installiert ohne +Terminal standardmäßig nur den Kern. Zur ausdrücklichen Installation des Moduls: + +```bash +./install_dependencies.sh --with-maintenance +pman --maintenance +``` + +Der Installer installiert keine zusätzlichen APT-Pakete. Für die Oberfläche ist +Python 3.9 oder neuer mit `curses` sowie ein UTF-8-Terminal erforderlich. + +## Wartungsoberfläche + +`M` oder `m` öffnet im Hauptmenü die Host-Wartung; im Aktionsmenü eines Gastes +öffnet dieselbe Taste die Wartung für genau den gewählten CT oder die VM. +Pfeiltasten wählen eine Aktion, Enter startet eine Diagnose oder öffnet Details. +Bild auf/ab blättert die Ausgabe, Esc führt zurück oder bricht eine Bestätigung +ab, und `q` beendet die +Ansicht. `Tab` wechselt beim CT den Scanpfad. Die Anzeige passt sich dem Terminal +an und berücksichtigt `NO_COLOR`. + +Die eigenen Adapter zeigen unter anderem Storage, Dateisysteme und SMART-Daten. +CT und VM bieten ausschließlich auf den gewählten Gast bezogene Diagnosen und +bestätigungspflichtiges TRIM; für Debian-/Ubuntu-CTs ist auch eine Bereinigung +des APT-Paketcache verfügbar. Gestoppte Gäste werden nicht automatisch gestartet. +Jede Änderung verlangt eine eigene Bestätigung; `--force` umgeht sie nicht. +Ein Abbruch laufender Änderungen ist kein Rollback. + +Das Host-Menü enthält außerdem ausgewählte **Community Scripts**. Sie laufen als +Upstream-Code mit eigenen interaktiven Dialogen und können Host-Einstellungen +oder dort ausgewählte Gäste ändern. Vor der Bestätigung prüft das Modul den +aktuellen Upstream; vor dem Start prüft es erneut und kontrolliert die Git-Blob-Prüfsummen der festgelegten +Revisionen. Geänderte, fehlende oder nicht erreichbare +Quellen verhindern den Start. Die erneute Prüfung und Downloads können bei +Zeitlimits im ungünstigsten Fall etwa eine Minute dauern; die Oberfläche ist +währenddessen blockiert. Die optionale Upstream-Telemetrie wird nicht +bereitgestellt. Auswirkungen vor der Bestätigung sorgfältig prüfen. + +Bekannte Grenzen: Ein UTF-8-Terminal wird benötigt. Ein laufender CT-Scan kann +bis zu 55 Sekunden weiterlaufen, auch wenn die Oberfläche den Abbruch angefordert +hat. Community-Helper zeigen eigene Dialoge und ihre erneute Live-Quellprüfung +kann während der Netzwerkanfrage kurzzeitig die Oberfläche blockieren. Ein +Abbruch ist kein Rollback. Details stehen in [docs/maintenance.md](docs/maintenance.md). + +## Deinstallation des Moduls + +```bash +./install_dependencies.sh --uninstall-maintenance +``` + +Der Bash-Kern bleibt installiert; private Diagnosezustände bleiben erhalten. Für +den Rückweg auf die vorherige Modulversion nutze im passenden Checkout: + +```bash +./install_dependencies.sh --rollback-maintenance +``` + +## Tests + +Auf einem Entwicklungsrechner ohne Proxmox-Host laufen die Mock- und TUI-Tests +mit: + +```bash +python3 -B -m unittest discover -s tests/maintenance +bash tests/run.sh +``` + +Die echten Proxmox-Aktionen brauchen einen Testhost. Siehe [Wartungsdokumentation](docs/maintenance.md). + +## Weitere Nutzung + +`pman` öffnet die VM-/CT-Tabelle, `pman --list` und `pman --json` geben den +Bestand aus. `pman --health` zeigt den Zustand und `pman --check` führt den +optionalen Alarm-Check aus. Konfiguration, Befehle und Beispiele erläutert das +[englische README](README.md). Lizenz: [MIT](LICENSE). diff --git a/README.md b/README.md index c5d0c1f..a06249d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +**Language: English | [Deutsch](README.de.md)** +
@@ -229,7 +231,7 @@ the checkout cannot change the privileged executable. | `pman --json` | Machine-readable JSON array for automation & `jq` | | `pman --filter running` | Filter output by status: `running` \| `stopped` \| `paused` | | `pman --name web` | Filter by VM/CT name (ERE substring-match; combinable with `--filter`) | -| `pman --force` | Skip all confirmation prompts (for unattended scripts) | +| `pman --force` | Skip supported core confirmation prompts; maintenance confirmations remain mandatory | | `pman --timeout 30` | Graceful shutdown timeout before force stop, in seconds (default: 60) | | `pman --no-clear` | Don't clear screen in interactive mode | | `pman --once` | Run a single interactive refresh cycle (useful for TTY recording) | @@ -255,6 +257,42 @@ Displays a table of all VMs/containers. Enter a VMID to open the action menu. Press `h` for the health overview · `r` to refresh · `q` to quit +### Optional maintenance interface + +Press `M` or `m` in the main menu for host maintenance, or in a guest action +menu for that selected CT or VM. Arrow keys select an action, Enter opens it, +Page Up/Down scrolls output, Esc goes back or cancels a confirmation, and `q` +exits. `Tab` switches the CT scan directory. The display adapts to terminal +size and honors `NO_COLOR`; a UTF-8 terminal is required. + +Native diagnostics use the selected scope; native guest changes require explicit +confirmation and never start stopped guests. `--force` cannot bypass confirmation. +The host-only Community Scripts menu launches selected upstream tools, which may +change host settings or guests chosen inside the helper. Before confirmation it +checks the live source; it repeats the check and verifies pinned Git blob hashes +before launch. Changed, missing or unreachable sources block execution. The +optional upstream telemetry component is omitted. Review the shown impact before +confirming. See [maintenance documentation](docs/maintenance.md) for limits, +source checks, rollback and removal. + +To remove only the optional module, run: + +```bash +./install_dependencies.sh --uninstall-maintenance +``` + +To switch back to the previous installed module release, run: + +```bash +./install_dependencies.sh --rollback-maintenance +``` + +Both operations retain the Bash core. The module may need a UTF-8 terminal. A +cancelled CT scan can continue in the guest until its 55-second timeout, and +community source checks may block the UI for up to about one minute while the +live checks and pinned source downloads wait for their network timeouts. +Cancelling a running change is not a rollback; see [maintenance documentation](docs/maintenance.md). + ### JSON output ```bash @@ -505,9 +543,10 @@ cp completions/pman.zsh ~/.zsh/completions/_pman - **Root required:** `qm` and `pct` need elevated privileges — there's no workaround. - **No credentials stored:** Relies entirely on Proxmox host authentication. -- **No outbound traffic unless notifications are configured:** alerts via ntfy / - e-mail are opt-in; the ntfy token is read from a `0600` file, passed to `curl` - via stdin and never sent over plain http. +- **Network access:** notifications are opt-in. The optional maintenance module + also checks public source metadata and fetches pinned community scripts on + request. The ntfy token is read from a `0600` file, passed to `curl` via stdin + and never sent over plain http. - **CI hardening:** ShellCheck on every push · Gitleaks scan for accidental secrets. diff --git a/docs/maintenance.md b/docs/maintenance.md index 58f38bd..aa7c7bc 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -63,13 +63,21 @@ change is not a rollback. A cancelled CT directory scan may continue inside the guest until its independent 55-second timeout; the outer command is also bounded. Changes are serialized by a private lock. There is no unattended mutation CLI. +The interface separates the action list from its detail or confirmation view. +At smaller widths the list is stacked; below the minimum size, actions are +blocked until the terminal is resized. While a job runs, Esc or `q` requests +cancellation and other input is ignored; after cancellation, `q` exits. + The host menu also has **Community PVE-Werkzeuge** with eight selected Host Management tools: Disk Health, LXC Filesystem Trim, LXC Cleaner, Kernel Clean, Clean Orphaned LVM, Host Backup, CPU Scaling Governor and LXC Updater. These are the upstream scripts, clearly marked as Community Scripts; their own interactive -prompts and target selection remain intact. Before launch, pman checks the live -GitHub metadata and downloads only the reviewed commit, then verifies the Git -blob SHA-1. A changed, missing or unreachable upstream source blocks launch. +prompts and target selection remain intact. Before confirmation, pman checks +the live GitHub metadata for the tool and its core library. Before launch it +checks both again, downloads only the reviewed commit, and verifies the Git +blob SHA-1. With network timeouts, those repeated checks and downloads can +block the interface for up to about one minute before the helper starts. A +changed, missing or unreachable upstream source blocks launch. The core runtime library is also pinned and checked. Its optional telemetry API is intentionally omitted. A helper can still install packages, delete selected logs/volumes, change host settings or update guests as described in its impact @@ -80,8 +88,9 @@ not a guarantee of newly available pool space. Guest filesystem use, thin-volume allocation and pool data/metadata use are displayed separately. Volume names are read from the actual guest configuration, not guessed from the VMID. -No broad log deletion, kernel removal, backup deletion or filesystem/partition -reconfiguration is provided in this first version. +The native adapters do not offer broad log deletion, kernel removal, backup +deletion or filesystem/partition reconfiguration. Some community helpers do +offer log or kernel removal and other changes, as described above. ## Community Scripts provenance and freshness diff --git a/maintenance/pman_maintenance/__init__.py b/maintenance/pman_maintenance/__init__.py index e4a0e6d..fa0ad8f 100644 --- a/maintenance/pman_maintenance/__init__.py +++ b/maintenance/pman_maintenance/__init__.py @@ -1,3 +1,3 @@ """Optional, local-only maintenance adapters for pman.""" -VERSION = '1.0.2' +VERSION = '1.0.3' INTERFACE = 1 diff --git a/maintenance/pman_maintenance/community.py b/maintenance/pman_maintenance/community.py index 2d15c68..c40e3ff 100644 --- a/maintenance/pman_maintenance/community.py +++ b/maintenance/pman_maintenance/community.py @@ -95,14 +95,19 @@ def _download_pinned(repository, commit, path, expected): return data -def run_tool(tool_id): +def preflight(tool_id): tool = next((item for item in TOOLS if item['id'] == tool_id), None) if not tool: raise ValueError('Unbekanntes Community-Werkzeug.') - # Check live upstream metadata immediately before fetching/running pinned bytes. _check_current(REPOSITORY, tool['path'], tool['sha']) + _check_current(CORE_REPOSITORY, 'core/core.func', CORE_SHA) + return tool + + +def run_tool(tool_id): + # Recheck immediately before fetching and executing pinned bytes. + tool = preflight(tool_id) core_path = 'core/core.func' - _check_current(CORE_REPOSITORY, core_path, CORE_SHA) script = _download_pinned(REPOSITORY, REVIEWED_COMMIT, tool['path'], tool['sha']) core = _download_pinned(CORE_REPOSITORY, CORE_COMMIT, core_path, CORE_SHA) with tempfile.TemporaryDirectory(prefix='pman-community-') as directory: diff --git a/maintenance/pman_maintenance/ui.py b/maintenance/pman_maintenance/ui.py index cf3f7d3..63ccd8f 100644 --- a/maintenance/pman_maintenance/ui.py +++ b/maintenance/pman_maintenance/ui.py @@ -29,6 +29,31 @@ def page_offset(offset, direction, page_size, line_count): return min(last, max(0, target)) +def terminal_usable(height, width): + return height >= 12 and width >= 45 + + +def wrap_output(lines, width): + return [piece for line in lines for piece in + (textwrap.wrap(clean_text(line).expandtabs(4), max(1, width), + replace_whitespace=False) or [''])] + + +def action_description(action): + descriptions = { + 'storage': 'Zeigt Speicher, Dateisysteme und Inodes des Hosts.', + 'smart': 'Liest SMART-Zustand und speichert einen lokalen Vergleichsstand.', + 'maintenance': 'Zeigt TRIM-Zeitplanung, Kernel und Backup-Speicher.', + 'volumes': 'Zeigt die konfigurierten Volumes des ausgewählten Gastes.', + 'filesystems': 'Zeigt die Dateisystembelegung des ausgewählten Gastes.', + 'scan': 'Untersucht große Verzeichnisse im ausgewählten CT-Pfad.', + 'trim': 'Meldet freie Blöcke des ausgewählten Gastes an den Speicher.', + 'cache-clean': 'Entfernt heruntergeladene APT-Paketarchive im ausgewählten CT.', + } + return '{}: {}'.format('Änderung mit Bestätigung' if action.mutation else 'Diagnose', + descriptions.get(action.id, action.title)) + + def mutate(key, ctx, runner): private_dir(STATE) flags = os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW @@ -72,21 +97,23 @@ def _screen(screen, ctx): color = curses.color_pair(1) selected = 0 scroll = 0 - output = ['Diagnosen ändern keine Gastdaten. Änderungen erfordern eine eigene Bestätigung.', - 'Community PVE-Werkzeuge sind separat im Host-Menü verfügbar; Start nur nach Bestätigung.', - 'CT-Scanpfad mit Tab umschalten: /var, /home, /opt, /srv.'] + output = [action_description(registry(ctx)[0]), '', 'Enter startet die Diagnose.'] pending = None pending_community = None + flush_confirmation = False community_mode = False + detail_mode = False community_selected = 0 busy = False + busy_kind = None cancel = threading.Event() messages = queue.Queue() worker = None status = 'Bereit' def start(kind, action=None): - nonlocal busy, status, worker + nonlocal busy, busy_kind, status, worker + busy_kind = kind cancel.clear() busy = True status = 'Prüfung läuft – Esc bricht ab' @@ -97,6 +124,8 @@ def job(): value = prepare(action.id, ctx, runner) elif kind == 'sources': value = source_report() + elif kind == 'community-check': + value = community.preflight(action['id']) elif kind == 'mutate': value = mutate(action.id, ctx, runner) else: @@ -113,7 +142,25 @@ def job(): kind, value, action = messages.get_nowait() busy = False scroll = 0 - if kind == 'prepare': + flush_confirmation = True + if cancel.is_set(): + output = (['Abbruch angefordert. Die Änderung kann teilweise oder vollständig', + 'ausgeführt worden sein; dies ist kein Rollback. Zustand prüfen.'] + if busy_kind == 'mutate' else + ['Abgebrochen; keine bestätigte Änderung wurde gestartet.']) + detail_mode = True + status = 'Abgebrochen' + elif kind == 'community-check': + pending_community = value + output = ['COMMUNITY SCRIPT – Bestätigung', 'Ziel: HOST; Gäste werden im Helper ausgewählt.', + 'Werkzeug: ' + value['title'], 'Auswirkung: ' + value['impact'], + 'Quelle: ' + community.REPOSITORY + '/' + value['path'], + 'Geprüfte Revision: ' + community.REVIEWED_COMMIT, + 'Prüfergebnis: Live-Upstream und Core stimmen überein.', + 'Vor Ausführung werden Quelle und Dateiprüfsummen erneut geprüft.', + 'j = starten | Esc = abbrechen'] + status = 'Wartet auf Bestätigung' + elif kind == 'prepare': pending = action output = ['Ziel: ' + value['target'], 'Befehl: ' + value['display'], value['impact'], '', 'Änderung ausführen? j = bestätigen, Esc = abbrechen', @@ -121,20 +168,21 @@ def job(): status = 'Bestätigung erforderlich' else: output = clean_text(value).expandtabs(4).splitlines() or ['Keine Ausgabe.'] - status = 'Fehler / nicht verfügbar' if kind == 'error' else 'Abgeschlossen' + detail_mode = True + status = ('Abgebrochen' if cancel.is_set() else 'Fehlgeschlagen') if kind == 'error' else 'Erfolgreich' except queue.Empty: pass screen.erase() height, width = screen.getmaxyx() - def put(y, text, attr=0): - if 0 <= y < height and width > 1: + def put(y, text, attr=0, x=0, limit=None): + if 0 <= y < height and width > x + 1: try: - screen.addnstr(y, 0, clean_text(text), width - 1, attr) + screen.addnstr(y, x, clean_text(text), min(width - x - 1, limit or width), attr) except curses.error: pass if height < 12 or width < 45: put(0, 'Terminal mindestens 45 Spalten und 12 Zeilen.') - put(1, 'Vergrößern oder q zum Beenden.') + put(1, 'Esc/q: Abbruch; danach q beendet.' if busy else 'Vergrößern oder q zum Beenden.') else: actions = registry(ctx) if community_mode: @@ -146,41 +194,93 @@ def put(y, text, attr=0): menu.append('Community PVE-Werkzeuge ({})'.format(len(community.TOOLS))) menu.append('Herkunft und Aktualitätsstatus') active_selected = selected - menu_height = min(len(menu), max(3, height // 3)) - first = max(0, active_selected - menu_height + 1) - title = 'pman Wartung | COMMUNITY PVE-WERKZEUGE' if community_mode else 'pman Wartung | {} {} | Scan: {}'.format(ctx.scope.upper(), ctx.id or '', ctx.mount) + title = 'pman Wartung | {} {} | {}'.format( + ctx.scope.upper(), ctx.id or '', + 'COMMUNITY PVE-WERKZEUGE' if community_mode else + ('Wartung | Scan: ' + ctx.mount if ctx.scope == 'ct' else 'Wartung')) put(0, title, color | curses.A_BOLD) - for row, index in enumerate(range(first, min(len(menu), first + menu_height)), 2): - put(row, ('> ' if index == active_selected else ' ') + menu[index], - curses.A_REVERSE if index == active_selected else 0) - start_y = menu_height + 3 - put(start_y, '─' * (width - 1), color) - page = max(1, height - start_y - 4) - wrapped = [piece for line in output for piece in (textwrap.wrap(clean_text(line), max(1, width - 2), replace_whitespace=False) or [''])] + modal = detail_mode or pending or pending_community or busy + side_by_side = width >= 76 and not modal + if modal: + selected_title = (community.TOOLS[community_selected]['title'] + if community_mode and community_selected < len(community.TOOLS) + else menu[active_selected]) + put(1, 'Auswahl: ' + selected_title, curses.A_BOLD) + start_y, detail_x, detail_width = 3, 0, width - 2 + elif side_by_side: + menu_width = min(34, width // 2) + menu_height = max(1, height - 5) + first = max(0, active_selected - menu_height + 1) + for row, index in enumerate(range(first, min(len(menu), first + menu_height)), 2): + put(row, ('> ' if index == active_selected else ' ') + menu[index], + curses.A_REVERSE if index == active_selected else 0, limit=menu_width - 1) + detail_x, detail_width, start_y = menu_width + 1, width - menu_width - 3, 2 + put(1, 'Details', color | curses.A_BOLD, x=detail_x) + else: + menu_height = min(len(menu), max(2, height // 4)) + first = max(0, active_selected - menu_height + 1) + for row, index in enumerate(range(first, min(len(menu), first + menu_height)), 2): + put(row, ('> ' if index == active_selected else ' ') + menu[index], + curses.A_REVERSE if index == active_selected else 0) + start_y, detail_x, detail_width = menu_height + 3, 0, width - 2 + page = max(1, height - start_y - 3) + wrapped = wrap_output(output, detail_width) scroll = min(scroll, max(0, len(wrapped) - page)) - for row, line in enumerate(wrapped[scroll:scroll + page], start_y + 1): - put(row, line) + for row, line in enumerate(wrapped[scroll:scroll + page], start_y): + put(row, line, x=detail_x, limit=detail_width) first_line = scroll + 1 if wrapped else 0 last_line = min(len(wrapped), scroll + page) - put(height - 3, 'Ausgabe Zeilen {}–{} von {}'.format(first_line, last_line, len(wrapped)), color) - help_text = '↑↓ Menü | Enter öffnen | Bild↑↓ blättern | Tab Pfad | Esc zurück | q Ende' - if community_mode: - help_text = '↑↓ Werkzeug | Enter Details/Start | Esc zurück | q Ende' - if width < 74: - help_text = '↑↓ Menü | Enter | PgUp/PgDn | Esc | q Ende' - if community_mode: - help_text = '↑↓ Werkzeug | Enter | Esc zurück | q Ende' + put(height - 3, 'Zeilen {}–{} von {}'.format(first_line, last_line, len(wrapped)), color) + if busy: + help_text = 'Esc/q Abbruch | danach q Ende' + elif pending or pending_community: + help_text = ('PgDn: alle Details lesen | Esc/n Abbruch' if scroll + page < len(wrapped) + else 'j Bestätigen | Esc/n Abbruch | PgUp/PgDn') + elif detail_mode: + help_text = 'PgUp/PgDn Blättern | Esc Zurück | q Ende' + else: + help_text = '↑↓ Auswahl | Enter | PgUp/PgDn | Esc | q Ende' + if ctx.scope == 'ct': + help_text = '↑↓ Auswahl | Enter | Tab Pfad | Esc | q Ende' put(height - 2, help_text, color) put(height - 1, status, color) screen.refresh() + if flush_confirmation: + flush_confirmation = False + try: + curses.flushinp() + except curses.error: + confirming = bool(pending or pending_community) + pending = None + pending_community = None + detail_mode = True + if confirming: + output = ['Eingabepuffer konnte nicht geleert werden; Bestätigung abgebrochen.'] + else: + output += ['', 'Eingabepuffer konnte nicht geleert werden. Ergebnis oben beachten.'] + status = 'Eingabefehler' + continue key = screen.getch() if key == -1: continue + if not terminal_usable(height, width): + if busy and key in (27, ord('q')): + cancel.set() + status = 'Abbruch angefordert.' + elif key == ord('q'): + return + continue if busy: if key in (27, ord('q')): cancel.set() status = 'Abbruch angefordert; CT-Scan kann bis zum Gast-Zeitlimit (55 s) weiterlaufen.' continue + if (pending or pending_community) and key in (curses.KEY_PPAGE, curses.KEY_NPAGE): + scroll = page_offset(scroll, -1 if key == curses.KEY_PPAGE else 1, page, len(wrapped)) + continue + if (pending or pending_community) and key in (ord('j'), ord('J')) and scroll + page < len(wrapped): + status = 'Bitte zuerst alle Details mit Bild ab lesen.' + continue if pending_community: if key in (ord('j'), ord('J')): tool = pending_community @@ -191,10 +291,12 @@ def put(y, text, attr=0): try: curses.endwin() exit_code = community.run_tool(tool['id']) + detail_mode = True output = ['Community Script beendet.', 'Exit-Code: {}'.format(exit_code), 'Quelle: community-scripts/ProxmoxVE @ {}'.format(community.REVIEWED_COMMIT[:12])] status = 'Abgeschlossen' if exit_code == 0 else 'Helper Script mit Fehler beendet' except Exception as exc: + detail_mode = True output = ['Start abgebrochen:', clean_text(exc)] status = 'Quelle geändert, nicht verfügbar oder Start fehlgeschlagen' finally: @@ -208,7 +310,9 @@ def put(y, text, attr=0): if key in (27, ord('n'), ord('N'), ord('q')): pending_community = None output = ['Start abgebrochen; es wurde kein Helper Script ausgeführt.'] - status = 'Bereit' + status = 'Abgebrochen' + if key == ord('q'): + return continue if pending: if key in (ord('j'), ord('J')): @@ -218,9 +322,25 @@ def put(y, text, attr=0): elif key in (27, ord('n'), ord('N'), ord('q')): pending = None output = ['Änderung abgebrochen.'] - status = 'Bereit' + status = 'Abgebrochen' + if key == ord('q'): + return continue - if key in (ord('q'), 27): + if key == ord('q'): + return + if key == 27: + if detail_mode: + detail_mode = False + scroll = 0 + if community_mode and community_selected < len(community.TOOLS): + tool = community.TOOLS[community_selected] + output = [tool['summary'], 'Risiko / Auswirkung: ' + tool['impact']] + elif selected < len(registry(ctx)): + output = [action_description(registry(ctx)[selected])] + else: + output = ['Enter öffnet diese Ansicht.'] + status = 'Bereit' + continue if community_mode: community_mode = False selected = len(registry(ctx)) @@ -228,8 +348,11 @@ def put(y, text, attr=0): status = 'Bereit' continue return + if detail_mode and key not in (curses.KEY_PPAGE, curses.KEY_NPAGE): + continue if key in (curses.KEY_UP, curses.KEY_DOWN): if community_mode: + scroll = 0 community_selected = navigate(community_selected, -1 if key == curses.KEY_UP else 1, len(community.TOOLS) + 1) if community_selected < len(community.TOOLS): @@ -237,9 +360,18 @@ def put(y, text, attr=0): output = [tool['summary'], '', 'Risiko / Auswirkung:', tool['impact'], '', 'Quelle: {} @ {}'.format(tool['path'], community.REVIEWED_COMMIT[:12]), 'Lizenz: MIT; Helper wird als Community-Code ausgeführt.'] + else: + output = ['Community-Menü schließen und zur Wartungsansicht zurückkehren.'] else: selected = navigate(selected, -1 if key == curses.KEY_UP else 1, len(registry(ctx)) + (2 if ctx.scope == 'host' else 1)) + scroll = 0 + if selected < len(registry(ctx)): + action = registry(ctx)[selected] + output = [action_description(action), '', 'Quelle: ' + action.source] + else: + output = ['Community-Werkzeuge' if ctx.scope == 'host' and selected == len(registry(ctx)) else 'Herkunft und Aktualitätsstatus', + 'Enter öffnet diese Ansicht.'] elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE): if height >= 12 and width >= 45: scroll = page_offset(scroll, -1 if key == curses.KEY_PPAGE else 1, page, len(wrapped)) @@ -256,13 +388,7 @@ def put(y, text, attr=0): status = 'Bereit' else: tool = community.TOOLS[community_selected] - pending_community = tool - output = ['Werkzeug: ' + tool['title'], tool['summary'], '', - 'Auswirkung: ' + tool['impact'], - 'Geprüfte Quelle: {} @ {}'.format(tool['path'], community.REVIEWED_COMMIT), - 'Der Live-Upstream muss weiterhin exakt diesem Stand entsprechen.', '', - 'Mit j starten; Esc bricht ab.'] - status = 'Community-Code ausführen?' + start('community-check', tool) elif ctx.scope == 'host' and selected == len(actions): community_mode = True community_selected = 0 diff --git a/tests/maintenance/test_community_tools.py b/tests/maintenance/test_community_tools.py index d9f8a06..23f54b4 100644 --- a/tests/maintenance/test_community_tools.py +++ b/tests/maintenance/test_community_tools.py @@ -1,4 +1,5 @@ import sys +import time import unittest from pathlib import Path from unittest.mock import patch @@ -63,11 +64,14 @@ def erase(self): pass def getmaxyx(self): return (24, 80) def addnstr(self, row, col, value, *args): self.rows.append(value) def refresh(self): pass - def getch(self): return self.keys.pop(0) + def getch(self): + time.sleep(0.01) + return self.keys.pop(0) screen = Screen() with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ patch.object(ui.curses, 'has_colors', return_value=False), \ patch.object(ui.locale, 'setlocale'), \ patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'), \ @@ -76,6 +80,30 @@ def getch(self): return self.keys.pop(0) self.assertTrue(any('COMMUNITY PVE-WERKZEUGE' in row for row in screen.rows)) run.assert_not_called() + def test_back_entry_updates_community_detail(self): + class Screen: + def __init__(self): + self.keys = [ui.curses.KEY_DOWN] * 3 + [10] + [ui.curses.KEY_DOWN] * len(community.TOOLS) + [ord('q')] + self.rows = [] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, row, col, value, *args): self.rows.append(value) + def refresh(self): pass + def getch(self): + time.sleep(.01) + return self.keys.pop(0) + screen = Screen() + with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ + patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.locale, 'setlocale'), \ + patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'): + ui.run_tui(Context('host')) + self.assertTrue(any('Community-Menü schließen' in row for row in screen.rows)) + def test_declining_helper_start_never_calls_runner(self): class Screen: def __init__(self): @@ -87,18 +115,89 @@ def erase(self): pass def getmaxyx(self): return (24, 80) def addnstr(self, row, col, value, *args): self.rows.append(value) def refresh(self): pass - def getch(self): return self.keys.pop(0) + def getch(self): + time.sleep(0.01) + return self.keys.pop(0) screen = Screen() with patch.object(ui.curses, 'wrapper', side_effect=lambda fn, ctx: fn(screen, ctx)), \ patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ patch.object(ui.curses, 'has_colors', return_value=False), \ patch.object(ui.locale, 'setlocale'), \ patch.object(ui.locale, 'getpreferredencoding', return_value='UTF-8'), \ + patch.object(community, 'preflight', side_effect=lambda tool_id: next(t for t in community.TOOLS if t['id'] == tool_id)), \ patch.object(community, 'run_tool') as run: ui.run_tui(Context('host')) run.assert_not_called() + +class CommunityPreflightTest(unittest.TestCase): + def test_preflight_checks_tool_and_core_without_download(self): + with patch.object(community, '_check_current') as check, \ + patch.object(community, '_download_pinned') as download: + community.preflight('fstrim') + self.assertEqual(check.call_count, 2) + download.assert_not_called() + + def test_checksum_mismatch_blocks_execution(self): + with patch.object(community, '_check_current'), \ + patch.object(community, '_fetch', return_value=b'changed'), \ + patch.object(community.subprocess, 'run') as run: + with self.assertRaisesRegex(RuntimeError, 'Prüfsumme'): + community.run_tool('fstrim') + run.assert_not_called() + +class CommunityFailureTest(unittest.TestCase): + def test_missing_or_unreachable_source_blocks_start(self): + for state in ('Quelle fehlt', 'Prüfung fehlgeschlagen'): + with self.subTest(state=state), \ + patch.object(community, '_reference', return_value={'blob_sha': next(t['sha'] for t in community.TOOLS if t['id'] == 'fstrim')}), \ + patch.object(community.sources, 'check_reference', return_value={'status': state}), \ + patch.object(community, '_download_pinned') as download, \ + patch.object(community.subprocess, 'run') as run: + with self.assertRaises(RuntimeError): + community.run_tool('fstrim') + download.assert_not_called() + run.assert_not_called() + + def test_core_checksum_mismatch_blocks_execution(self): + expected = next(t['sha'] for t in community.TOOLS if t['id'] == 'fstrim') + with patch.object(community, '_check_current'), \ + patch.object(community, '_fetch', side_effect=[b'tool', b'bad core']), \ + patch.object(community, 'git_blob_sha', side_effect=[expected, 'wrong']), \ + patch.object(community.subprocess, 'run') as run: + with self.assertRaisesRegex(RuntimeError, 'Prüfsumme'): + community.run_tool('fstrim') + run.assert_not_called() + +class CommunityTerminalTest(unittest.TestCase): + def test_helper_exception_restores_curses_state(self): + class Screen: + def __init__(self): + self.keys = [ui.curses.KEY_DOWN] * 3 + [10, 10, ord('j'), ord('q')] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): + time.sleep(.02) + return self.keys.pop(0) + tool = community.TOOLS[0] + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'endwin') as endwin, \ + patch.object(ui.curses, 'reset_prog_mode') as reset, \ + patch.object(community, 'preflight', return_value=tool), \ + patch.object(community, 'run_tool', side_effect=RuntimeError('failure')): + ui._screen(Screen(), Context('host')) + endwin.assert_called_once() + reset.assert_called_once() + + if __name__ == '__main__': unittest.main() diff --git a/tests/maintenance/test_installer.py b/tests/maintenance/test_installer.py index 889b59f..5948a64 100644 --- a/tests/maintenance/test_installer.py +++ b/tests/maintenance/test_installer.py @@ -66,7 +66,7 @@ def install(*args): package = stage / 'usr/local/lib/pman/maintenance' old = os.readlink(package/'current') version = source/'maintenance/pman_maintenance/__init__.py' - version.write_text(version.read_text().replace("VERSION = '1.0.2'", "VERSION = '1.0.3'")) + version.write_text(version.read_text().replace("VERSION = '1.0.3'", "VERSION = '1.0.4'")) install('--with-maintenance') new = os.readlink(package/'current') self.assertNotEqual(new, old) diff --git a/tests/maintenance/test_terminal_pty.py b/tests/maintenance/test_terminal_pty.py new file mode 100644 index 0000000..5c00cff --- /dev/null +++ b/tests/maintenance/test_terminal_pty.py @@ -0,0 +1,106 @@ +import fcntl +import os +import pty +import select +import struct +import subprocess +import sys +import termios +import time +import unittest +from pathlib import Path + + +class TerminalPTYTest(unittest.TestCase): + def test_normal_exit_restores_terminal_mode_and_cursor(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + original = termios.tcgetattr(slave) + module_dir = Path(__file__).resolve().parents[2] / 'maintenance' + code = ('import sys; sys.path.insert(0, {!r}); ' + 'from pman_maintenance.ui import run_tui; ' + 'from pman_maintenance.actions import Context; ' + 'run_tui(Context("host"))').format(str(module_dir)) + env = dict(os.environ, TERM='xterm-256color', NO_COLOR='1') + process = subprocess.Popen([sys.executable, '-c', code], stdin=slave, + stdout=slave, stderr=slave, env=env) + output = bytearray() + try: + deadline = time.monotonic() + 5 + while b'pman Wartung' not in output and time.monotonic() < deadline: + if select.select([master], [], [], .1)[0]: + output.extend(os.read(master, 65536)) + self.assertIn(b'pman Wartung', output) + os.write(master, b'q') + self.assertEqual(process.wait(timeout=5), 0) + while select.select([master], [], [], 0)[0]: + try: + output.extend(os.read(master, 65536)) + except OSError: + break + self.assertEqual(termios.tcgetattr(slave), original) + self.assertIn(b'\x1b[?25h', output) + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + os.close(slave) + + def test_exception_restores_terminal_mode(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + original = termios.tcgetattr(slave) + module_dir = Path(__file__).resolve().parents[2] / 'maintenance' + code = ("import sys; sys.path.insert(0, {!r}); " + "from pman_maintenance import ui; " + "from pman_maintenance.actions import Context; " + "exec('def fail(screen, ctx):\\n raise RuntimeError(\"test failure\")'); " + "ui._screen=fail; " + "exec('try:\\n ui.run_tui(Context(\"host\"))\\nexcept RuntimeError:\\n pass')").format(str(module_dir)) + process = subprocess.Popen([sys.executable, '-c', code], stdin=slave, stdout=slave, + stderr=slave, env=dict(os.environ, TERM='xterm-256color')) + try: + self.assertEqual(process.wait(timeout=5), 0) + self.assertEqual(termios.tcgetattr(slave), original) + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + os.close(slave) + + def test_cancelled_diagnostic_restores_terminal(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + original = termios.tcgetattr(slave) + module_dir = Path(__file__).resolve().parents[2] / 'maintenance' + code = ("import sys; sys.path.insert(0, {!r}); " + "from pman_maintenance import ui; " + "from pman_maintenance.actions import Context; " + "exec('def slow(action, ctx, runner):\\n runner.cancel.wait(5)\\n raise RuntimeError(\"cancelled\")'); " + "ui.execute=slow; ui.run_tui(Context('host'))").format(str(module_dir)) + process = subprocess.Popen([sys.executable, '-c', code], stdin=slave, stdout=slave, + stderr=slave, env=dict(os.environ, TERM='xterm-256color', NO_COLOR='1')) + output = bytearray() + def wait_for(fragment): + deadline = time.monotonic() + 7 + while fragment not in output and time.monotonic() < deadline: + if select.select([master], [], [], .1)[0]: + output.extend(os.read(master, 65536)) + self.assertIn(fragment, output) + try: + wait_for(b'pman Wartung') + os.write(master, b'\n') + wait_for('Prüfung läuft'.encode()) + os.write(master, b'\x1b') + wait_for(b'Abgebrochen') + os.write(master, b'q') + self.assertEqual(process.wait(timeout=5), 0) + self.assertEqual(termios.tcgetattr(slave), original) + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + os.close(slave) diff --git a/tests/maintenance/test_ui.py b/tests/maintenance/test_ui.py index cc88e40..f76ebdc 100644 --- a/tests/maintenance/test_ui.py +++ b/tests/maintenance/test_ui.py @@ -11,4 +11,184 @@ def test_navigation_wraps(self): self.assertEqual(navigate(0, -1, 4), 3) self.assertEqual(navigate(3, 1, 4), 0) -if __name__ == '__main__': unittest.main() + +class LayoutTest(unittest.TestCase): + def test_selection_details_and_small_terminal_guard(self): + from pman_maintenance.ui import action_description, terminal_usable + from pman_maintenance.actions import Context, registry + action = registry(Context('ct', 101))[0] + self.assertIn('Diagnose', action_description(action)) + self.assertFalse(terminal_usable(8, 30)) + self.assertTrue(terminal_usable(24, 80)) + + def test_wrapping_removes_control_sequences_and_bounds_lines(self): + from pman_maintenance.ui import wrap_output + lines = wrap_output(['\x1b[2J' + 'x' * 50], 12) + self.assertTrue(all(len(line) <= 12 for line in lines)) + self.assertFalse(any('\x1b' in line for line in lines)) + +class SmallTerminalTest(unittest.TestCase): + def test_enter_cannot_launch_hidden_action(self): + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): self.keys = [10, ord('q')] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (8, 30) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): return self.keys.pop(0) + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui, 'execute') as execute: + ui._screen(Screen(), Context('host')) + execute.assert_not_called() + +class ConfirmationTest(unittest.TestCase): + def test_hidden_confirmation_details_cannot_be_accepted(self): + import time + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): + self.keys = [ui.curses.KEY_DOWN] * 3 + [10, ord('j'), ord('n'), ord('q')] + self.rows = [] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (12, 45) + def addnstr(self, row, col, value, *args): self.rows.append(value) + def refresh(self): pass + def getch(self): + time.sleep(.02) + return self.keys.pop(0) + screen = Screen() + plan = {'target': 'CT 101', 'display': 'pct fstrim 101', + 'impact': 'Impact: ' + ('long details ' * 30)} + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui, 'prepare', return_value=plan), \ + patch.object(ui, 'mutate') as mutation: + ui._screen(screen, Context('ct', 101)) + mutation.assert_not_called() + self.assertTrue(any('alle Details' in row for row in screen.rows)) + + def test_confirmation_after_scrolling_starts_only_selected_action(self): + import time + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): + self.keys = ([ui.curses.KEY_DOWN] * 3 + [10] + + [ui.curses.KEY_NPAGE] * 20 + [ord('j'), ord('q')]) + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (12, 45) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): + time.sleep(.02) + return self.keys.pop(0) + plan = {'target': 'CT 101', 'display': 'pct fstrim 101', + 'impact': 'Impact: ' + ('long details ' * 30)} + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui, 'prepare', return_value=plan), \ + patch.object(ui, 'mutate', return_value='done') as mutation: + ui._screen(Screen(), Context('ct', 101)) + self.assertEqual(mutation.call_count, 1) + self.assertEqual(mutation.call_args.args[:2], ('trim', Context('ct', 101))) + + def test_buffered_confirmation_key_is_flushed_before_view(self): + import time + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): self.keys = [ui.curses.KEY_DOWN] * 3 + [10, ord('j'), ord('n'), ord('q')] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): + time.sleep(.02) + return self.keys.pop(0) + screen = Screen() + plan = {'target': 'CT 101', 'display': 'pct fstrim 101', 'impact': 'TRIM'} + def flush(): + if screen.keys and screen.keys[0] == ord('j'): + screen.keys.pop(0) + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.curses, 'flushinp', side_effect=flush) as flush_input, \ + patch.object(ui, 'prepare', return_value=plan), \ + patch.object(ui, 'mutate') as mutation: + ui._screen(screen, Context('ct', 101)) + flush_input.assert_called_once() + mutation.assert_not_called() + + def test_flush_failure_rejects_confirmation(self): + import time + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): self.keys = [ui.curses.KEY_DOWN] * 3 + [10, ord('j'), ord('q')] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): return (24, 80) + def addnstr(self, *args): pass + def refresh(self): pass + def getch(self): + time.sleep(.02) + return self.keys.pop(0) + plan = {'target': 'CT 101', 'display': 'pct fstrim 101', 'impact': 'TRIM'} + with patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'has_colors', return_value=False), \ + patch.object(ui.curses, 'flushinp', side_effect=ui.curses.error), \ + patch.object(ui, 'prepare', return_value=plan), \ + patch.object(ui, 'mutate') as mutation: + ui._screen(Screen(), Context('ct', 101)) + mutation.assert_not_called() + +class ResizeAndColorTest(unittest.TestCase): + def test_resize_and_no_color_leave_small_view_and_do_not_initialize_colors(self): + from unittest.mock import patch + from pman_maintenance import ui + from pman_maintenance.actions import Context + class Screen: + def __init__(self): self.keys = [ui.curses.KEY_RESIZE, ord('q')]; self.count = 0; self.rows = [] + def keypad(self, enabled): pass + def timeout(self, value): pass + def erase(self): pass + def getmaxyx(self): + self.count += 1 + return (8, 30) if self.count == 1 else (24, 80) + def addnstr(self, row, col, value, *args): self.rows.append(value) + def refresh(self): pass + def getch(self): return self.keys.pop(0) + screen = Screen() + with patch.dict(ui.os.environ, {'NO_COLOR': '1'}), \ + patch.object(ui.curses, 'curs_set'), \ + patch.object(ui.curses, 'flushinp'), \ + patch.object(ui.curses, 'has_colors', return_value=True), \ + patch.object(ui.curses, 'start_color') as color: + ui._screen(screen, Context('host')) + color.assert_not_called() + self.assertTrue(any('Terminal mindestens' in row for row in screen.rows)) + self.assertTrue(any('pman Wartung' in row for row in screen.rows)) + +if __name__ == '__main__': + unittest.main()