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..9cb9938 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,27 @@ 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
+ 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.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 d86b4c7..a06249d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+**Language: English | [Deutsch](README.de.md)**
+
@@ -8,16 +10,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.14.0:** optional storage and maintenance TUI (Python standard library),
+installed separately with `--with-maintenance`. See [maintenance module](docs/maintenance.md).
-**New in v2.13.2:** ⚡ **Stop** now shuts guests down cleanly (`qm|pct shutdown`,
+**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 +177,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 +208,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.
@@ -215,10 +231,11 @@ 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) |
+| `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 |
@@ -240,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
@@ -490,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.
@@ -500,7 +554,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..aa7c7bc
--- /dev/null
+++ b/docs/maintenance.md
@@ -0,0 +1,158 @@
+# 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 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`.
+
+## 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.
+
+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
+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 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
+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
+are read from the actual guest configuration, not guessed from the VMID.
+
+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
+
+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 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".
+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..fa0ad8f
--- /dev/null
+++ b/maintenance/pman_maintenance/__init__.py
@@ -0,0 +1,3 @@
+"""Optional, local-only maintenance adapters for pman."""
+VERSION = '1.0.3'
+INTERFACE = 1
diff --git a/maintenance/pman_maintenance/actions.py b/maintenance/pman_maintenance/actions.py
new file mode 100644
index 0000000..268cda7
--- /dev/null
+++ b/maintenance/pman_maintenance/actions.py
@@ -0,0 +1,326 @@
+"""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 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
+ 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
+ attributes = {}
+ for row in data.get('ata_smart_attributes', {}).get('table', []):
+ 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-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)
+
+
+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/community.py b/maintenance/pman_maintenance/community.py
new file mode 100644
index 0000000..c40e3ff
--- /dev/null
+++ b/maintenance/pman_maintenance/community.py
@@ -0,0 +1,131 @@
+"""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 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_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'
+ 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/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..cc9b1df
--- /dev/null
+++ b/maintenance/pman_maintenance/sources.py
@@ -0,0 +1,103 @@
+"""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
+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', [])}
+ 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; geprüfte Community-Tools nur auf Auswahl und mit Versionsprüfung.',
+ '', 'Referenzen:']
+ for reference in manifest['references']:
+ row = checked.get((reference['repository'], reference['path']), {})
+ 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'])
+ 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..63ccd8f
--- /dev/null
+++ b/maintenance/pman_maintenance/ui.py
@@ -0,0 +1,408 @@
+"""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
+from . import community
+
+
+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 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 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
+ 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
+ finally:
+ # curses restores terminal mode but leaves the cursor at the last status row.
+ print()
+
+
+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 = [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, busy_kind, status, worker
+ busy_kind = kind
+ 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 == 'community-check':
+ value = community.preflight(action['id'])
+ 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
+ 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',
+ 'Abbruch eines laufenden Befehls ist kein Rollback.']
+ status = 'Bestätigung erforderlich'
+ else:
+ output = clean_text(value).expandtabs(4).splitlines() or ['Keine Ausgabe.']
+ 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, x=0, limit=None):
+ if 0 <= y < height and width > x + 1:
+ try:
+ 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, 'Esc/q: Abbruch; danach q beendet.' if busy else 'Vergrößern oder q zum Beenden.')
+ else:
+ actions = registry(ctx)
+ 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
+ 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)
+ 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):
+ 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, '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
+ 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'])
+ 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:
+ 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 = 'Abgebrochen'
+ if key == ord('q'):
+ return
+ 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 = 'Abgebrochen'
+ if key == ord('q'):
+ return
+ continue
+ 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))
+ output = ['Community-Werkzeugmenü geschlossen.']
+ 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):
+ 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:
+ 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))
+ 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 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]
+ start('community-check', tool)
+ 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]
+ 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..a3ca0c5
--- /dev/null
+++ b/maintenance/sources.json
@@ -0,0 +1,121 @@
+{
+ "format": 1,
+ "reviewed_at": "2026-10-27",
+ "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",
+ "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": "verified launchable helper; only starts after live hash verification"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/disk-health.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "fa12746aedce484fe760bcccf4d1bcb3de58501b",
+ "license": "MIT",
+ "role": "verified launchable helper; only starts after live hash verification"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/clean-lxcs.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "e54a307a980130c43134c68d28a8c7cfcdf6b260",
+ "license": "MIT",
+ "role": "verified launchable helper; only starts after live hash verification"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/kernel-clean.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "98351a487f14ed77df99f8945a9f45bf0b1870c0",
+ "license": "MIT",
+ "role": "verified launchable helper; only starts after live hash verification"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/clean-orphaned-lvm.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "18548a37a27a74a0bd7510b58efc0205618b2f64",
+ "license": "MIT",
+ "role": "verified launchable helper; interactive LVM deletion"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/host-backup.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "4a579cd1cb14dbe0623608bd238ea92e3bc84b07",
+ "license": "MIT",
+ "role": "verified launchable helper; writes a user-selected archive"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/scaling-governor.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "79622844c5051349a05acdd46490a5242cfb656c",
+ "license": "MIT",
+ "role": "verified launchable helper; changes host CPU policy"
+ },
+ {
+ "repository": "community-scripts/ProxmoxVE",
+ "path": "tools/pve/update-lxcs.sh",
+ "reviewed_commit": "28fd647e6aed3db6d771cade4fb7c8c5deb48af3",
+ "blob_sha": "ec3dd34f2ff1b1df3c4ce7e664aab5958f9dfca5",
+ "license": "MIT",
+ "role": "verified launchable helper; updates selected containers"
+ },
+ {
+ "repository": "community-scripts/core",
+ "path": "core/core.func",
+ "reviewed_commit": "af0fe772dd02bed10fd0124e3aacc86f2bc900d7",
+ "blob_sha": "0e18236d58a4aec4b200c231c8a4a2387e378bbb",
+ "license": "MIT",
+ "role": "pinned runtime dependency; optional telemetry API intentionally omitted"
+ }
+ ]
+}
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_community_tools.py b/tests/maintenance/test_community_tools.py
new file mode 100644
index 0000000..23f54b4
--- /dev/null
+++ b/tests/maintenance/test_community_tools.py
@@ -0,0 +1,203 @@
+import sys
+import time
+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):
+ 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, '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_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):
+ 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):
+ 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_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..5948a64
--- /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("VERSION = '1.0.3'", "VERSION = '1.0.4'"))
+ 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_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()
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_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
new file mode 100644
index 0000000..f76ebdc
--- /dev/null
+++ b/tests/maintenance/test_ui.py
@@ -0,0 +1,194 @@
+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)
+
+
+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()
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"