Initial commit

This commit is contained in:
2026-05-13 16:04:17 +02:00
commit b66612d672
43 changed files with 10515 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
PVE_API_URL=https://pve.example.invalid:8006
PVE_API_TOKEN_ID=
PVE_API_TOKEN_SECRET=
REPORT_OUTPUT_DIR=/reports
REPORT_TIMEZONE=Europe/Paris
PVE_VERIFY_TLS=true
# Optionnel: chemin vers une CA interne montee dans le conteneur.
# Non utilise si PVE_VERIFY_TLS=false.
PVE_CA_BUNDLE=
PVE_TIMEOUT_SECONDS=30
# Endpoint PVE nominal pour lire les jobs de sauvegarde planifies.
# Ne pas modifier sauf cas particulier de compatibilite.
PVE_BACKUP_JOBS_ENDPOINT=/cluster/backup
# Nombre maximal de taches PVE recentes inspectees pour trouver la derniere sauvegarde.
PVE_TASK_HISTORY_LIMIT=500
# Nombre maximal de lignes recuperees par log de tache vzdump.
# Augmenter si certains jobs multi-VM tres longs ne remontent pas la duree.
PVE_TASK_LOG_LIMIT=5000
# Mapping manuel IP/DNS PBS vers nom d'hote affiche dans le rapport.
# Format: ip=hostname,ip2=hostname2
PBS_HOSTNAMES=
# Collecte optionnelle d'un serveur Proxmox Backup Server.
# L'API PBS utilise le port 8007 et le header PBSAPIToken=tokenid:secret.
# Dupliquer ce bloc en PBS02_*, PBS03_*, PBS04_* pour ajouter d'autres PBS.
PBS01_NAME=
PBS01_API_URL=
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
PBS01_VERIFY_TLS=true
PBS01_CA_BUNDLE=
PBS01_TIMEOUT_SECONDS=30
LOG_LEVEL=INFO
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
+19
View File
@@ -0,0 +1,19 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
build/
dist/
*.egg-info/
reports/*
!reports/.gitkeep
certs/*
!certs/.gitkeep
# Fichiers et repertoires de contexte utilises par les assistants IA.
AGENTS.md
.claude/
.codex/
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt pyproject.toml README.md ./
COPY src ./src
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
fonts-dejavu-core \
libfontconfig1 \
libglib2.0-0 \
libharfbuzz-subset0 \
libharfbuzz0b \
libjpeg62-turbo \
libpango-1.0-0 \
libpangoft2-1.0-0 \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir .
RUN useradd --create-home --shell /usr/sbin/nologin pvereport
USER pvereport
VOLUME ["/reports"]
ENTRYPOINT ["pve-backup-report"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+201
View File
@@ -0,0 +1,201 @@
# 📄 PVE Backup Report
![Version](https://img.shields.io/badge/version-1.0.0-blue)
![Python](https://img.shields.io/badge/python-3.11%2B-green)
![License](https://img.shields.io/badge/license-Apache--2.0-orange)
![Docker](https://img.shields.io/badge/docker-ready-2496ED)
Outil Python pour générer un rapport quotidien des sauvegardes Proxmox VE vers Proxmox Backup Server.
L'application collecte les données via les API PVE/PBS, calcule la couverture des VM/CT, récupère les dernières sauvegardes connues et génère un rapport PDF avec WeasyPrint. Le rendu PDF inclut une page de garde, une table des matières, des KPI de synthèse, les stockages PBS, les jobs de sauvegarde, la couverture VM/CT, la rétention PBS et les anomalies de collecte.
## Documentation
- 🇬🇧 [English version](README.md)
## ✨ Fonctionnalités
- 📄 Rapport PDF quotidien horodaté, sans écrasement des rapports précédents.
- 🧭 Inventaire des stockages PBS déclarés dans Proxmox VE.
- ✅ Analyse de couverture des VM QEMU et conteneurs LXC.
- 🕒 Récupération des dernières sauvegardes connues via les tâches PVE.
- 🗄️ Collecte optionnelle des datastores, namespaces, prune jobs et snapshots PBS.
- ⚠️ Section d'anomalies pour les erreurs ou données de collecte partielles.
- 🐳 Exécution recommandée avec Docker, ou exécution directe en CLI.
## 🐳 Utilisation avec Docker
Docker est le mode d'exécution recommandé. L'image contient les dépendances Python et les bibliothèques système nécessaires à WeasyPrint.
### 📦 Préparation
```sh
cp .env.example .env
mkdir -p reports
```
Adapter ensuite `.env` avec les informations du cluster PVE et des PBS à collecter.
Dans Docker, conserver :
```env
REPORT_OUTPUT_DIR=/reports
```
Le fichier `compose.yaml` monte le répertoire local `./reports` dans le conteneur sous `/reports`. Les PDF générés sont donc visibles sur l'hôte dans `./reports/`.
### ⚙️ Configuration minimale
```env
PVE_API_URL=https://pve.example.invalid:8006
PVE_API_TOKEN_ID=backup-report@pve!report
PVE_API_TOKEN_SECRET=change-me
REPORT_OUTPUT_DIR=/reports
REPORT_TIMEZONE=Europe/Paris
PVE_VERIFY_TLS=true
```
Pour activer la collecte PBS, renseigner l'URL, le token ID et le secret du PBS concerné. Une configuration partielle n'active pas la collecte du PBS.
Exemple avec un PBS :
```env
PBS01_NAME=nom-affiche
PBS01_API_URL=https://backup.example.invalid:8007
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
```
Il est possible de renseigner un ou plusieurs PBS. Pour ajouter un serveur, dupliquer le bloc en incrémentant le numéro du préfixe : `PBS02_*`, `PBS03_*`, `PBS04_*`, etc. L'application détecte automatiquement tous les blocs `PBS<number>_*` présents dans l'environnement.
### 🏗️ Construction
```sh
docker compose build
```
### ✅ Vérification
```sh
docker compose run --rm pve-backup-report --check-config
docker compose run --rm pve-backup-report --check-api
```
`--check-api` teste les endpoints PVE principaux. Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilège `Sys.Audit` sur `/`.
### 📄 Génération du rapport
```sh
docker compose run --rm pve-backup-report --generate-pdf
```
Le rapport PDF est écrit dans `./reports/` avec un nom horodaté et n'écrase jamais un rapport précédent.
### 🧪 Commandes de diagnostic Docker
```sh
docker compose run --rm pve-backup-report --dump-inventory
docker compose run --rm pve-backup-report --dump-coverage
docker compose run --rm pve-backup-report --dump-report-data
docker compose run --rm pve-backup-report --dump-pbs-storage-usages
docker compose run --rm pve-backup-report --debug-last-backup-vmid <VMID>
```
Lancer le conteneur sans argument exécute seulement la commande par défaut `pve-backup-report`. Pour générer un PDF, utiliser explicitement `--generate-pdf`.
### 🕒 Planification avec cron et Docker
Exemple de crontab côté hôte pour lancer le rapport tous les jours à 02:00 :
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /usr/bin/docker compose run --rm pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
Adapter `/srv/pve-backup-report` au chemin réel du dépôt. Le `cd` est important : Docker Compose y trouve `compose.yaml` et l'application y charge `.env`.
## 💻 Utilisation directe en ligne de commande
Ce mode est utile pour le développement ou le diagnostic hors conteneur. L'hôte doit disposer de Python, des dépendances Python du projet et des bibliothèques système requises par WeasyPrint.
### 📦 Installation locale
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m ensurepip --upgrade
pip install -r requirements.txt
pip install -e .
```
Pour lancer les tests :
```sh
pip install -e ".[dev]"
pytest
```
### 🧰 Commandes installées
```sh
pve-backup-report --check-config
pve-backup-report --check-api
pve-backup-report --dump-inventory
pve-backup-report --dump-coverage
pve-backup-report --dump-report-data
pve-backup-report --dump-pbs-storage-usages
pve-backup-report --generate-pdf
```
Sans installation éditable, depuis le dépôt :
```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
PYTHONPATH=src python3 -m pve_backup_report --dump-pbs-storage-usages
PYTHONPATH=src python3 -m pve_backup_report --generate-pdf
```
En exécution locale directe, `REPORT_OUTPUT_DIR` peut rester à `reports/` ou pointer vers un autre répertoire accessible par l'utilisateur courant.
Le même fichier `.env` peut être utilisé en Docker et en exécution locale. Si `REPORT_OUTPUT_DIR=/reports` est conservé hors Docker et que `/reports` n'est pas accessible, l'application utilise automatiquement le répertoire local `reports/` et l'indique dans les logs.
### 🕒 Planification avec cron sans Docker
Exemple avec l'environnement virtuel du projet :
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /srv/pve-backup-report/.venv/bin/pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
L'utilisateur qui exécute la crontab doit avoir le droit de lire `.env` et d'écrire dans `REPORT_OUTPUT_DIR`.
## 🔧 Variables utiles
- 🌐 `PVE_API_URL` : URL d'un noeud PVE joignable, par exemple `https://pve.example.invalid:8006`.
- 🔑 `PVE_API_TOKEN_ID` : identifiant complet du token, par exemple `backup-report@pve!report`.
- 🔒 `PVE_API_TOKEN_SECRET` : secret du token.
- 🛡️ `PVE_VERIFY_TLS` : laisser `true` en production ; utiliser `PVE_CA_BUNDLE` pour une CA interne.
- 🕒 `PVE_TASK_HISTORY_LIMIT` : nombre de tâches PVE récentes inspectées pour retrouver la dernière sauvegarde.
- 📜 `PVE_TASK_LOG_LIMIT` : nombre de lignes récupérées par log `vzdump` pour extraire le détail par VM/CT.
- 🗺️ `PBS_HOSTNAMES` : mapping manuel optionnel des serveurs PBS sous la forme `adresse=nom-affiche,adresse2=nom-affiche2`.
- 🗄️ `PBS<number>_*` : configurations optionnelles des API PBS, par exemple `PBS01_*`, `PBS02_*`, `PBS10_*`.
- 🏷️ `REPORT_FILENAME_PREFIX` : préfixe du fichier PDF généré.
## 📄 Sortie PDF
`--generate-pdf` génère un PDF horodaté dans `REPORT_OUTPUT_DIR`. La colonne `Dernière sauvegarde` affiche l'état, la date, l'heure et la durée lorsque PVE fournit cette information. Les tableaux `Rétention des sauvegardes VM/CT <PBS>` sont séparés par namespace et affichent le datastore, le nombre de versions PBS, la plus ancienne et la plus récente sauvegarde visibles sur chaque PBS configuré.
## 🤖 Utilisation de l'IA
L'application a été entièrement codée avec Codex et Claude code. Je n'aurai jamais eu le temps de coder l'application tout seul en aussi peu de temps.
## ⚖️ Licence
Ce projet est distribué sous licence Apache License 2.0. Voir le fichier `LICENSE`.
+199
View File
@@ -0,0 +1,199 @@
# 📄 PVE Backup Report
![Version](https://img.shields.io/badge/version-1.0.0-blue)
![Python](https://img.shields.io/badge/python-3.11%2B-green)
![License](https://img.shields.io/badge/license-Apache--2.0-orange)
![Docker](https://img.shields.io/badge/docker-ready-2496ED)
Python tool to generate a daily backup report for Proxmox VE against Proxmox Backup Server.
The application collects data via the PVE/PBS APIs, computes VM/CT coverage, retrieves the latest known backups and generates a PDF report using WeasyPrint. The PDF includes a cover page, a table of contents, summary KPIs, PBS storages, backup jobs, VM/CT coverage, PBS retention and collection anomalies.
## ✨ Features
- 📄 Timestamped daily PDF report, without overwriting previous reports.
- 🧭 Inventory of PBS storages declared in Proxmox VE.
- ✅ Coverage analysis for QEMU VMs and LXC containers.
- 🕒 Retrieval of the latest known backups via PVE tasks.
- 🗄️ Optional collection of PBS datastores, namespaces, prune jobs and snapshots.
- ⚠️ Anomalies section for errors or partial collection data.
- 🐳 Recommended execution with Docker, or direct CLI execution.
## 🐳 Usage with Docker
Docker is the recommended execution mode. The image includes the Python dependencies and system libraries required by WeasyPrint.
### 📦 Setup
```sh
cp .env.example .env
mkdir -p reports
```
Then edit `.env` with your PVE cluster and PBS connection details.
In Docker, keep:
```env
REPORT_OUTPUT_DIR=/reports
```
The `compose.yaml` file mounts the local `./reports` directory into the container at `/reports`. Generated PDFs are therefore visible on the host in `./reports/`.
### ⚙️ Minimal configuration
```env
PVE_API_URL=https://pve.example.invalid:8006
PVE_API_TOKEN_ID=backup-report@pve!report
PVE_API_TOKEN_SECRET=change-me
REPORT_OUTPUT_DIR=/reports
REPORT_TIMEZONE=Europe/Paris
PVE_VERIFY_TLS=true
```
To enable PBS collection, provide the URL, token ID and secret for the relevant PBS. An incomplete configuration does not enable PBS collection.
Example with one PBS:
```env
PBS01_NAME=nom-affiche
PBS01_API_URL=https://backup.example.invalid:8007
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
```
One or more PBS servers can be configured. To add a server, duplicate the block and increment the prefix number: `PBS02_*`, `PBS03_*`, `PBS04_*`, etc. The application automatically detects all `PBS<number>_*` blocks present in the environment.
### 🏗️ Build
```sh
docker compose build
```
### ✅ Verification
```sh
docker compose run --rm pve-backup-report --check-config
docker compose run --rm pve-backup-report --check-api
```
`--check-api` tests the main PVE endpoints. If `/cluster/backup` returns `HTTP 403 - Permission check failed (/, Sys.Audit)`, the token works but is missing the `Sys.Audit` privilege on `/`.
### 📄 Generating the report
```sh
docker compose run --rm pve-backup-report --generate-pdf
```
The PDF report is written to `./reports/` with a timestamped filename and never overwrites a previous report.
### 🧪 Docker diagnostic commands
```sh
docker compose run --rm pve-backup-report --dump-inventory
docker compose run --rm pve-backup-report --dump-coverage
docker compose run --rm pve-backup-report --dump-report-data
docker compose run --rm pve-backup-report --dump-pbs-storage-usages
docker compose run --rm pve-backup-report --debug-last-backup-vmid <VMID>
```
Running the container without arguments only executes the default `pve-backup-report` command. To generate a PDF, use `--generate-pdf` explicitly.
### 🕒 Scheduling with cron and Docker
Example crontab on the host to run the report every day at 02:00:
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /usr/bin/docker compose run --rm pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
Replace `/srv/pve-backup-report` with the actual path to the repository. The `cd` is important: Docker Compose finds `compose.yaml` there and the application loads `.env` from it.
## 💻 Direct command-line usage
This mode is useful for development or diagnostics outside a container. The host must have Python, the project's Python dependencies and the system libraries required by WeasyPrint.
### 📦 Local installation
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m ensurepip --upgrade
pip install -r requirements.txt
pip install -e .
```
To run the tests:
```sh
pip install -e ".[dev]"
pytest
```
### 🧰 Installed commands
```sh
pve-backup-report --check-config
pve-backup-report --check-api
pve-backup-report --dump-inventory
pve-backup-report --dump-coverage
pve-backup-report --dump-report-data
pve-backup-report --dump-pbs-storage-usages
pve-backup-report --generate-pdf
```
Without an editable install, from the repository:
```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
PYTHONPATH=src python3 -m pve_backup_report --dump-pbs-storage-usages
PYTHONPATH=src python3 -m pve_backup_report --generate-pdf
```
In direct local execution, `REPORT_OUTPUT_DIR` can remain `reports/` or point to any other directory writable by the current user.
The same `.env` file can be used in Docker and in local execution. If `REPORT_OUTPUT_DIR=/reports` is kept outside Docker and `/reports` is not accessible, the application automatically falls back to the local `reports/` directory and logs the fallback.
### 🕒 Scheduling with cron without Docker
Example using the project's virtual environment:
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /srv/pve-backup-report/.venv/bin/pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
The user running the crontab must have read access to `.env` and write access to `REPORT_OUTPUT_DIR`.
## 🔧 Key variables
- 🌐 `PVE_API_URL`: URL of a reachable PVE node, e.g. `https://pve.example.invalid:8006`.
- 🔑 `PVE_API_TOKEN_ID`: full token identifier, e.g. `backup-report@pve!report`.
- 🔒 `PVE_API_TOKEN_SECRET`: token secret.
- 🛡️ `PVE_VERIFY_TLS`: keep `true` in production; use `PVE_CA_BUNDLE` for an internal CA.
- 🕒 `PVE_TASK_HISTORY_LIMIT`: number of recent PVE tasks inspected to find the latest backup.
- 📜 `PVE_TASK_LOG_LIMIT`: number of lines retrieved per `vzdump` log to extract per-VM/CT detail.
- 🗺️ `PBS_HOSTNAMES`: optional manual mapping of PBS servers as `address=display-name,address2=display-name2`.
- 🗄️ `PBS<number>_*`: optional PBS API configurations, e.g. `PBS01_*`, `PBS02_*`, `PBS10_*`.
- 🏷️ `REPORT_FILENAME_PREFIX`: prefix for the generated PDF filename.
## 📄 PDF output
`--generate-pdf` generates a timestamped PDF in `REPORT_OUTPUT_DIR`. The `Last backup` column shows the status, date, time and duration when PVE provides that information. The `PBS VM/CT backup retention <PBS>` tables are split by namespace and show the datastore, the number of PBS versions, and the oldest and most recent backup visible on each configured PBS.
🤖 AI Usage
The application was entirely coded using Codex and Claude Code. I would never have had enough time to build the application on my own in such a short period of time.
## ⚖️ License
This project is distributed under the Apache License 2.0. See the `LICENSE` file.
+8
View File
@@ -0,0 +1,8 @@
services:
pve-backup-report:
build: .
env_file:
- .env
volumes:
- ./reports:/reports
restart: "no"
+133
View File
@@ -0,0 +1,133 @@
# API Proxmox Backup Server
Ce document decrit la collecte PBS utilisee par le rapport.
## Objectif
La collecte PBS sert a completer le rapport PVE avec les politiques de retention configurees et les versions de sauvegarde visibles sur les serveurs Proxmox Backup Server renseignes.
L'application peut interroger un ou plusieurs serveurs PBS. Chaque serveur est collecte uniquement si son URL API, son token ID et son token secret sont fournis.
Cette etape ne prouve pas les synchronisations entre serveurs PBS. Elle collecte les prune jobs et les snapshots visibles sur chaque PBS configure.
## Authentification
Le client PBS utilise un token API PBS.
Le header HTTP est :
```text
Authorization: PBSAPIToken=TOKENID:TOKENSECRET
```
Exemple de configuration d'un serveur PBS :
```env
PBS01_NAME=nom-affiche
PBS01_API_URL=https://backup.example.invalid:8007
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
```
Pour collecter plusieurs PBS, dupliquer ce bloc avec les prefixes `PBS02_*`, `PBS03_*`, `PBS04_*`, etc. Tous les blocs `PBS<number>_*` complets sont detectes automatiquement.
Le secret du token ne doit jamais etre journalise ni apparaitre dans le rapport.
## Endpoint utilise
| Donnee | Endpoint PBS |
| --- | --- |
| Prune jobs | `/config/prune` |
| Datastores PBS | `/config/datastore` |
| Statut d'un datastore | `/admin/datastore/{datastore}/status` |
| Statut garbage collector d'un datastore | `/admin/datastore/{datastore}/gc` |
| Namespaces d'un datastore | `/admin/datastore/{datastore}/namespace` |
| Snapshots d'un datastore/namespace | `/admin/datastore/{datastore}/snapshots?ns={namespace}` |
| Utilisateurs PBS | `/access/users` |
| Permissions effectives | `/access/permissions?auth-id={auth-id}&path={path}` |
Le client ajoute automatiquement `/api2/json` a l'URL PBS si ce suffixe n'est pas deja present.
## Donnees normalisees
Chaque prune job PBS est transforme en politique de retention avec les champs suivants :
- identifiant du job ;
- serveur PBS ;
- datastore ;
- namespace ;
- planification ;
- etat actif/inactif ;
- `keep-last` ;
- `keep-hourly` ;
- `keep-daily` ;
- `keep-weekly` ;
- `keep-monthly` ;
- `keep-yearly` ;
- `max-depth` ;
- commentaire.
Si le champ `ns` est absent, la namespace est consideree comme la racine `/`.
Le rapport calcule le nombre attendu de versions d'une VM/CT depuis la politique de retention active qui correspond au meme serveur PBS, datastore et namespace que les snapshots. Ce nombre est la somme des valeurs `keep-last`, `keep-hourly`, `keep-daily`, `keep-weekly`, `keep-monthly` et `keep-yearly` renseignees. Le delta affiche `nombre de snapshots visibles - nombre attendu`.
Chaque snapshot PBS est agrege par datastore, namespace, type de sauvegarde et VMID afin de produire :
- nombre de versions de sauvegarde ;
- date de la plus ancienne sauvegarde ;
- date de la plus recente sauvegarde ;
- taille de la sauvegarde la plus recente lorsque PBS expose cette information.
Les snapshots restant visibles sur PBS alors que la VM/CT n'existe plus dans l'inventaire PVE sont conserves pour le tableau de retention et marques `Non-active sur PVE`.
Les snapshots dont le namespace ne figure pas dans les namespaces declares sur PVE sont exclus de ces tableaux de retention.
Le statut de chaque datastore PBS configure est collecte afin d'afficher l'espace total, l'espace consomme et l'espace libre dans le rapport.
## Utilisateurs PBS declares cote PVE
La commande de diagnostic `--dump-pbs-users` collecte les stockages PBS declares dans PVE via `/storage`, puis rapproche leur champ `username` des utilisateurs visibles sur chaque PBS configure via `/access/users`.
Pour chaque stockage PBS PVE ayant un `username`, le diagnostic interroge les permissions effectives de cet auth-id sur le chemin PBS cible :
```text
/datastore/{datastore}
/datastore/{datastore}/{namespace}
```
Le second format est utilise lorsqu'une namespace non racine est declaree cote PVE.
Les informations collectees alimentent le tableau `Utilisateurs PBS - Audit des accès` du rapport et la commande de diagnostic. Elles servent a verifier quel compte PBS est utilise par PVE, si le compte est actif lorsque PBS expose cette information, et quels privileges effectifs sont disponibles sur le datastore/namespace cible.
Si le `username` PVE est un token PBS de type `user@realm!token`, le rapprochement avec `/access/users` se fait sur le user parent `user@realm`; les permissions sont demandees avec l'auth-id complet.
Les types PBS `vm` et `ct` sont rapproches des types PVE `qemu` et `lxc`.
La collecte des snapshots interroge les datastores exposes par chaque PBS configure.
Pour chaque datastore, elle ajoute aussi l'ensemble des namespaces declarees dans les stockages PBS de PVE, meme si le PBS concerne ne recoit pas directement les backups des VM/CT depuis PVE. Cela permet de retrouver des sauvegardes synchronisees sur un PBS secondaire, par exemple dans les namespaces `serveurs-internes` ou `Serveurs-PVELAB`.
Les namespaces retournees par l'API du PBS cible restent collectees egalement. Les snapshots sont ensuite rapproches des VM/CT par type et VMID.
Comme ces serveurs additionnels peuvent contenir des sauvegardes directes et synchronisees, certains appels `/snapshots` peuvent etre plus longs. Augmenter le timeout du bloc PBS concerne si le timeout par defaut est insuffisant.
## Gestion des erreurs
Si aucun PBS n'est configure, la collecte PBS est ignoree.
Si un PBS configure est indisponible, le rapport continue et une anomalie `pbs_retention` est ajoutee pour le serveur concerne.
Si un PBS configure est indisponible pour la collecte des snapshots, le rapport continue et une anomalie `pbs_snapshots` est ajoutee pour le serveur concerne.
Lorsqu'une namespace declaree dans PVE est testee sur un datastore PBS ou elle n'existe pas, PBS peut repondre `HTTP 400 - Bad Request` sur l'endpoint `/snapshots`. Pour une namespace non racine, cette reponse est interpretee comme une absence de namespace sur ce datastore et n'est pas remontee comme anomalie. Les autres erreurs, y compris un `400` sur la racine `/`, restent visibles.
Si la reponse `/config/prune` n'est pas une liste, le rapport continue et ajoute une anomalie.
Si `/config/prune` repond avec une liste vide alors qu'un PBS est configure, une anomalie `warning` est ajoutee afin de verifier soit l'absence reelle de prune jobs, soit les droits effectifs du token PBS.
## Limites
La collecte ne verifie pas encore :
- les synchronisations entre serveurs PBS ;
- l'etat d'execution des prune jobs ;
- le resultat effectif d'un prune sur les snapshots.
Ces points pourront etre ajoutes dans une etape dediee.
+226
View File
@@ -0,0 +1,226 @@
# API Proxmox VE
## Principes
L'application doit utiliser l'API REST Proxmox VE en lecture seule.
Etat actuel : le module `pve_client.py` implemente la connexion API reutilisable et les tests des endpoints `/nodes`, `/storage`, `/cluster` et de l'endpoint configure pour les jobs de sauvegarde.
La collecte des stockages PBS, des jobs de sauvegarde, des VM/CT et des pools est implementee. Le calcul de couverture prend en charge les `vmid` explicitement listes dans les jobs actifs, les jobs `all=1` et les jobs `pool=<nom>`.
L'URL de base suit generalement ce format :
```text
https://<pve-host>:8006/api2/json
```
Le script peut interroger n'importe quel noeud disponible du cluster pour les endpoints globaux.
## Authentification par token
L'authentification recommandee utilise l'en-tete HTTP `Authorization`.
Format :
```text
Authorization: PVEAPIToken=<token-id>=<token-secret>
```
Le token secret ne doit jamais etre logue.
Dans `.env`, renseigner :
```env
PVE_API_TOKEN_ID=backup-report@pve!report
PVE_API_TOKEN_SECRET=secret-fourni-par-pve
```
## Endpoints utiles
Les endpoints exacts doivent etre verifies avec la version PVE cible pendant l'implementation.
Endpoints generalement utiles :
| Besoin | Endpoint indicatif |
| --- | --- |
| Version PVE | `/version` |
| Ressources cluster | `/cluster/resources` |
| Configuration des stockages | `/storage` |
| Index cluster | `/cluster` |
| Jobs de sauvegarde | `/cluster/backup` |
| Pools | `/pools` et `/pools/{poolid}` |
| Taches de sauvegarde recentes | `/cluster/tasks` et `/nodes/{node}/tasks` |
| Configuration VM QEMU | `/nodes/{node}/qemu/{vmid}/config` |
| Configuration conteneur LXC | `/nodes/{node}/lxc/{vmid}/config` |
| Statut des noeuds | `/nodes` |
La valeur nominale pour les jobs de sauvegarde est :
```text
/cluster/backup
```
`PVE_BACKUP_JOBS_ENDPOINT` existe seulement comme override technique. La valeur attendue reste `/cluster/backup`.
Privilege observe pour les jobs de sauvegarde :
```text
Sys.Audit sur /
```
Si le token ne possede pas ce privilege, PVE retourne typiquement `HTTP 403 - Permission check failed (/, Sys.Audit)`.
Avec les tokens en privileges separes, avoir `PVEAuditor` visible dans l'interface ne suffit pas toujours : il faut verifier les droits effectifs du token, pas seulement ceux de l'utilisateur parent.
Verification manuelle :
```sh
PYTHONPATH=src python3 -m pve_backup_report --check-api
```
La commande affiche uniquement le statut et le nombre d'elements retournes. Elle ne journalise pas le token secret.
Les endpoints sont testes independamment. Un echec sur l'endpoint des jobs de sauvegarde n'empeche pas d'afficher le resultat de `/nodes`, `/storage` et `/cluster`.
## Stockages PBS
Un stockage PBS est generalement identifie par un type equivalent a `pbs`.
Champs attendus a normaliser :
- `storage` ou ID equivalent ;
- `type` ;
- `server` ;
- `datastore` ;
- `namespace` ;
- `username`.
Tous les champs ne sont pas forcement presents selon la version ou la configuration.
Le rapport doit afficher une valeur explicite comme `non renseigne` lorsqu'un champ non critique est absent.
Pour l'etat actif, PVE expose generalement `disable=1` lorsqu'un storage est desactive et omet le champ lorsque le storage est actif. L'absence de `disable` doit donc etre interpretee comme actif.
La commande de diagnostic actuelle est :
```sh
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
```
Elle affiche les stockages PBS normalises sans afficher de secret.
## Ressources VM et CT
L'inventaire peut etre construit depuis `/cluster/resources`.
Filtrer les types utiles :
- `qemu` pour les VM ;
- `lxc` pour les conteneurs.
Champs utiles :
- `vmid` ;
- `name` ;
- `type` ;
- `node` ;
- `status`.
Les notes des VM/CT sont collectees depuis la configuration de chaque guest sur son noeud courant :
- VM QEMU : `/nodes/{node}/qemu/{vmid}/config` ;
- conteneurs LXC : `/nodes/{node}/lxc/{vmid}/config`.
Le champ attendu est `description`. Si la configuration d'une VM/CT est indisponible, la collecte continue, une anomalie `guests` est ajoutee, et la note est affichee comme `non renseigné` dans le rapport.
Commande de diagnostic :
```sh
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
```
Commande de previsualisation du modele final :
```sh
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
```
## Derniere sauvegarde realisee
L'etat de la derniere sauvegarde est deduit des taches PVE recentes recuperees via `/cluster/tasks` et `/nodes/{node}/tasks` sans parametre, puis filtrees localement sur le type `vzdump`.
Le nombre de taches inspectees localement est controle par `PVE_TASK_HISTORY_LIMIT`.
Le nombre de lignes recuperees pour chaque log de tache est controle par `PVE_TASK_LOG_LIMIT`.
Si l'historique des taches est indisponible, la generation du rapport continue et une anomalie est ajoutee.
Pour les jobs qui sauvegardent plusieurs VM/CT, le resultat par VM/CT est extrait du log de la tache via `/nodes/{node}/tasks/{upid}/log`.
La duree de sauvegarde est extraite en priorite des lignes `Finished Backup of ... (HH:MM:SS)`, car cette valeur correspond a la VM/CT concernee. En repli, pour une tache simple ou une VM/CT explicitement demarree dans le log, elle peut etre calculee depuis `starttime` et `endtime`.
Le VMID est extrait depuis les lignes de log `Starting Backup of ...`, `Finished Backup of ...` et `Backup of ... failed`. Si le log est indisponible, le script tente un repli depuis les champs `vmid`, `id`, `guest` ou `upid`.
Si une VM/CT vient d'etre ajoutee a un job et n'a pas encore eu de sauvegarde terminee dans l'historique disponible, aucun resultat de derniere sauvegarde ne peut etre renseigne.
## Jobs de sauvegarde
Les jobs de sauvegarde PVE contiennent les informations necessaires pour determiner :
- activation ou desactivation du job ;
- cible storage ;
- planification ;
- selection des VM/CT ;
- exclusions eventuelles ;
- mode de sauvegarde.
L'implementation doit distinguer :
- les jobs actifs ;
- les jobs desactives ;
- les jobs ciblant un PBS ;
- les jobs ciblant un autre type de storage.
Etat actuel : les jobs sont normalises avec ID, storage, schedule, activation, mode, selection brute et exclusions brutes.
Le calcul de couverture interprete les listes explicites `vmid=...`, les jobs `all=1`, les jobs `pool=<nom>`, et les exclusions simples.
Si un job reference un pool absent ou impossible a collecter, la couverture concernee reste `indetermine`.
## Planification
PVE peut exprimer les horaires avec une syntaxe de calendrier.
Le rapport doit afficher la valeur brute si le parsing complet n'est pas implemente.
Si une interpretation lisible est ajoutee, conserver aussi la valeur source en cas d'ambiguite.
## Anomalies de collecte
Ajouter une anomalie lorsque :
- un endpoint retourne une erreur ;
- une reponse est incomplete ;
- un job reference un storage inconnu ;
- un type de selection de VM n'est pas pris en charge ;
- un champ indispensable a l'analyse est absent.
Une anomalie doit contenir :
- une severite ;
- le composant concerne ;
- un message court ;
- des details techniques non sensibles.
## Erreurs gerees par le client
Le client distingue :
- erreur de connexion ou timeout ;
- erreur HTTP avec endpoint et code retour ;
- reponse JSON invalide ;
- reponse JSON sans champ `data`.
Les messages d'erreur ne doivent jamais inclure `PVE_API_TOKEN_SECRET`.
## TLS
La connexion API peut etre executee avec `PVE_VERIFY_TLS=false` lorsque la CA existante n'est pas compatible avec la validation stricte de Python/OpenSSL et ne peut pas etre remplacee.
+157
View File
@@ -0,0 +1,157 @@
# Architecture cible
## Vue d'ensemble
L'application est un outil batch lance une fois par jour. Elle collecte les donnees depuis l'API Proxmox VE, les transforme en objets internes, calcule la couverture de sauvegarde, puis genere un rapport PDF horodate.
Le conteneur Docker execute une commande unique et se termine avec un code de retour explicite.
Etat actuel du depot : la CLI, la configuration, le logging, la collecte PVE/PBS, l'analyse de couverture et la generation PDF WeasyPrint sont implementes. Le conteneur Docker integre les dependances systeme necessaires au rendu PDF.
## Composants
### CLI
La CLI est le point d'entree du script.
Responsabilites :
- charger la configuration ;
- initialiser les logs ;
- lancer la collecte ;
- lancer l'analyse ;
- generer le PDF ;
- retourner un code de sortie adapte.
### Configuration
Le module de configuration lit les variables d'environnement et valide les valeurs obligatoires.
Il ne doit pas contenir de logique d'appel API.
### Client PVE
Le client PVE encapsule les appels HTTP vers l'API Proxmox VE.
Le client utilise `requests` et une session reutilisable.
Il implemente :
- authentification par API token ;
- verification TLS configurable ;
- CA bundle optionnel ;
- timeout reseau ;
- erreurs dediees pour connexion, HTTP et reponse invalide ;
- methodes de verification pour `/nodes`, `/storage` et `/cluster/backup`.
Il gere :
- l'URL de base ;
- l'authentification par token ;
- la verification TLS ;
- les erreurs HTTP ;
- les timeouts ;
- la deserialisation JSON.
### Collecteurs
Les collecteurs recuperent les informations brutes :
- stockages ;
- jobs de sauvegarde ;
- ressources du cluster ;
- informations utiles sur les VM et CT.
- notes des VM/CT depuis leur configuration par noeud ;
- prune jobs PBS, si la collecte PBS est configuree ;
- snapshots PBS par datastore et namespace, si la collecte PBS correspondante est configuree.
Chaque collecteur doit retourner des donnees structurees, pas du JSON brut disperse dans l'application.
Etat actuel : la collecte des stockages PBS via `/storage`, des jobs de sauvegarde via `/cluster/backup`, des VM/CT via `/cluster/resources`, des pools via `/pools`, des dernieres taches `vzdump` via `/cluster/tasks` et `/nodes/{node}/tasks`, ainsi que des prune jobs et snapshots via l'API PBS, est implementee.
### Modeles
Les modeles representent les entites internes :
- `PbsStorage` ;
- `PbsRetentionPolicy` ;
- `PbsBackupSnapshotSummary` ;
- `Guest` ;
- `BackupJob` ;
- `BackupCoverage` ;
- `ReportData` ;
- `ReportSummary` ;
- `CollectionIssue`.
Les modeles peuvent etre implementes avec `dataclasses` ou Pydantic selon les choix du projet.
### Analyse de couverture
Le module d'analyse compare l'inventaire des VM/CT avec les jobs de sauvegarde actifs.
Il produit un statut par VM/CT :
- `sauvegarde_planifiee` ;
- `non_sauvegardee` ;
- `indetermine`.
Etat actuel : la couverture par `vmid` explicitement listes, `all=1` et `pool=<nom>` est calculee. Les exclusions simples sont prises en compte.
### Generation PDF
Le generateur PDF prend un objet `ReportData` complet et produit un fichier.
Il ne doit pas appeler l'API PVE.
Il ne doit pas relire la configuration globale sauf pour les options de rendu strictement necessaires.
Le rendu principal utilise `WeasyPrint` a partir d'un template HTML/CSS. Cette approche garde la logique metier separee de la presentation et rend les evolutions visuelles plus simples.
Le PDF contient actuellement le resume, les stockages PBS declares sur PVE, les espaces de stockage PBS, les politiques de retention PBS, les jobs de sauvegarde, les VM/CT non sauvegardees, la couverture VM/CT, les retentions de sauvegarde VM/CT par PBS avec nombre attendu de versions et delta, et les anomalies.
## Flux de donnees
1. Chargement de la configuration.
2. Connexion a l'API PVE.
3. Connexion optionnelle aux API PBS configurees.
4. Collecte des stockages.
5. Filtrage des stockages PBS.
6. Collecte des prune jobs et des snapshots PBS, si configures.
7. Collecte des jobs de sauvegarde.
8. Collecte des VM et CT.
9. Calcul de couverture.
10. Construction du modele de rapport.
11. Generation du PDF.
12. Log du resultat et code de sortie.
Une etape de preparation assemble un `ReportData` final avec un `ReportSummary` calcule. La commande `--dump-report-data` permet de previsualiser ce modele en JSON.
La commande `--generate-pdf` rend ce modele en PDF avec WeasyPrint. En Docker Compose, elle doit etre lancee explicitement :
```sh
docker compose run --rm pve-backup-report --generate-pdf
```
## Gestion des erreurs
Les erreurs bloquantes arretent l'execution :
- configuration invalide ;
- echec d'authentification ;
- API PVE totalement indisponible ;
- impossibilite d'ecrire le PDF.
Les erreurs partielles peuvent etre ajoutees au rapport :
- un noeud ne repond pas ;
- un champ attendu manque ;
- un job ne peut pas etre interprete ;
- un storage reference n'existe pas dans la collecte.
## Idempotence
L'execution quotidienne ne doit pas modifier l'etat PVE.
L'outil est en lecture seule vis-a-vis du cluster.
Le seul effet de bord attendu est la creation d'un nouveau rapport dans le repertoire de sortie.
+247
View File
@@ -0,0 +1,247 @@
# Configuration
## Variables d'environnement
Configuration minimale recommandee :
```env
PVE_API_URL=https://pve.example.invalid:8006
PVE_API_TOKEN_ID=
PVE_API_TOKEN_SECRET=
REPORT_OUTPUT_DIR=/reports
REPORT_TIMEZONE=Europe/Paris
```
Un fichier `.env.example` est fourni comme modele. Le fichier `.env` reel ne doit pas etre commite.
Configuration optionnelle :
```env
PVE_VERIFY_TLS=false
PVE_CA_BUNDLE=
PVE_TIMEOUT_SECONDS=30
PVE_BACKUP_JOBS_ENDPOINT=/cluster/backup
PVE_TASK_HISTORY_LIMIT=500
PBS_HOSTNAMES=backup.example.invalid=nom-affiche
LOG_LEVEL=INFO
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
```
## Description des variables
| Variable | Obligatoire | Description |
| --- | --- | --- |
| `PVE_API_URL` | Oui | URL de l'API Proxmox VE, avec schema et port. |
| `PVE_API_TOKEN_ID` | Oui | Identifiant complet du token API PVE. |
| `PVE_API_TOKEN_SECRET` | Oui | Secret du token API PVE. |
| `REPORT_OUTPUT_DIR` | Non | Repertoire de sortie des rapports PDF. Defaut applicatif : `reports/`. En Docker Compose, utiliser `/reports`. |
| `REPORT_TIMEZONE` | Non | Fuseau horaire utilise pour les dates du rapport. Defaut : `Europe/Paris`. |
| `PVE_VERIFY_TLS` | Non | Active la verification TLS. Defaut : `true`. |
| `PVE_CA_BUNDLE` | Non | Chemin vers une CA interne montee dans le conteneur. |
| `PVE_TIMEOUT_SECONDS` | Non | Timeout HTTP. Defaut : `30`. |
| `PVE_BACKUP_JOBS_ENDPOINT` | Non | Endpoint qui liste les jobs de sauvegarde planifies. Defaut : `/cluster/backup`. A conserver sauf cas particulier. |
| `PVE_TASK_HISTORY_LIMIT` | Non | Nombre de taches PVE recentes inspectees pour trouver la derniere sauvegarde. Defaut : `500`. |
| `PVE_TASK_LOG_LIMIT` | Non | Nombre de lignes recuperees par log de tache `vzdump`. Defaut : `5000`. |
| `PBS_HOSTNAMES` | Non | Mapping manuel IP/DNS PBS vers nom d'hote affiche dans le rapport. |
| `PBS<number>_NAME` | Non | Nom affiche du serveur PBS configure, par exemple `PBS01_NAME`. Defaut : le prefixe `PBS<number>`. |
| `PBS<number>_API_URL` | Non | URL API du serveur PBS, par exemple `https://backup.example.invalid:8007`. Active la collecte si le token est aussi renseigne. |
| `PBS<number>_API_TOKEN_ID` | Non | Identifiant complet du token API PBS. |
| `PBS<number>_API_TOKEN_SECRET` | Non | Secret du token API PBS. |
| `PBS<number>_VERIFY_TLS` | Non | Active la verification TLS pour ce serveur PBS. Defaut : valeur de `PVE_VERIFY_TLS`. |
| `PBS<number>_CA_BUNDLE` | Non | Chemin vers une CA interne pour ce serveur PBS. |
| `PBS<number>_TIMEOUT_SECONDS` | Non | Timeout HTTP pour ce serveur PBS. Defaut : valeur de `PVE_TIMEOUT_SECONDS` ou `30`. |
| `LOG_LEVEL` | Non | Niveau de log. Defaut : `INFO`. |
| `REPORT_FILENAME_PREFIX` | Non | Prefixe du fichier PDF. |
## Exemple Docker Compose
```yaml
services:
pve-backup-report:
build: .
env_file:
- .env
volumes:
- ./reports:/reports
restart: "no"
```
Avec ce montage, la configuration Docker doit contenir :
```env
REPORT_OUTPUT_DIR=/reports
```
Les rapports sont alors ecrits dans `./reports/` sur l'hote.
## Mapping des noms PBS
Le champ `PBS_HOSTNAMES` permet d'afficher le nom d'hote entre parentheses dans la colonne `Serveur PBS`.
Format :
```env
PBS_HOSTNAMES=backup.example.invalid=nom-affiche
```
Rendu dans le rapport :
```text
backup.example.invalid (nom-affiche)
```
## Collecte PBS
La collecte PBS est optionnelle. Pour chaque serveur PBS, elle est active uniquement si les trois variables API URL, token ID et token secret sont renseignees.
Un bloc PBS suit le motif `PBS<number>_*`. L'ordre de collecte et d'affichage suit l'ordre numerique des prefixes : `PBS01`, `PBS02`, `PBS10`, etc.
```env
PBS01_NAME=nom-affiche
PBS01_API_URL=https://backup.example.invalid:8007
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
```
Pour ajouter d'autres serveurs PBS, dupliquer le bloc et incrementer le numero du prefixe :
```env
PBS02_NAME=nom-affiche-secondaire
PBS02_API_URL=https://backup-secondary.example.invalid:8007
PBS02_API_TOKEN_ID=
PBS02_API_TOKEN_SECRET=
PBS10_NAME=nom-affiche-dixieme
PBS10_API_URL=https://backup-extra.example.invalid:8007
PBS10_API_TOKEN_ID=
PBS10_API_TOKEN_SECRET=
```
Le token PBS est transmis avec le schema d'authentification `PBSAPIToken` sous la forme `TOKENID:TOKENSECRET`.
La collecte d'un PBS est active uniquement lorsque l'URL, le token ID et le token secret sont tous renseignes. Une URL seule ne bloque pas le demarrage, mais aucune collecte n'est lancee pour ce PBS.
Le service Docker lance la commande `pve-backup-report`. Pour produire un rapport, passer explicitement l'argument `--generate-pdf`.
Pour tester la connexion API :
```sh
docker compose run --rm pve-backup-report --check-api
```
Pour generer le PDF :
```sh
docker compose run --rm pve-backup-report --generate-pdf
```
## Permissions du token PVE
Le token doit etre limite a la lecture.
Permissions attendues selon l'implementation :
- lecture de la configuration du cluster ;
- lecture des stockages ;
- lecture des ressources VM/CT ;
- lecture des jobs de sauvegarde.
Pour le test de l'endpoint des jobs de sauvegarde, PVE peut exiger le privilege `Sys.Audit` sur le chemin `/`.
Si `--check-api` retourne :
```text
<endpoint>: HTTP 403 - Permission check failed (/, Sys.Audit)
```
ajouter ce privilege au role du token utilise, ou utiliser un role de lecture existant qui le contient.
Dans l'etat observe du projet, `/cluster/backup` existe bien et l'erreur attendue en cas de droit insuffisant est un `403`, pas un `404`.
### Diagnostic des droits effectifs du token
Un token PVE peut etre en mode privileges separes. Dans ce mode, les droits effectifs du token sont l'intersection des droits de l'utilisateur et des ACL propres au token.
Verifier d'abord le token :
```sh
pveum user token permissions <user@realm> <tokenid> --path /
```
Exemple :
```sh
pveum user token permissions backup-report@pve report --path /
```
Verifier ensuite les ACL :
```sh
pveum acl list
```
Pour ajouter explicitement `PVEAuditor` au token sur `/` :
```sh
pveum acl modify / --tokens 'backup-report@pve!report' --roles PVEAuditor --propagate 1
```
Si le token est en privileges separes, l'utilisateur parent doit aussi avoir un droit compatible :
```sh
pveum acl modify / --users backup-report@pve --roles PVEAuditor --propagate 1
```
Autre option, moins restrictive : creer ou modifier le token en privileges complets (`privsep=0`). Dans ce cas, le token reprend les droits de son utilisateur parent. Cette option est moins fine et doit etre choisie volontairement.
Eviter les privileges d'administration globaux.
## TLS
La verification TLS peut etre desactivee explicitement pour les connexions locales :
```env
PVE_VERIFY_TLS=false
PVE_CA_BUNDLE=
```
Lorsque `PVE_VERIFY_TLS=false`, `PVE_CA_BUNDLE` est ignore meme s'il est renseigne.
Quand cette option est utilisee explicitement, l'application journalise un avertissement unique et masque les avertissements repetes de `urllib3` pour conserver des logs lisibles.
Si la CA interne n'est pas reconnue par le conteneur, preferer un volume monte et renseigner `PVE_CA_BUNDLE`.
Exemple :
```yaml
volumes:
- ./reports:/reports
- /etc/ssl/local/pve-ca.pem:/etc/ssl/local/pve-ca.pem:ro
```
Puis :
```env
PVE_CA_BUNDLE=/etc/ssl/local/pve-ca.pem
```
## Repertoire de sortie
Le repertoire de sortie doit etre persistant.
Dans Docker, monter un volume hote :
```yaml
volumes:
- /srv/pve-backup-report/reports:/reports
```
Et configurer :
```env
REPORT_OUTPUT_DIR=/reports
```
Le conteneur doit avoir le droit d'ecrire dans ce repertoire.
Les rapports ne doivent pas etre produits dans un chemin ephemere si leur conservation est requise pour l'audit.
+299
View File
@@ -0,0 +1,299 @@
# Exploitation
## Execution avec Docker
Docker est le mode d'exploitation recommande. Le conteneur execute une commande ponctuelle, ecrit les logs sur stdout/stderr, genere le rapport PDF puis se termine.
### Preparation
```sh
cp .env.example .env
mkdir -p reports
```
Dans `.env`, adapter les acces PVE/PBS et conserver en execution Docker :
```env
REPORT_OUTPUT_DIR=/reports
```
Le service Docker Compose monte `./reports` vers `/reports`, ce qui rend les PDF disponibles sur l'hote dans le repertoire `reports/`.
### Construction de l'image
```sh
docker compose build
```
L'image installe les dependances Python et les bibliotheques systeme necessaires a WeasyPrint.
### Verification de la configuration
```sh
docker compose run --rm pve-backup-report --check-config
```
### Test de connexion API
```sh
docker compose run --rm pve-backup-report --check-api
```
Cette commande teste `/nodes`, `/storage`, `/cluster` et l'endpoint configure dans `PVE_BACKUP_JOBS_ENDPOINT`.
Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilege `Sys.Audit` sur `/`.
### Generation du rapport PDF
```sh
docker compose run --rm pve-backup-report --generate-pdf
```
Le rapport est cree dans `REPORT_OUTPUT_DIR`. Le nom contient un horodatage et n'ecrase pas les rapports precedents.
Exemple de fichier genere sur l'hote :
```text
reports/rapport-sauvegardes-pve-2026-05-09-020000.pdf
```
### Commandes de diagnostic Docker
Inventaire :
```sh
docker compose run --rm pve-backup-report --dump-inventory
```
Couverture :
```sh
docker compose run --rm pve-backup-report --dump-coverage
```
Donnees de rapport au format JSON :
```sh
docker compose run --rm pve-backup-report --dump-report-data
```
Cette sortie est une previsualisation technique du modele de rapport. Les champs bruts sensibles sont filtres, mais le JSON contient des informations d'exploitation comme les noms de VM/CT, VMID, notes PVE, serveurs, datastores, namespaces, utilisateurs PBS et metadonnees de sauvegarde. Il doit donc etre traite comme une donnee interne et ne pas etre partage hors du perimetre d'exploitation/audit autorise.
Espaces des datastores PBS :
```sh
docker compose run --rm pve-backup-report --dump-pbs-storage-usages
```
Utilisateurs PBS utilises par les stockages PVE :
```sh
docker compose run --rm pve-backup-report --dump-pbs-users
```
Diagnostic d'une derniere sauvegarde manquante :
```sh
docker compose run --rm pve-backup-report --debug-last-backup-vmid <VMID>
```
La commande `docker compose run --rm pve-backup-report` sans argument ne genere pas de PDF. Pour produire le rapport, utiliser explicitement `--generate-pdf`.
## Execution directe en ligne de commande
Ce mode est utile pour le developpement ou le diagnostic hors conteneur.
Installation locale :
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m ensurepip --upgrade
pip install -r requirements.txt
pip install -e .
```
La generation PDF utilise WeasyPrint. Hors Docker, l'hote doit fournir les bibliotheques systeme requises par WeasyPrint.
Avec WeasyPrint 62.x, conserver la contrainte `pydyf>=0.10,<0.11` pour eviter les erreurs de generation PDF liees a l'API interne de `pydyf`.
Commandes installees :
```sh
pve-backup-report --check-config
pve-backup-report --check-api
pve-backup-report --dump-inventory
pve-backup-report --dump-coverage
pve-backup-report --dump-report-data
pve-backup-report --dump-pbs-storage-usages
pve-backup-report --dump-pbs-users
pve-backup-report --generate-pdf
```
Sans installation editable :
```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
PYTHONPATH=src python3 -m pve_backup_report --dump-pbs-storage-usages
PYTHONPATH=src python3 -m pve_backup_report --dump-pbs-users
PYTHONPATH=src python3 -m pve_backup_report --generate-pdf
```
En execution locale directe, `REPORT_OUTPUT_DIR` peut rester a `reports/` ou pointer vers un autre repertoire accessible par l'utilisateur courant.
Le meme fichier `.env` peut etre utilise en Docker et en execution locale. La valeur Docker `REPORT_OUTPUT_DIR=/reports` reste valide dans le conteneur avec le montage `./reports:/reports`. Hors Docker, si `/reports` n'est pas accessible, l'application utilise automatiquement le repertoire local `reports/` et l'indique dans les logs.
Il reste possible de forcer explicitement un chemin local :
```env
REPORT_OUTPUT_DIR=reports
```
## Politique de retention PBS
Pour alimenter le tableau `Politique de retention` et les tableaux `Retention des sauvegardes VM/CT`, renseigner les variables API du PBS concerne :
```env
PBS01_NAME=nom-affiche
PBS01_API_URL=https://backup.example.invalid:8007
PBS01_API_TOKEN_ID=
PBS01_API_TOKEN_SECRET=
PBS01_VERIFY_TLS=true
```
Pour plusieurs PBS, dupliquer ce bloc avec les prefixes `PBS02_*`, `PBS03_*`, `PBS04_*`, etc. Le rapport cree les sections de retention pour chaque PBS configure.
La collecte lit les prune jobs des PBS configures via `/config/prune`. Si un PBS est indisponible, si son token n'a pas les droits suffisants, ou si aucune politique n'est retournee, le rapport continue et ajoute une anomalie `pbs_retention`.
La collecte lit aussi les snapshots des datastores de chaque PBS configure. Les namespaces declarees dans les stockages PBS de PVE sont interrogees sur chaque PBS, y compris sur un PBS secondaire qui ne recoit pas directement les backups depuis PVE mais contient des sauvegardes synchronisees. Ces donnees alimentent les tableaux `Retention des sauvegardes VM/CT <PBS>`, y compris les VM/CT encore visibles sur PBS mais absentes de l'inventaire PVE. Elles sont marquees `Non-active sur PVE` dans le rapport.
Si une namespace PVE n'existe pas sur un datastore PBS teste, l'eventuel `HTTP 400 - Bad Request` retourne par PBS est ignore pour cette namespace non racine afin d'eviter une anomalie attendue.
Les tableaux de retention sont separes par namespace et affichent aussi le datastore, car une meme namespace peut exister sur plusieurs datastores d'un meme PBS.
Ils affichent aussi le nombre attendu de versions et un delta par rapport au nombre de snapshots visibles. Ce calcul depend d'une politique de retention active correspondant au meme serveur PBS, datastore et namespace.
Le rapport collecte aussi le statut des datastores de chaque PBS configure afin d'afficher l'espace total, l'espace consomme et l'espace libre.
Le statut du garbage collector de chaque datastore PBS est aussi collecte. Si le garbage collector est en cours, un avertissement est affiche uniquement sous les tableaux de retention du PBS/datastore concerne.
## Planification quotidienne
La planification recommandee sur Debian 13 peut etre faite avec systemd timer ou cron. Le conteneur et la CLI sont concus pour une execution ponctuelle : la commande genere le rapport puis se termine.
### Cron avec Docker Compose
Exemple de crontab cote hote pour lancer le rapport tous les jours a 02:00 :
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /usr/bin/docker compose run --rm pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
Points importants :
- adapter `/srv/pve-backup-report` au chemin reel du depot ;
- conserver `REPORT_OUTPUT_DIR=/reports` dans `.env` pour Docker ;
- verifier que le repertoire hote `reports/` existe et est accessible ;
- utiliser `flock` pour eviter deux generations simultanees si une execution dure plus longtemps que prevu ;
- rediriger stdout/stderr vers un fichier de log ou vers le systeme de supervision de l'hote.
Pour tester exactement la commande planifiee :
```sh
cd /srv/pve-backup-report
/usr/bin/flock -n /tmp/pve-backup-report.lock /usr/bin/docker compose run --rm pve-backup-report --generate-pdf
```
### Cron en appel direct du script
Exemple avec l'environnement virtuel du projet :
```cron
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * cd /srv/pve-backup-report && /usr/bin/flock -n /tmp/pve-backup-report.lock /srv/pve-backup-report/.venv/bin/pve-backup-report --generate-pdf >> /var/log/pve-backup-report.log 2>&1
```
Dans ce mode :
- le `cd` vers le depot permet de charger le fichier `.env` par defaut ;
- `REPORT_OUTPUT_DIR` peut rester a `/reports`, a `reports/`, ou pointer vers un chemin absolu accessible par l'utilisateur cron ;
- l'utilisateur cron doit avoir les droits de lecture sur `.env` et d'ecriture dans le repertoire des rapports ;
- les bibliotheques systeme requises par WeasyPrint doivent etre installees sur l'hote.
Pour tester exactement la commande planifiee :
```sh
cd /srv/pve-backup-report
/usr/bin/flock -n /tmp/pve-backup-report.lock /srv/pve-backup-report/.venv/bin/pve-backup-report --generate-pdf
```
Adapter l'horaire pour eviter les periodes de forte charge ou les fenetres de maintenance.
## Verification quotidienne
Apres execution, verifier :
- la presence d'un nouveau PDF ;
- le code retour de la commande ;
- les logs du conteneur ;
- la section des anomalies ;
- la liste des VM/CT sans sauvegarde.
## Conservation des preuves
Pour un usage audit, conserver les rapports dans un emplacement sauvegarde.
Recommandations :
- stockage persistant hors conteneur ;
- droits d'ecriture limites ;
- lecture autorisee aux personnes habilitees ;
- retention alignee avec la politique interne ;
- horloge systeme synchronisee par NTP.
## Codes de sortie recommandes
| Code | Signification |
| --- | --- |
| `0` | Rapport genere sans erreur bloquante. |
| `1` | Erreur de configuration. |
| `2` | Authentification PVE impossible. |
| `3` | API PVE indisponible. |
| `4` | Erreur d'ecriture du PDF. |
| `5` | Erreur inattendue. |
Une execution avec anomalies non bloquantes peut retourner `0` si le rapport est genere et que les anomalies sont visibles dans le PDF.
## Supervision
Surveiller au minimum :
- absence de rapport du jour ;
- echec de commande ;
- augmentation du nombre de VM/CT non sauvegardes ;
- apparition d'anomalies recurrentes ;
- expiration ou revocation du token PVE.
## Rotation des logs
Les logs doivent rester sur stdout/stderr pour s'integrer a Docker.
La rotation est geree par Docker ou par la plateforme d'exploitation.
## Restauration et audit
Ce rapport prouve la configuration observee au moment de son execution.
Il ne prouve pas a lui seul qu'une restauration est possible.
Pour une preuve plus forte, completer ce projet avec :
- controle des dernieres executions de jobs ;
- verification des snapshots presents dans PBS ;
- tests periodiques de restauration ;
- signature ou stockage immuable des rapports.
+252
View File
@@ -0,0 +1,252 @@
# Rapport PDF
## Objectif
Le PDF doit etre lisible par une equipe d'exploitation et exploitable comme preuve de suivi lors d'un audit.
Il doit privilegier la clarte, la date de generation, la couverture de sauvegarde et les exceptions.
Etat actuel : la generation PDF est implementee avec `WeasyPrint` a partir d'un template HTML/CSS.
L'ancien rendu `reportlab` reste present dans le depot comme reference technique, mais la commande `--generate-pdf` utilise le rendu WeasyPrint.
Etat du rendu WeasyPrint :
- Page de garde complete (break-after page) : barre de logo sur fond blanc en haut, titre principal en bleu `#1f4e79` 32pt centre, barre de metadonnees (Generation, Version) ancree en bas de page via un spacer flex.
- Pied de page present sur toutes les pages y compris la page de garde : libelle de document a gauche, date de generation au centre, version a droite, separes du contenu par un filet horizontal.
- En-tetes de pages (hors premiere page) : titre du rapport a gauche, numero de page sur le total a droite.
- Table des matieres avec numeros de page automatiques (CSS `target-counter`) et hierarchie visuelle : titres de groupe en gras bleu, sous-sections indentees.
- Icones SVG devant chaque titre de section (histogramme, serveur, cylindre, horloge, engrenage, triangle d'alerte, bouclier, archive, croix, cercle gris avec point d'interrogation pour la namespace non renseignee) pour identifier rapidement chaque partie.
- En-tetes de sections avec bordure gauche bleue et fond gris tres clair pour distinguer les titres du contenu.
- Section Resume affichee en grille de KPI cards (7 colonnes sur 2 lignes) plutot qu'en tableau ; les indicateurs "Non sauvegardees" et "Anomalies" apparaissent en orange si leur valeur est superieure a zero.
- Tableaux avec en-tetes fonces, lignes alternees, et classes CSS appliquees aux cellules selon leur valeur : vert pour "Active" et "Succes", rouge/gras pour "Non-active" et "Echec", ambre pour "Indetermine", gris italique pour "non renseigne".
- Messages d'avertissement (GC PBS en cours) et messages vides distincts visuellement des tableaux.
- Le rapport reste lisible en impression noir et blanc : le style repose sur le gras, l'italique et la bordure gauche en plus de la couleur.
Le modele de donnees final du futur rapport est previsualisable avec :
```sh
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
```
Cette commande produit un JSON contenant le resume calcule, les stockages PBS, les jobs de sauvegarde, la couverture VM/CT et les anomalies.
Les champs bruts sensibles sont filtres dans cette sortie, mais `--dump-report-data` reste une sortie d'audit technique : elle contient des noms de VM/CT, VMID, notes PVE, serveurs, datastores, namespaces, utilisateurs PBS et metadonnees d'infrastructure. Elle doit etre traitee comme une donnee interne.
Generation PDF :
```sh
PYTHONPATH=src python3 -m pve_backup_report --generate-pdf
```
Le fichier est ecrit dans `REPORT_OUTPUT_DIR` avec un nom horodate. Un rapport existant n'est jamais ecrase.
## Nom du fichier
Format recommande :
```text
rapport-sauvegardes-pve-YYYY-MM-DD-HHMMSS.pdf
```
Exemple :
```text
rapport-sauvegardes-pve-2026-05-07-020000.pdf
```
Le script doit refuser d'ecraser un fichier existant ou generer un nom alternatif unique.
## Structure du rapport
### Page de garde
Structure implementee (page entiere, break-after page) :
1. **Barre logo** : logo Proxmox VE (PNG base64) sur fond blanc, separee par un filet gris en bas.
2. **Zone titre** : titre `{{ title }}` en bleu `#1f4e79` 32pt centre, sur fond blanc.
3. **Spacer** : div `flex: 1` qui absorbe l'espace vide et pousse le cartouche en bas.
4. **Barre de metadonnees** : fond gris clair avec bordure bleue en haut, deux elements centres — Generation (`{{ generated_at }}`) et Version (`{{ version }}`), separes par un filet vertical.
### Table des matieres
La table des matieres est placee apres la page de garde.
Elle est alimentee automatiquement depuis les titres de sections du rapport afin de faciliter la navigation dans le PDF.
### Resume executif
Indicateurs minimaux :
- nombre total de VM ;
- nombre total de CT ;
- nombre de VM/CT avec sauvegarde planifiee ;
- nombre de VM/CT sans sauvegarde planifiee ;
- nombre de VM/CT indetermines ;
- nombre de stockages PBS ;
- nombre d'anomalies.
Le resume calcule dans `ReportSummary` contient aussi :
- nombre de jobs actifs ;
- nombre de jobs inactifs ;
- nombre de sauvegardes vers storage non PBS ;
- nombre de sauvegardes vers PBS desactive ;
### Stockages PBS déclarés sur PVE
Tableau attendu :
| ID | Username | Serveur PBS | Datastore | Namespace | Actif |
| --- | --- | --- | --- | --- | --- |
Les secrets ne doivent jamais apparaitre.
La colonne `Actif` affiche `oui` lorsque le stockage PBS est actif dans PVE. Si le champ PVE `disable` est absent, le stockage est considere actif ; `disable=1` est affiche `non`.
### Utilisateurs PBS - Audit des accès
Ce tableau est place juste apres `Stockages PBS déclarés sur PVE`.
Tableau attendu :
| Serveur PBS | Auth-id | Storage PVE | Datastore | Namespace | Actif | Expiration | Email | Permissions | Commentaire |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
Les donnees viennent des `username` des stockages PBS PVE, rapproches des utilisateurs et permissions effectives exposes par les PBS configures.
Si l'auth-id PVE est un token de type `user@realm!token`, la colonne `Auth-id` conserve la valeur complete et les metadonnees utilisateur sont rapprochees avec `user@realm`.
Les secrets ne doivent jamais apparaitre. Les permissions affichees sont uniquement les privileges effectifs actifs sur le datastore/namespace cible.
### Espaces de stockage PBS
Tableau attendu :
| Serveur PBS | Datastore | Espace total | Espace consomme | Espace libre |
| --- | --- | --- | --- | --- |
Les valeurs sont alimentees depuis le statut des datastores de chaque PBS configure.
Les tailles sont affichees en unites lisibles (`Kio`, `Mio`, `Gio`, `Tio`).
### Sauvegarde des VM/CT par namespace
Les tableaux de sauvegarde sont regroupes sous un titre de niveau 1 `Sauvegarde des VM/CT`.
Les VM/CT sont affichees dans plusieurs tableaux, un tableau par namespace.
Le titre de chaque tableau indique la namespace concernee.
Colonnes attendues :
| VMID | Nom | Notes | Type | Noeud | Etat de la VM | Sauvegarde | Serveur PBS | Storage | Mode | Frequence de sauvegarde | Derniere sauvegarde |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
La colonne `Notes` reprend les notes renseignees sur la VM ou le CT dans PVE. Si aucune note n'est renseignee ou si elle ne peut pas etre collectee, la valeur affichee est `non renseigné`.
La colonne `Sauvegarde` affiche `Active` lorsqu'une sauvegarde PBS active est planifiee, sinon `Non-active`.
La colonne `Frequence de sauvegarde` reprend l'horaire des jobs de sauvegarde associes, c'est-a-dire la valeur affichee dans la colonne `Horaire` du tableau `Jobs de sauvegarde`.
La colonne `Serveur PBS` est deduite du storage PBS associe lorsque le storage est connu. Si `PBS_HOSTNAMES` contient une correspondance, le rendu est `IP (nom-hote)`.
La namespace n'est pas affichee comme colonne dans ces tableaux, car elle est deja portee par le titre du tableau.
Les tableaux sont tries par ordre alphabetique de namespace, puis les lignes de chaque tableau sont triees par ordre alphabetique sur `Frequence de sauvegarde`. Le `VMID` ne sert plus de critere principal de tri.
La colonne `Mode` traduit le mode technique PVE du job :
- `snapshot` devient `Aucune interruption attendue` ;
- `suspend` devient `Suspension temporaire` ;
- `stop` devient `Arret pendant sauvegarde`.
La colonne `Derniere sauvegarde` affiche l'etat `Succes`, `Echec` ou `Indetermine`, suivi de la date, de l'heure de fin et de la duree de la derniere tache `vzdump` connue pour la VM/CT lorsque cette duree est disponible.
Pour les jobs multi-VM/CT, cette information est deduite du log de la tache `vzdump`, pas seulement de la tache globale du job.
La valeur `non renseigne` est attendue si aucune tache `vzdump` terminee n'est encore disponible dans l'historique PVE accessible, par exemple pour une VM/CT ajoutee recemment a un job avant sa premiere sauvegarde effective.
Les lignes sans sauvegarde doivent etre visuellement identifiables.
Les lignes indeterminees doivent etre distinctes des lignes non sauvegardees.
### Retention des sauvegardes VM/CT par PBS et namespace
Les tableaux de retention sont regroupes sous un titre de niveau 1 `Retention des sauvegardes VM/CT`.
Les donnees de retention sont affichees dans plusieurs tableaux, separes par serveur PBS puis par namespace.
Le titre de chaque tableau indique le PBS et la namespace concernes.
Colonnes attendues :
| VMID | Nom VM/CT | Datastore | Etat PVE | Nombre de versions | Nombre attendu de versions | Delta | Plus ancienne | Plus recente | Taille |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
La colonne `Datastore` permet de distinguer deux sauvegardes appartenant a la meme namespace sur un meme PBS mais stockees dans des datastores differents.
Les colonnes `Nombre de versions`, `Plus ancienne`, `Plus recente` et `Taille` sont alimentees depuis le PBS concerne via la liste des snapshots du datastore et de la namespace concernes.
La colonne `Nombre attendu de versions` est calculee depuis la politique de retention active du meme serveur PBS, datastore et namespace, en additionnant les valeurs `keep-last`, `keep-hourly`, `keep-daily`, `keep-weekly`, `keep-monthly` et `keep-yearly` renseignees.
La colonne `Delta` affiche l'ecart `Nombre de versions - Nombre attendu de versions` avec un signe `+` ou `-` sauf lorsque l'ecart vaut zero. Si aucune politique active correspondante n'est trouvee, les colonnes `Nombre attendu de versions` et `Delta` affichent `non renseigne`.
La colonne `Taille` correspond a la taille de la sauvegarde la plus recente visible sur le PBS concerne.
La colonne `Etat PVE` indique si la VM/CT existe encore dans l'inventaire PVE (`Active sur PVE`) ou si elle n'y est plus presente (`Non-active sur PVE`).
Chaque serveur PBS peut disposer de plusieurs tableaux :
- `Retention des sauvegardes VM/CT <serveur PBS> - <namespace>`.
La namespace n'est pas affichee comme colonne dans ces tableaux, car elle est deja portee par le titre du tableau.
Si le garbage collector du PBS/datastore concerne est en cours au moment de la generation du rapport, un avertissement est affiche juste sous le titre du tableau concerne. Cet avertissement precise que le nombre de versions des sauvegardes des VM/CT peut apparaitre superieur au nombre de versions declarees.
Chaque tableau ne liste que les VM/CT pour lesquelles au moins un snapshot correspondant est visible sur le PBS concerne.
Les VM/CT encore visibles sur PBS mais absentes de l'inventaire PVE restent affichees dans le tableau et sont marquees `Non-active sur PVE`.
Les snapshots dont le namespace n'existe pas dans les namespaces declares sur PVE sont exclus de ces tableaux.
Les tableaux de retention sont classes par namespace, puis par nom de VM/CT et VMID lorsque l'information est disponible.
### Politique de retention
Le tableau `Politique de retention` est alimente depuis les PBS configures lorsque leurs variables `*_API_URL`, `*_API_TOKEN_ID` et `*_API_TOKEN_SECRET` sont renseignees.
La collecte lit les prune jobs PBS via `/config/prune` et affiche :
- serveur PBS ;
- datastore ;
- namespace ;
- planification ;
- etat actif/inactif ;
- colonnes `Derniere`, `Horaire`, `Jour`, `Semaine`, `Mois`, `Annee` ;
- profondeur `max-depth`.
Une namespace absente dans PBS est affichee comme `/`, c'est-a-dire la namespace racine.
### VM/CT sans sauvegarde
Cette section doit reprendre uniquement les charges non couvertes.
Elle doit etre courte et directement exploitable.
### Anomalies
Cette section liste les problemes de collecte ou d'interpretation.
Elle peut etre vide, mais doit alors indiquer qu'aucune anomalie n'a ete detectee.
## Lisibilite
Le rapport doit rester lisible en impression noir et blanc.
Ne pas dependre uniquement de la couleur pour signaler un risque.
Utiliser des libelles explicites :
- `Sauvegarde planifiee` ;
- `Non sauvegardee` ;
- `Indetermine`.
## Horodatage
Toutes les dates affichees doivent utiliser le fuseau configure.
Le fuseau horaire doit apparaitre dans le rapport.
## Conservation
Le rapport quotidien doit etre conserve dans un repertoire persistant.
La politique de retention des rapports generes est distincte des politiques de retention PBS et doit etre documentee dans l'exploitation si elle est ajoutee.
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pve-backup-report"
version = "1.0.0"
description = "Rapport quotidien des sauvegardes Proxmox VE vers Proxmox Backup Server"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.11"
dependencies = [
"python-dotenv>=1.0,<2.0",
"requests>=2.32,<3.0",
"reportlab>=4.2,<5.0",
"jinja2>=3.1,<4.0",
"weasyprint>=62,<63",
"pydyf>=0.10,<0.11",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9.0",
]
[project.scripts]
pve-backup-report = "pve_backup_report.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
pve_backup_report = ["templates/*.j2", "templates/*.css"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+6
View File
@@ -0,0 +1,6 @@
python-dotenv>=1.0,<2.0
requests>=2.32,<3.0
reportlab>=4.2,<5.0
jinja2>=3.1,<4.0
weasyprint>=62,<63
pydyf>=0.10,<0.11
+3
View File
@@ -0,0 +1,3 @@
"""Package principal de PVE Backup Report."""
__version__ = "1.0.0"
+5
View File
@@ -0,0 +1,5 @@
from pve_backup_report.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+612
View File
@@ -0,0 +1,612 @@
from __future__ import annotations
import argparse
import json
import logging
import tempfile
from collections.abc import Sequence
from pathlib import Path
from pve_backup_report import __version__
from pve_backup_report.collectors import collect_report_data
from pve_backup_report.collectors import collect_backup_task_candidates, recent_unique_vzdump_tasks, task_log_line
from pve_backup_report.collectors import (
collect_pbs_access_users,
collect_pbs_storages,
normalize_pbs_datastore_usage,
)
from pve_backup_report.config import ConfigError, load_config
from pve_backup_report.coverage import (
STATUS_DISABLED_PBS,
STATUS_INDETERMINATE,
STATUS_MISSING,
STATUS_NON_PBS_PLANNED,
STATUS_PBS_PLANNED,
analyze_backup_coverage,
)
from pve_backup_report.logging_config import configure_logging
from pve_backup_report.pbs_client import PbsApiError, PbsClient
from pve_backup_report.pve_client import PveApiError, PveClient
from pve_backup_report.report_data import prepare_report_data, report_data_to_dict
from pve_backup_report.report_pdf import format_size
from pve_backup_report.report_weasy_pdf import render_pdf
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pve-backup-report",
description="Prepare le rapport de sauvegarde Proxmox VE.",
)
parser.add_argument(
"--check-config",
action="store_true",
help="valide la configuration puis quitte",
)
parser.add_argument(
"--check-api",
action="store_true",
help="teste les endpoints PVE /nodes, /storage et /cluster/backup puis quitte",
)
parser.add_argument(
"--dump-inventory",
action="store_true",
help="affiche les stockages PBS et jobs de sauvegarde normalises puis quitte",
)
parser.add_argument(
"--dump-coverage",
action="store_true",
help=(
"affiche la couverture VM/CT basee sur vmid, all=1, pools "
"et le type de storage puis quitte"
),
)
parser.add_argument(
"--dump-report-data",
action="store_true",
help="affiche en JSON les donnees structurees destinees au futur PDF puis quitte",
)
parser.add_argument(
"--dump-pbs-storage-usages",
action="store_true",
help="interroge directement les PBS et affiche l'espace total, consomme et libre des datastores",
)
parser.add_argument(
"--dump-pbs-users",
action="store_true",
help="affiche les utilisateurs PBS configures sur les stockages PVE et leurs permissions effectives",
)
parser.add_argument(
"--generate-pdf",
action="store_true",
help="genere un rapport PDF horodate puis quitte",
)
parser.add_argument(
"--debug-last-backup-vmid",
type=int,
metavar="VMID",
help="affiche les taches/logs vzdump recentes mentionnant un VMID puis quitte",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
return parser
def run(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
config = load_config()
except ConfigError as exc:
configure_logging()
logger.error("Configuration invalide: %s", exc)
return 1
configure_logging(config.log_level)
logger.info("Demarrage de PVE Backup Report %s", __version__)
logger.info("Cible PVE configuree: %s", config.pve_api_url)
for pbs_server in config.configured_pbs_servers:
logger.info("Cible %s configuree: %s", pbs_server.name, pbs_server.api_url)
if config.pve_verify_tls is False:
logger.warning("Verification TLS desactivee par PVE_VERIFY_TLS=false")
if config.pve_ca_bundle is not None:
logger.warning("PVE_CA_BUNDLE est renseigne mais ignore car TLS est desactive")
if args.check_config:
logger.info("Configuration valide")
return 0
if args.check_api:
client = PveClient(config)
try:
results = client.check_required_endpoints()
except PveApiError as exc:
logger.error("Verification API PVE interrompue: %s", exc)
return 3
finally:
client.close()
has_error = False
for result in results:
if result.ok and result.count is None:
logger.info("%s: OK", result.endpoint)
elif result.ok:
if result.detail:
logger.info(
"%s: OK, %s element(s), %s",
result.endpoint,
result.count,
result.detail,
)
else:
logger.info("%s: OK, %s element(s)", result.endpoint, result.count)
else:
has_error = True
logger.error("%s: ECHEC - %s", result.endpoint, result.error)
if result.error and "Sys.Audit" in result.error:
logger.error(
"Action requise: verifier les droits effectifs du token "
"PVE, notamment la separation de privileges et Sys.Audit sur /"
)
return 3 if has_error else 0
if args.dump_inventory:
report_data = collect_data_or_log_error(config, "inventaire")
if report_data is None:
return 3
log_inventory(report_data)
return 0 if not any(issue.severity == "error" for issue in report_data.issues) else 5
if args.dump_coverage:
report_data = collect_data_or_log_error(config, "couverture")
if report_data is None:
return 3
report_data = analyze_backup_coverage(report_data)
log_coverage(report_data)
return 0 if not any(issue.severity == "error" for issue in report_data.issues) else 5
if args.dump_report_data:
report_data = collect_data_or_log_error(config, "donnees rapport")
if report_data is None:
return 3
report_data = prepare_report_data(report_data, config)
print(json.dumps(report_data_to_dict(report_data), indent=2, ensure_ascii=False))
return 0 if not any(issue.severity == "error" for issue in report_data.issues) else 5
if args.dump_pbs_storage_usages:
return dump_pbs_storage_usages(config)
if args.dump_pbs_users:
return dump_pbs_users(config)
if args.generate_pdf:
try:
report_output_dir = ensure_report_output_dir_writable(config.report_output_dir)
except OSError as exc:
logger.error("Generation PDF echouee: %s", exc)
return 4
report_data = collect_data_or_log_error(config, "generation PDF")
if report_data is None:
return 3
report_data = prepare_report_data(report_data, config)
try:
pdf_path = render_pdf(
report_data,
report_output_dir,
config.report_filename_prefix,
config.pbs_hostnames,
)
except (OSError, RuntimeError) as exc:
logger.error("Generation PDF echouee: %s", exc)
return 4
logger.info("Rapport PDF genere: %s", pdf_path)
return 0 if not any(issue.severity == "error" for issue in report_data.issues) else 5
if args.debug_last_backup_vmid is not None:
return debug_last_backup_vmid(config, args.debug_last_backup_vmid)
logger.info("Squelette initialise: collecte PVE et generation PDF non implementees")
return 0
def collect_data_or_log_error(config, label: str):
client = PveClient(config)
pbs_clients = configured_pbs_clients(config)
try:
return collect_report_data(client, pbs_clients)
except PveApiError as exc:
logger.error("Collecte %s echouee: %s", label, exc)
return None
finally:
client.close()
for pbs_client in pbs_clients:
pbs_client.close()
def configured_pbs_clients(config) -> list[PbsClient]:
return [
PbsClient(config, server=pbs_server)
for pbs_server in config.configured_pbs_servers
]
def ensure_report_output_dir_writable(path: Path) -> Path:
try:
ensure_writable_directory(path)
except OSError as exc:
if path != Path("/reports"):
raise
fallback_path = Path("reports")
try:
ensure_writable_directory(fallback_path)
except OSError:
raise OSError(report_output_dir_error_message(path, exc)) from exc
logger.warning(
"REPORT_OUTPUT_DIR=/reports inaccessible hors Docker; "
"utilisation du repertoire local %s",
fallback_path,
)
return fallback_path
return path
def ensure_writable_directory(path: Path) -> None:
if path.exists() and not path.is_dir():
raise OSError(f"{path}: REPORT_OUTPUT_DIR doit pointer vers un repertoire")
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise OSError(report_output_dir_error_message(path, exc)) from exc
try:
with tempfile.NamedTemporaryFile(
dir=path,
prefix=".pve-backup-report-write-test-",
delete=True,
):
pass
except OSError as exc:
raise OSError(report_output_dir_error_message(path, exc)) from exc
def report_output_dir_error_message(path: Path, exc: OSError) -> str:
details = f"{path}: impossible d'ecrire dans REPORT_OUTPUT_DIR: {exc}"
if path == Path("/reports"):
return (
f"{details}. La valeur /reports est prevue pour le conteneur Docker "
"avec le volume ./reports:/reports. En execution locale, le fallback "
"automatique vers reports/ a aussi echoue; verifier les droits du "
"repertoire courant ou configurer REPORT_OUTPUT_DIR avec un chemin "
"accessible."
)
return details
def dump_pbs_storage_usages(config) -> int:
pbs_clients = configured_pbs_clients(config)
if not pbs_clients:
logger.error("Aucun PBS configure")
return 1
has_error = False
try:
for pbs_client in pbs_clients:
logger.info("Interrogation PBS: %s (%s)", pbs_client.server_name, pbs_client.api_url)
try:
raw_datastores = pbs_client.get_datastores()
except PbsApiError as exc:
has_error = True
logger.error("%s /config/datastore: ECHEC - %s", pbs_client.server_name, exc)
continue
if not isinstance(raw_datastores, list):
has_error = True
logger.error("%s /config/datastore: reponse inattendue", pbs_client.server_name)
continue
logger.info("%s /config/datastore: OK, %s datastore(s)", pbs_client.server_name, len(raw_datastores))
if not raw_datastores:
logger.warning("%s: aucun datastore retourne par /config/datastore", pbs_client.server_name)
continue
for raw_datastore in raw_datastores:
if not isinstance(raw_datastore, dict):
logger.warning("%s: datastore ignore, format invalide", pbs_client.server_name)
continue
datastore = datastore_name_from_raw(raw_datastore)
if datastore is None:
logger.warning("%s: datastore ignore, nom absent", pbs_client.server_name)
continue
try:
raw_status = pbs_client.get_datastore_status(datastore)
except PbsApiError as exc:
has_error = True
logger.error(
"%s /admin/datastore/%s/status: ECHEC - %s",
pbs_client.server_name,
datastore,
exc,
)
continue
if not isinstance(raw_status, dict):
has_error = True
logger.error(
"%s /admin/datastore/%s/status: reponse inattendue",
pbs_client.server_name,
datastore,
)
continue
usage = normalize_pbs_datastore_usage(pbs_client.server_name, datastore, raw_status)
logger.info(
"%s datastore=%s total=%s consomme=%s libre=%s",
usage.server_name,
usage.datastore,
display_value(format_size(usage.total_bytes)),
display_value(format_size(usage.used_bytes)),
display_value(format_size(usage.available_bytes)),
)
finally:
for pbs_client in pbs_clients:
pbs_client.close()
return 5 if has_error else 0
def dump_pbs_users(config) -> int:
client = PveClient(config)
pbs_clients = configured_pbs_clients(config)
if not pbs_clients:
logger.error("Aucun PBS configure")
client.close()
return 1
issues = []
try:
try:
pbs_storages = collect_pbs_storages(client, issues)
except PveApiError as exc:
logger.error("Collecte des stockages PBS PVE echouee: %s", exc)
return 3
users = collect_pbs_access_users(pbs_clients, pbs_storages, issues)
logger.info("Utilisateurs PBS utilises par PVE: %s", len(users))
for user in users:
logger.info(
"- %s: auth-id=%s, storage=%s, datastore=%s, namespace=%s, enabled=%s, expire=%s, email=%s, permissions=%s, commentaire=%s",
user.server_name,
user.auth_id,
user.storage_id,
display_value(user.datastore),
display_value(user.namespace),
display_bool(user.enabled),
display_value(user.expire),
display_value(user.email),
display_permissions(user.permissions),
display_value(user.comment),
)
for issue in issues:
logger.warning(
"- %s/%s: %s%s",
issue.severity,
issue.component,
issue.message,
f" ({issue.details})" if issue.details else "",
)
return 0 if not any(issue.severity == "error" for issue in issues) else 5
finally:
client.close()
for pbs_client in pbs_clients:
pbs_client.close()
def datastore_name_from_raw(raw_datastore: dict[str, object]) -> str | None:
for key in ("name", "store", "datastore"):
value = raw_datastore.get(key)
if value is not None and str(value).strip():
return str(value).strip()
return None
def debug_last_backup_vmid(config, vmid: int) -> int:
client = PveClient(config)
try:
try:
guests = collect_report_data(client).guests
except PveApiError as exc:
logger.error("Collecte VM/CT echouee: %s", exc)
return 3
issues = []
tasks = recent_unique_vzdump_tasks(
collect_backup_task_candidates(client, guests, issues)
)
logger.info("Taches vzdump inspectees: %s", len(tasks))
found = False
for task in tasks[: config.pve_task_history_limit]:
node = task.get("node")
upid = task.get("upid")
if not node or not upid:
continue
try:
raw_log = client.get_task_log(str(node), str(upid))
except PveApiError as exc:
logger.warning("Log indisponible pour %s: %s", upid, exc)
continue
lines = []
if isinstance(raw_log, list):
lines = [line for line in (task_log_line(entry) for entry in raw_log) if line]
matching_lines = [line for line in lines if str(vmid) in line]
if not matching_lines:
continue
found = True
logger.info(
"Tache trouvee node=%s upid=%s status=%s start=%s end=%s",
node,
upid,
task.get("status"),
task.get("starttime"),
task.get("endtime"),
)
for line in matching_lines[:20]:
logger.info("LOG %s", line)
if not found:
logger.warning("Aucune ligne de log recente ne mentionne le VMID %s", vmid)
if issues:
for issue in issues:
logger.warning("%s/%s: %s", issue.severity, issue.component, issue.message)
return 0
finally:
client.close()
def log_inventory(report_data) -> None:
logger.info("Storages PBS: %s", len(report_data.pbs_storages))
for storage in report_data.pbs_storages:
logger.info(
"- %s: user=%s, server=%s, datastore=%s, namespace=%s, enabled=%s",
storage.storage_id,
display_value(storage.username),
display_value(storage.server),
display_value(storage.datastore),
display_value(storage.namespace),
display_bool(storage.enabled),
)
logger.info("Jobs backup: %s", len(report_data.backup_jobs))
for job in report_data.backup_jobs:
logger.info(
"- %s: storage=%s, schedule=%s, enabled=%s, mode=%s, selection=%s, exclude=%s",
job.job_id,
display_value(job.storage),
display_value(job.schedule),
display_bool(job.enabled),
display_value(job.mode),
display_value(job.selection),
display_value(job.excluded),
)
logger.info("Pools: %s", len(report_data.pools))
for pool in report_data.pools:
vmids = ", ".join(str(vmid) for vmid in sorted(pool.vmids))
logger.info(
"- %s: vmids=%s",
pool.pool_id,
vmids or "aucun membre VM/CT",
)
if report_data.issues:
logger.info("Anomalies collecte: %s", len(report_data.issues))
for issue in report_data.issues:
logger.warning(
"- %s/%s: %s%s",
issue.severity,
issue.component,
issue.message,
f" ({issue.details})" if issue.details else "",
)
def log_coverage(report_data) -> None:
pbs_planned = [item for item in report_data.coverage if item.status == STATUS_PBS_PLANNED]
non_pbs_planned = [
item for item in report_data.coverage if item.status == STATUS_NON_PBS_PLANNED
]
disabled_pbs = [item for item in report_data.coverage if item.status == STATUS_DISABLED_PBS]
missing = [item for item in report_data.coverage if item.status == STATUS_MISSING]
indeterminate = [
item for item in report_data.coverage if item.status == STATUS_INDETERMINATE
]
logger.info("VM/CT inventories: %s", len(report_data.guests))
logger.info("Sauvegardes PBS planifiees: %s", len(pbs_planned))
logger.info("Sauvegardes non PBS planifiees: %s", len(non_pbs_planned))
logger.info("Sauvegardes vers PBS desactive: %s", len(disabled_pbs))
logger.info("Non sauvegardees: %s", len(missing))
logger.info("Indeterminees: %s", len(indeterminate))
if disabled_pbs:
logger.info("VM/CT avec sauvegarde vers PBS desactive:")
for item in disabled_pbs:
log_coverage_item(item)
if non_pbs_planned:
logger.info("VM/CT sauvegardees vers storage non PBS:")
for item in non_pbs_planned:
log_coverage_item(item)
if indeterminate:
logger.info("VM/CT indeterminees:")
for item in indeterminate:
log_coverage_item(item)
if missing:
logger.info("VM/CT non sauvegardees:")
for item in missing:
log_coverage_item(item)
if pbs_planned:
logger.info("VM/CT sauvegardees vers PBS actif:")
for item in pbs_planned:
log_coverage_item(item)
def log_coverage_item(item) -> None:
guest = item.guest
jobs = ", ".join(job.job_id for job in item.jobs) if item.jobs else "non renseigne"
storages = ", ".join(item.storages) if item.storages else "non renseigne"
details = [
f"type={guest.guest_type}",
f"noeud={display_value(guest.node)}",
f"etat={display_value(guest.status)}",
f"couverture={item.status}",
f"stockage={storages}",
f"jobs={jobs}",
]
if item.reason:
details.append(f"detail={item.reason}")
logger.info(
"- %s %s %s",
guest.vmid,
guest.name,
" ".join(details),
)
def display_value(value: object | None) -> str:
if value is None or value == "":
return "non renseigne"
return str(value)
def display_bool(value: bool | None) -> str:
if value is None:
return "non renseigne"
return "oui" if value else "non"
def display_permissions(permissions: dict[str, bool]) -> str:
enabled_permissions = sorted(
permission for permission, enabled in permissions.items() if enabled
)
return ", ".join(enabled_permissions) if enabled_permissions else "non renseigne"
def main(argv: Sequence[str] | None = None) -> int:
return run(argv)
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
class ConfigError(ValueError):
"""Erreur de configuration runtime."""
@dataclass(frozen=True)
class PbsServerConfig:
prefix: str
name: str
api_url: str | None
api_token_id: str | None
api_token_secret: str | None
verify_tls: bool
ca_bundle: Path | None
timeout_seconds: int
@property
def configured(self) -> bool:
return (
self.api_url is not None
and self.api_token_id is not None
and self.api_token_secret is not None
)
@dataclass(frozen=True)
class AppConfig:
pve_api_url: str
pve_api_token_id: str
pve_api_token_secret: str
report_output_dir: Path
report_timezone: str
pve_verify_tls: bool
pve_ca_bundle: Path | None
pve_timeout_seconds: int
pve_backup_jobs_endpoint: str
pve_task_history_limit: int
pve_task_log_limit: int
pbs_hostnames: dict[str, str]
pbs_servers: tuple[PbsServerConfig, ...]
log_level: str
report_filename_prefix: str
@property
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
return tuple(server for server in self.pbs_servers if server.configured)
def load_config(env_file: str | Path | None = ".env") -> AppConfig:
load_env_file(env_file)
pve_verify_tls = parse_bool(os.getenv("PVE_VERIFY_TLS", "true"), "PVE_VERIFY_TLS")
pve_timeout_seconds = parse_int(
os.getenv("PVE_TIMEOUT_SECONDS", "30"),
"PVE_TIMEOUT_SECONDS",
)
config = AppConfig(
pve_api_url=require_env("PVE_API_URL"),
pve_api_token_id=require_env("PVE_API_TOKEN_ID"),
pve_api_token_secret=require_env("PVE_API_TOKEN_SECRET"),
report_output_dir=Path(os.getenv("REPORT_OUTPUT_DIR", "reports")),
report_timezone=os.getenv("REPORT_TIMEZONE", "Europe/Paris"),
pve_verify_tls=pve_verify_tls,
pve_ca_bundle=parse_optional_path(os.getenv("PVE_CA_BUNDLE")),
pve_timeout_seconds=pve_timeout_seconds,
pve_backup_jobs_endpoint=parse_endpoint(
os.getenv("PVE_BACKUP_JOBS_ENDPOINT", "/cluster/backup"),
"PVE_BACKUP_JOBS_ENDPOINT",
),
pve_task_history_limit=parse_int(
os.getenv("PVE_TASK_HISTORY_LIMIT", "500"),
"PVE_TASK_HISTORY_LIMIT",
),
pve_task_log_limit=parse_int(
os.getenv("PVE_TASK_LOG_LIMIT", "5000"),
"PVE_TASK_LOG_LIMIT",
),
pbs_hostnames=parse_mapping(os.getenv("PBS_HOSTNAMES", ""), "PBS_HOSTNAMES"),
pbs_servers=parse_pbs_servers(
os.environ,
pve_verify_tls=pve_verify_tls,
pve_timeout_seconds=pve_timeout_seconds,
),
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
report_filename_prefix=os.getenv(
"REPORT_FILENAME_PREFIX",
"rapport-sauvegardes-pve",
),
)
return config
PBS_SERVER_ENV_PATTERN = re.compile(r"^PBS(\d+)_([A-Z0-9_]+)$")
PBS_SERVER_REQUIRED_KEYS = ("API_URL", "API_TOKEN_ID", "API_TOKEN_SECRET")
PBS_SERVER_KNOWN_KEYS = {
"NAME",
"API_URL",
"API_TOKEN_ID",
"API_TOKEN_SECRET",
"VERIFY_TLS",
"CA_BUNDLE",
"TIMEOUT_SECONDS",
}
def parse_pbs_servers(
environ: Mapping[str, str],
*,
pve_verify_tls: bool,
pve_timeout_seconds: int,
) -> tuple[PbsServerConfig, ...]:
prefixes = sorted(
{
match.group(1)
for key in environ
if (match := PBS_SERVER_ENV_PATTERN.match(key))
and match.group(2) in PBS_SERVER_KNOWN_KEYS
},
key=lambda value: (int(value), value),
)
servers = []
for number in prefixes:
prefix = f"PBS{number}"
values = {
key: parse_optional_string(environ.get(f"{prefix}_{key}"))
for key in PBS_SERVER_REQUIRED_KEYS
}
validate_optional_group(
prefix,
{f"{prefix}_{key}": value for key, value in values.items()},
)
servers.append(
PbsServerConfig(
prefix=prefix,
name=(environ.get(f"{prefix}_NAME", prefix).strip() or prefix),
api_url=values["API_URL"],
api_token_id=values["API_TOKEN_ID"],
api_token_secret=values["API_TOKEN_SECRET"],
verify_tls=parse_bool(
environ.get(f"{prefix}_VERIFY_TLS", str(pve_verify_tls)),
f"{prefix}_VERIFY_TLS",
),
ca_bundle=parse_optional_path(environ.get(f"{prefix}_CA_BUNDLE")),
timeout_seconds=parse_int(
environ.get(f"{prefix}_TIMEOUT_SECONDS", str(pve_timeout_seconds)),
f"{prefix}_TIMEOUT_SECONDS",
),
)
)
return tuple(servers)
def load_env_file(env_file: str | Path | None) -> None:
if env_file is None:
return
path = Path(env_file)
if not path.exists():
return
try:
from dotenv import load_dotenv
except ImportError:
load_env_file_fallback(path)
return
load_dotenv(path)
def load_env_file_fallback(path: Path) -> None:
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def require_env(name: str) -> str:
value = os.getenv(name)
if value is None or value.strip() == "":
raise ConfigError(f"variable obligatoire absente: {name}")
return value.strip()
def parse_bool(value: str, name: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "y", "on"}:
return True
if normalized in {"0", "false", "no", "n", "off"}:
return False
raise ConfigError(f"{name} doit etre un booleen")
def parse_int(value: str, name: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise ConfigError(f"{name} doit etre un entier") from exc
if parsed <= 0:
raise ConfigError(f"{name} doit etre strictement positif")
return parsed
def parse_optional_path(value: str | None) -> Path | None:
if value is None or value.strip() == "":
return None
return Path(value.strip())
def parse_optional_string(value: str | None) -> str | None:
if value is None or value.strip() == "":
return None
return value.strip()
def validate_optional_group(name: str, values: dict[str, object | None]) -> None:
configured = {key for key, value in values.items() if value is not None}
if not configured or configured == set(values):
return
if configured == {f"{name}_API_URL"}:
return
missing = sorted(set(values) - configured)
raise ConfigError(
f"configuration {name} incomplete: variables manquantes {', '.join(missing)}"
)
def parse_endpoint(value: str, name: str) -> str:
stripped = value.strip()
if not stripped:
raise ConfigError(f"{name} ne doit pas etre vide")
if not stripped.startswith("/"):
raise ConfigError(f"{name} doit commencer par /")
return stripped
def parse_mapping(value: str, name: str) -> dict[str, str]:
mapping: dict[str, str] = {}
stripped = value.strip()
if not stripped:
return mapping
for item in stripped.split(","):
pair = item.strip()
if not pair:
continue
if "=" not in pair:
raise ConfigError(f"{name} doit utiliser le format cle=valeur")
key, mapped_value = pair.split("=", 1)
key = key.strip()
mapped_value = mapped_value.strip()
if not key or not mapped_value:
raise ConfigError(f"{name} contient une entree vide")
mapping[key] = mapped_value
return mapping
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from pve_backup_report.models import BackupCoverage, BackupJob, Guest, PbsStorage, Pool, ReportData
STATUS_PBS_PLANNED = "sauvegarde_pbs_planifiee"
STATUS_NON_PBS_PLANNED = "sauvegarde_non_pbs_planifiee"
STATUS_DISABLED_PBS = "sauvegarde_pbs_desactivee"
STATUS_MISSING = "non_sauvegardee"
STATUS_INDETERMINATE = "indetermine"
def analyze_backup_coverage(report_data: ReportData) -> ReportData:
coverage = calculate_backup_coverage(
guests=report_data.guests,
backup_jobs=report_data.backup_jobs,
pbs_storages=report_data.pbs_storages,
pools=report_data.pools,
)
return ReportData(
pbs_server_names=report_data.pbs_server_names,
pbs_storages=report_data.pbs_storages,
pbs_access_users=report_data.pbs_access_users,
pbs_retention_policies=report_data.pbs_retention_policies,
pbs_snapshot_summaries=report_data.pbs_snapshot_summaries,
pbs_datastore_usages=report_data.pbs_datastore_usages,
pbs_gc_statuses=report_data.pbs_gc_statuses,
guests=report_data.guests,
pools=report_data.pools,
backup_jobs=report_data.backup_jobs,
coverage=coverage,
last_backup_results=report_data.last_backup_results,
summary=report_data.summary,
issues=report_data.issues,
)
def calculate_backup_coverage(
guests: list[Guest],
backup_jobs: list[BackupJob],
pbs_storages: list[PbsStorage],
pools: list[Pool] | None = None,
) -> list[BackupCoverage]:
guests_by_vmid = {guest.vmid: guest for guest in guests}
pools_by_id = {pool.pool_id: pool for pool in pools or []}
jobs_by_vmid: dict[int, list[BackupJob]] = {}
indeterminate_by_vmid: dict[int, list[BackupJob]] = {}
for job in backup_jobs:
if not job.enabled:
continue
scope = resolve_job_scope(job, guests_by_vmid, pools_by_id)
if scope.indeterminate:
for guest in guests:
indeterminate_by_vmid.setdefault(guest.vmid, []).append(job)
continue
for vmid in scope.vmids:
jobs_by_vmid.setdefault(vmid, []).append(job)
pbs_by_id = {storage.storage_id: storage for storage in pbs_storages}
coverage: list[BackupCoverage] = []
for guest in guests:
jobs = jobs_by_vmid.get(guest.vmid, [])
if jobs:
status, reason = classify_jobs(jobs, pbs_by_id)
coverage.append(
BackupCoverage(
guest=guest,
status=status,
jobs=jobs,
reason=reason,
storages=sorted({job.storage for job in jobs if job.storage}),
)
)
elif guest.vmid in indeterminate_by_vmid:
jobs = indeterminate_by_vmid[guest.vmid]
coverage.append(
BackupCoverage(
guest=guest,
status=STATUS_INDETERMINATE,
jobs=jobs,
reason="selection de job non interpretee",
storages=sorted({job.storage for job in jobs if job.storage}),
)
)
else:
coverage.append(
BackupCoverage(
guest=guest,
status=STATUS_MISSING,
reason="aucun job actif applicable",
)
)
return coverage
def calculate_explicit_vmid_coverage(
guests: list[Guest],
backup_jobs: list[BackupJob],
) -> list[BackupCoverage]:
return calculate_backup_coverage(guests, backup_jobs, pbs_storages=[], pools=[])
class JobScope:
def __init__(self, vmids: set[int] | None = None, indeterminate: bool = False) -> None:
self.vmids = vmids or set()
self.indeterminate = indeterminate
def resolve_job_scope(
job: BackupJob,
guests_by_vmid: dict[int, Guest],
pools_by_id: dict[str, Pool],
) -> JobScope:
explicit_vmids = parse_job_vmids(job)
if explicit_vmids:
return JobScope(explicit_vmids)
source = job.selection or ""
if selection_truthy(source, "all"):
excluded = parse_job_exclusions(job)
return JobScope(set(guests_by_vmid) - excluded)
pool_id = extract_selection_value(source, "pool")
if pool_id:
pool = pools_by_id.get(pool_id)
if pool is None:
return JobScope(indeterminate=True)
excluded = parse_job_exclusions(job)
return JobScope(pool.vmids - excluded)
return JobScope()
def parse_job_vmids(job: BackupJob) -> set[int]:
source = job.selection or ""
vmid_part = extract_selection_value(source, "vmid")
included = parse_vmid_list(vmid_part)
excluded = parse_job_exclusions(job)
return included - excluded
def parse_job_exclusions(job: BackupJob) -> set[int]:
source = job.selection or ""
excluded_part = job.excluded or extract_selection_value(source, "exclude")
return parse_vmid_list(excluded_part)
def classify_jobs(
jobs: list[BackupJob],
pbs_by_id: dict[str, PbsStorage],
) -> tuple[str, str | None]:
has_active_pbs = False
has_disabled_pbs = False
has_non_pbs = False
unknown_storages: set[str] = set()
for job in jobs:
if not job.storage:
unknown_storages.add("non renseigne")
continue
pbs_storage = pbs_by_id.get(job.storage)
if pbs_storage is None:
has_non_pbs = True
continue
if pbs_storage.enabled is False:
has_disabled_pbs = True
continue
has_active_pbs = True
if has_active_pbs:
return STATUS_PBS_PLANNED, None
if has_disabled_pbs:
return STATUS_DISABLED_PBS, "storage PBS desactive"
if has_non_pbs:
return STATUS_NON_PBS_PLANNED, "job actif vers storage non PBS"
return STATUS_INDETERMINATE, "storage cible non renseigne ou inconnu"
def extract_selection_value(selection: str, key: str) -> str | None:
prefix = f"{key}="
for part in selection.split(", "):
if part.startswith(prefix):
return part.removeprefix(prefix)
return None
def selection_truthy(selection: str, key: str) -> bool:
value = extract_selection_value(selection, key)
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def parse_vmid_list(value: str | None) -> set[int]:
if not value:
return set()
vmids: set[int] = set()
for item in value.replace(";", ",").split(","):
stripped = item.strip()
if stripped.isdigit():
vmids.add(int(stripped))
return vmids
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
numeric_level = getattr(logging, level.upper(), logging.INFO)
logging.basicConfig(
level=numeric_level,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
stream=sys.stdout,
force=True,
)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass(frozen=True)
class PbsStorage:
storage_id: str
username: str | None = None
server: str | None = None
datastore: str | None = None
namespace: str | None = None
enabled: bool | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PbsRetentionPolicy:
policy_id: str
server_name: str
datastore: str | None = None
namespace: str | None = None
schedule: str | None = None
enabled: bool = True
keep_last: int | None = None
keep_hourly: int | None = None
keep_daily: int | None = None
keep_weekly: int | None = None
keep_monthly: int | None = None
keep_yearly: int | None = None
max_depth: int | None = None
comment: str | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PbsBackupSnapshotSummary:
server_name: str
vmid: int
guest_type: str
datastore: str
namespace: str
snapshot_count: int = 0
oldest_backup_at: datetime | None = None
newest_backup_at: datetime | None = None
newest_backup_size_bytes: int | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PbsDatastoreUsage:
server_name: str
datastore: str
total_bytes: int | None = None
used_bytes: int | None = None
available_bytes: int | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PbsGarbageCollectionStatus:
server_name: str
datastore: str
status: str
schedule: str | None = None
last_run_endtime: datetime | None = None
next_run: datetime | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class PbsAccessUser:
server_name: str
auth_id: str
user_id: str
storage_id: str
datastore: str | None = None
namespace: str | None = None
enabled: bool | None = None
expire: int | None = None
email: str | None = None
comment: str | None = None
permissions: dict[str, bool] = field(default_factory=dict)
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class Guest:
vmid: int
name: str
guest_type: str
node: str | None = None
status: str | None = None
notes: str | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class BackupJob:
job_id: str
storage: str | None = None
schedule: str | None = None
enabled: bool = True
mode: str | None = None
selection: str | None = None
excluded: str | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class Pool:
pool_id: str
vmids: set[int] = field(default_factory=set)
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class CollectionIssue:
severity: str
component: str
message: str
details: str | None = None
@dataclass(frozen=True)
class BackupCoverage:
guest: Guest
status: str
jobs: list[BackupJob] = field(default_factory=list)
reason: str | None = None
storages: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class LastBackupResult:
vmid: int
status: str
finished_at: datetime | None = None
duration_seconds: int | None = None
node: str | None = None
raw: dict[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class ReportSummary:
generated_at: datetime | None = None
total_vm: int = 0
total_ct: int = 0
total_guests: int = 0
pbs_storage_count: int = 0
backup_job_count: int = 0
active_backup_job_count: int = 0
inactive_backup_job_count: int = 0
pbs_planned_count: int = 0
non_pbs_planned_count: int = 0
disabled_pbs_count: int = 0
missing_count: int = 0
indeterminate_count: int = 0
issue_count: int = 0
@dataclass(frozen=True)
class ReportData:
pbs_server_names: list[str] = field(default_factory=list)
pbs_storages: list[PbsStorage] = field(default_factory=list)
pbs_access_users: list[PbsAccessUser] = field(default_factory=list)
pbs_retention_policies: list[PbsRetentionPolicy] = field(default_factory=list)
pbs_snapshot_summaries: dict[tuple[str, str, str, str, int], PbsBackupSnapshotSummary] = field(default_factory=dict)
pbs_datastore_usages: list[PbsDatastoreUsage] = field(default_factory=list)
pbs_gc_statuses: list[PbsGarbageCollectionStatus] = field(default_factory=list)
guests: list[Guest] = field(default_factory=list)
pools: list[Pool] = field(default_factory=list)
backup_jobs: list[BackupJob] = field(default_factory=list)
coverage: list[BackupCoverage] = field(default_factory=list)
last_backup_results: dict[int, LastBackupResult] = field(default_factory=dict)
summary: ReportSummary = field(default_factory=ReportSummary)
issues: list[CollectionIssue] = field(default_factory=list)
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urljoin
import requests
import urllib3
from pve_backup_report import __version__
from pve_backup_report.config import AppConfig, PbsServerConfig
from pve_backup_report.sanitization import sanitize_message
class PbsApiError(RuntimeError):
"""Erreur generique lors d'un appel a l'API PBS."""
class PbsHttpError(PbsApiError):
def __init__(self, endpoint: str, status_code: int, message: str) -> None:
super().__init__(f"{endpoint}: HTTP {status_code} - {message}")
self.endpoint = endpoint
self.status_code = status_code
self.message = message
class PbsConnectionError(PbsApiError):
pass
class PbsResponseError(PbsApiError):
pass
@dataclass
class PbsClient:
"""Client API PBS reutilisable base sur requests."""
config: AppConfig
server: PbsServerConfig | None = None
session: requests.Session = field(default_factory=requests.Session)
def __post_init__(self) -> None:
if self.server is None:
if not self.config.configured_pbs_servers:
raise PbsConnectionError("aucun PBS configure")
self.server = self.config.configured_pbs_servers[0]
self.session.headers.update(
{
"Authorization": self._authorization_header(),
"Accept": "application/json",
"User-Agent": f"pve-backup-report/{__version__}",
}
)
if self.verify_tls is False:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@property
def server_id(self) -> str:
return self._server.prefix.lower()
@property
def server_name(self) -> str:
return self._server.name
@property
def api_url(self) -> str | None:
return self._server.api_url
@property
def timeout_seconds(self) -> int:
return self._server.timeout_seconds
@property
def base_url(self) -> str:
if self.api_url is None:
raise PbsConnectionError(f"{self.server_name}_API_URL non configure")
base = self.api_url.rstrip("/")
if not base.endswith("/api2/json"):
base = f"{base}/api2/json"
return base
@property
def verify_tls(self) -> bool | str:
if not self._server.verify_tls:
return False
if self._server.ca_bundle is not None:
return str(self._server.ca_bundle)
return True
def get(self, endpoint: str, params: dict[str, object] | None = None) -> Any:
normalized_endpoint = self._normalize_endpoint(endpoint)
url = urljoin(f"{self.base_url}/", normalized_endpoint)
try:
response = self.session.get(
url,
params=params,
timeout=self.timeout_seconds,
verify=self.verify_tls,
)
except requests.exceptions.SSLError as exc:
raise PbsConnectionError(
f"{endpoint}: verification TLS echouee: {self._sanitize_exception(exc)}"
) from exc
except requests.exceptions.ConnectionError as exc:
raise PbsConnectionError(
f"{endpoint}: erreur reseau API PBS: {self._sanitize_exception(exc)}"
) from exc
except requests.Timeout as exc:
raise PbsConnectionError(
f"{endpoint}: timeout apres {self.timeout_seconds}s"
) from exc
except requests.RequestException as exc:
raise PbsConnectionError(
f"{endpoint}: erreur de connexion API PBS: {self._sanitize_exception(exc)}"
) from exc
if response.status_code >= 400:
raise PbsHttpError(
endpoint=endpoint,
status_code=response.status_code,
message=self._extract_error_message(response),
)
try:
payload = response.json()
except ValueError as exc:
raise PbsResponseError(f"{endpoint}: reponse JSON invalide") from exc
if not isinstance(payload, dict) or "data" not in payload:
raise PbsResponseError(f"{endpoint}: champ 'data' absent de la reponse")
return payload["data"]
def get_prune_jobs(self) -> Any:
return self.get("/config/prune")
def get_datastores(self) -> Any:
return self.get("/config/datastore")
def get_datastore_status(self, datastore: str) -> Any:
encoded_datastore = quote(datastore, safe="")
return self.get(f"/admin/datastore/{encoded_datastore}/status")
def get_datastore_gc_status(self, datastore: str) -> Any:
encoded_datastore = quote(datastore, safe="")
return self.get(f"/admin/datastore/{encoded_datastore}/gc")
def get_datastore_namespaces(self, datastore: str) -> Any:
encoded_datastore = quote(datastore, safe="")
return self.get(f"/admin/datastore/{encoded_datastore}/namespace")
def get_datastore_snapshots(self, datastore: str, namespace: str | None) -> Any:
encoded_datastore = quote(datastore, safe="")
params: dict[str, object] | None = None
if namespace and namespace != "/":
params = {"ns": namespace}
return self.get(f"/admin/datastore/{encoded_datastore}/snapshots", params=params)
def get_access_users(self) -> Any:
return self.get("/access/users")
def get_access_permissions(self, auth_id: str, path: str) -> Any:
return self.get("/access/permissions", params={"auth-id": auth_id, "path": path})
def close(self) -> None:
self.session.close()
def _authorization_header(self) -> str:
token_id = self._server.api_token_id
token_secret = self._server.api_token_secret
if token_id is None or token_secret is None:
raise PbsConnectionError(f"token API {self.server_name} non configure")
return f"PBSAPIToken={token_id}:{token_secret}"
@property
def _server(self) -> PbsServerConfig:
if self.server is None:
raise PbsConnectionError("aucun PBS configure")
return self.server
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
return endpoint.lstrip("/")
@staticmethod
def _extract_error_message(response: requests.Response) -> str:
try:
payload = response.json()
except ValueError:
return sanitize_message(response.reason)
if isinstance(payload, dict):
for key in ("message", "error"):
value = payload.get(key)
if isinstance(value, str) and value:
return sanitize_message(value)
data = payload.get("data")
if isinstance(data, str) and data:
return sanitize_message(data)
return sanitize_message(response.reason)
@staticmethod
def _sanitize_exception(exc: BaseException) -> str:
message = sanitize_message(exc)
return message or exc.__class__.__name__
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urljoin
import requests
import urllib3
from pve_backup_report import __version__
from pve_backup_report.config import AppConfig
from pve_backup_report.sanitization import sanitize_message
class PveApiError(RuntimeError):
"""Erreur generique lors d'un appel a l'API PVE."""
class PveHttpError(PveApiError):
def __init__(self, endpoint: str, status_code: int, message: str) -> None:
super().__init__(f"{endpoint}: HTTP {status_code} - {message}")
self.endpoint = endpoint
self.status_code = status_code
self.message = message
class PveConnectionError(PveApiError):
pass
class PveResponseError(PveApiError):
pass
@dataclass(frozen=True)
class EndpointCheckResult:
endpoint: str
ok: bool
count: int | None = None
detail: str | None = None
error: str | None = None
@dataclass
class PveClient:
"""Client API PVE reutilisable base sur requests."""
config: AppConfig
session: requests.Session = field(default_factory=requests.Session)
def __post_init__(self) -> None:
self.session.headers.update(
{
"Authorization": self._authorization_header(),
"Accept": "application/json",
"User-Agent": f"pve-backup-report/{__version__}",
}
)
if self.verify_tls is False:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@property
def base_url(self) -> str:
base = self.config.pve_api_url.rstrip("/")
if not base.endswith("/api2/json"):
base = f"{base}/api2/json"
return base
@property
def verify_tls(self) -> bool | str:
if not self.config.pve_verify_tls:
return False
if self.config.pve_ca_bundle is not None:
return str(self.config.pve_ca_bundle)
return True
def get(self, endpoint: str, params: dict[str, object] | None = None) -> Any:
normalized_endpoint = self._normalize_endpoint(endpoint)
url = urljoin(f"{self.base_url}/", normalized_endpoint)
self._validate_ca_bundle(endpoint)
try:
response = self.session.get(
url,
params=params,
timeout=self.config.pve_timeout_seconds,
verify=self.verify_tls,
)
except requests.exceptions.SSLError as exc:
raise PveConnectionError(
f"{endpoint}: verification TLS echouee: {self._sanitize_exception(exc)}"
) from exc
except requests.exceptions.ConnectionError as exc:
raise PveConnectionError(
f"{endpoint}: erreur reseau API PVE: {self._sanitize_exception(exc)}"
) from exc
except requests.Timeout as exc:
raise PveConnectionError(
f"{endpoint}: timeout apres {self.config.pve_timeout_seconds}s"
) from exc
except requests.RequestException as exc:
raise PveConnectionError(
f"{endpoint}: erreur de connexion API PVE: {self._sanitize_exception(exc)}"
) from exc
if response.status_code >= 400:
raise PveHttpError(
endpoint=endpoint,
status_code=response.status_code,
message=self._extract_error_message(response),
)
try:
payload = response.json()
except ValueError as exc:
raise PveResponseError(f"{endpoint}: reponse JSON invalide") from exc
if not isinstance(payload, dict) or "data" not in payload:
raise PveResponseError(f"{endpoint}: champ 'data' absent de la reponse")
return payload["data"]
def get_nodes(self) -> Any:
return self.get("/nodes")
def get_storages(self) -> Any:
return self.get("/storage")
def get_backup_jobs(self) -> Any:
return self.get(self.config.pve_backup_jobs_endpoint)
def get_cluster_index(self) -> Any:
return self.get("/cluster")
def get_cluster_resources(self) -> Any:
return self.get("/cluster/resources")
def get_qemu_config(self, node: str, vmid: int) -> Any:
encoded_node = quote(node, safe="")
return self.get(f"/nodes/{encoded_node}/qemu/{vmid}/config")
def get_lxc_config(self, node: str, vmid: int) -> Any:
encoded_node = quote(node, safe="")
return self.get(f"/nodes/{encoded_node}/lxc/{vmid}/config")
def get_cluster_tasks(self) -> Any:
return self.get("/cluster/tasks")
def get_node_tasks(self, node: str) -> Any:
return self.get(f"/nodes/{node}/tasks")
def get_task_log(self, node: str, upid: str) -> Any:
encoded_upid = quote(upid, safe="")
return self.get(
f"/nodes/{node}/tasks/{encoded_upid}/log",
params={"start": 0, "limit": self.config.pve_task_log_limit},
)
def get_pools(self) -> Any:
return self.get("/pools")
def get_pool(self, pool_id: str) -> Any:
return self.get(f"/pools/{pool_id}")
def check_required_endpoints(self) -> list[EndpointCheckResult]:
results: list[EndpointCheckResult] = []
endpoints = (
"/nodes",
"/storage",
"/cluster",
self.config.pve_backup_jobs_endpoint,
)
for endpoint in endpoints:
try:
data = self.get(endpoint)
except PveApiError as exc:
results.append(
EndpointCheckResult(
endpoint=endpoint,
ok=False,
error=str(exc),
)
)
continue
count = len(data) if hasattr(data, "__len__") else None
detail = self._summarize_endpoint_data(endpoint, data)
results.append(
EndpointCheckResult(
endpoint=endpoint,
ok=True,
count=count,
detail=detail,
)
)
return results
def close(self) -> None:
self.session.close()
def _authorization_header(self) -> str:
return (
"PVEAPIToken="
f"{self.config.pve_api_token_id}={self.config.pve_api_token_secret}"
)
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
return endpoint.lstrip("/")
@staticmethod
def _extract_error_message(response: requests.Response) -> str:
try:
payload = response.json()
except ValueError:
return response.reason or "erreur HTTP"
if isinstance(payload, dict):
for key in ("message", "error"):
value = payload.get(key)
if isinstance(value, str) and value:
return sanitize_message(value)
data = payload.get("data")
if isinstance(data, str) and data:
return sanitize_message(data)
return sanitize_message(response.reason or "erreur HTTP")
@staticmethod
def _summarize_endpoint_data(endpoint: str, data: Any) -> str | None:
if endpoint != "/cluster" or not isinstance(data, list):
return None
subdirs = [
item["subdir"]
for item in data
if isinstance(item, dict) and isinstance(item.get("subdir"), str)
]
if not subdirs:
return None
return "sous-endpoints: " + ", ".join(sorted(subdirs))
def _validate_ca_bundle(self, endpoint: str) -> None:
ca_bundle = self.config.pve_ca_bundle
if not self.config.pve_verify_tls or ca_bundle is None:
return
if not ca_bundle.exists():
raise PveConnectionError(
f"{endpoint}: fichier CA introuvable: {ca_bundle}"
)
if not ca_bundle.is_file():
raise PveConnectionError(
f"{endpoint}: chemin CA invalide, fichier attendu: {ca_bundle}"
)
@staticmethod
def _sanitize_exception(exc: BaseException) -> str:
message = sanitize_message(exc)
return message or exc.__class__.__name__
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
from dataclasses import asdict, replace
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pve_backup_report.config import AppConfig
from pve_backup_report.coverage import (
STATUS_DISABLED_PBS,
STATUS_INDETERMINATE,
STATUS_MISSING,
STATUS_NON_PBS_PLANNED,
STATUS_PBS_PLANNED,
analyze_backup_coverage,
)
from pve_backup_report.models import ReportData, ReportSummary
SENSITIVE_FIELD_NAMES = {
"authorization",
"fingerprint",
"files",
"password",
"raw",
"secret",
"ticket",
"token",
}
def prepare_report_data(report_data: ReportData, config: AppConfig) -> ReportData:
covered_report_data = analyze_backup_coverage(report_data)
summary = build_report_summary(covered_report_data, config)
return replace(
covered_report_data,
pbs_server_names=configured_pbs_server_names(config),
summary=summary,
)
def configured_pbs_server_names(config: AppConfig) -> list[str]:
return [server.name for server in config.configured_pbs_servers]
def build_report_summary(report_data: ReportData, config: AppConfig) -> ReportSummary:
return ReportSummary(
generated_at=current_time(config.report_timezone),
total_vm=count_guests_by_type(report_data, "qemu"),
total_ct=count_guests_by_type(report_data, "lxc"),
total_guests=len(report_data.guests),
pbs_storage_count=len(report_data.pbs_storages),
backup_job_count=len(report_data.backup_jobs),
active_backup_job_count=sum(1 for job in report_data.backup_jobs if job.enabled),
inactive_backup_job_count=sum(
1 for job in report_data.backup_jobs if not job.enabled
),
pbs_planned_count=count_coverage(report_data, STATUS_PBS_PLANNED),
non_pbs_planned_count=count_coverage(report_data, STATUS_NON_PBS_PLANNED),
disabled_pbs_count=count_coverage(report_data, STATUS_DISABLED_PBS),
missing_count=count_coverage(report_data, STATUS_MISSING),
indeterminate_count=count_coverage(report_data, STATUS_INDETERMINATE),
issue_count=len(report_data.issues),
)
def report_data_to_dict(report_data: ReportData) -> dict[str, Any]:
return {
"summary": serialize_dataclass(report_data.summary),
"pbs_server_names": report_data.pbs_server_names,
"pbs_storages": [
{
"id": storage.storage_id,
"username": storage.username,
"server": storage.server,
"datastore": storage.datastore,
"namespace": storage.namespace,
"enabled": storage.enabled,
}
for storage in report_data.pbs_storages
],
"pbs_retention_policies": [
{
"id": policy.policy_id,
"server": policy.server_name,
"datastore": policy.datastore,
"namespace": policy.namespace,
"schedule": policy.schedule,
"enabled": policy.enabled,
"keep_last": policy.keep_last,
"keep_hourly": policy.keep_hourly,
"keep_daily": policy.keep_daily,
"keep_weekly": policy.keep_weekly,
"keep_monthly": policy.keep_monthly,
"keep_yearly": policy.keep_yearly,
"max_depth": policy.max_depth,
"comment": policy.comment,
}
for policy in report_data.pbs_retention_policies
],
"pbs_access_users": [
{
"server": user.server_name,
"auth_id": user.auth_id,
"user_id": user.user_id,
"storage_id": user.storage_id,
"datastore": user.datastore,
"namespace": user.namespace,
"enabled": user.enabled,
"expire": user.expire,
"email": user.email,
"comment": user.comment,
"permissions": {
permission: enabled
for permission, enabled in sorted(user.permissions.items())
},
}
for user in report_data.pbs_access_users
],
"pbs_snapshot_summaries": [
{
"server": summary.server_name,
"vmid": summary.vmid,
"type": summary.guest_type,
"datastore": summary.datastore,
"namespace": summary.namespace,
"snapshot_count": summary.snapshot_count,
"oldest_backup_at": serialize_datetime(summary.oldest_backup_at),
"newest_backup_at": serialize_datetime(summary.newest_backup_at),
"newest_backup_size_bytes": summary.newest_backup_size_bytes,
}
for summary in report_data.pbs_snapshot_summaries.values()
],
"pbs_datastore_usages": [
serialize_dataclass_safe(usage)
for usage in report_data.pbs_datastore_usages
],
"pbs_gc_statuses": [
serialize_dataclass_safe(status)
for status in report_data.pbs_gc_statuses
],
"backup_jobs": [
{
"id": job.job_id,
"storage": job.storage,
"schedule": job.schedule,
"enabled": job.enabled,
"mode": job.mode,
"selection": job.selection,
"exclude": job.excluded,
}
for job in report_data.backup_jobs
],
"coverage": [
{
"vmid": item.guest.vmid,
"name": item.guest.name,
"notes": item.guest.notes,
"type": item.guest.guest_type,
"node": item.guest.node,
"status": item.guest.status,
"coverage": item.status,
"storages": item.storages,
"jobs": [job.job_id for job in item.jobs],
"detail": item.reason,
"last_backup": serialize_last_backup(
report_data.last_backup_results.get(item.guest.vmid)
),
}
for item in report_data.coverage
],
"issues": [serialize_dataclass_safe(issue) for issue in report_data.issues],
}
def count_guests_by_type(report_data: ReportData, guest_type: str) -> int:
return sum(1 for guest in report_data.guests if guest.guest_type == guest_type)
def count_coverage(report_data: ReportData, status: str) -> int:
return sum(1 for item in report_data.coverage if item.status == status)
def current_time(timezone: str) -> datetime:
try:
tzinfo = ZoneInfo(timezone)
except ZoneInfoNotFoundError:
tzinfo = ZoneInfo("UTC")
return datetime.now(tzinfo)
def serialize_dataclass(value: object) -> dict[str, Any]:
data = asdict(value)
for key, item in list(data.items()):
data[key] = serialize_datetime(item)
return data
def serialize_datetime(value: object) -> object:
if isinstance(value, datetime):
return value.isoformat()
return value
def serialize_dataclass_safe(value: object) -> dict[str, Any]:
return redact_sensitive_data(serialize_dataclass(value))
def redact_sensitive_data(value: Any) -> Any:
if isinstance(value, dict):
redacted = {}
for key, item in value.items():
if is_sensitive_field_name(str(key)):
continue
redacted[key] = redact_sensitive_data(item)
return redacted
if isinstance(value, list):
return [redact_sensitive_data(item) for item in value]
return value
def is_sensitive_field_name(name: str) -> bool:
normalized = name.casefold()
return any(field in normalized for field in SENSITIVE_FIELD_NAMES)
def serialize_last_backup(value: object | None) -> dict[str, Any] | None:
if value is None:
return None
return serialize_dataclass_safe(value)
File diff suppressed because it is too large Load Diff
+536
View File
@@ -0,0 +1,536 @@
from __future__ import annotations
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Any
from pve_backup_report import __version__
from pve_backup_report.models import ReportData
from pve_backup_report.report_pdf import (
backup_retention_server_names,
build_backup_retention_rows,
coverage_row,
display,
display_bool,
display_pbs_user_expire,
display_permissions,
format_datetime,
format_pbs_server,
namespaces_for_storages,
pbs_datastore_usage_row,
retention_policy_row,
sort_text,
unique_report_path,
)
TEMPLATE_PACKAGE = "pve_backup_report"
TEMPLATE_DIR = "templates"
HTML_TEMPLATE = "report.html.j2"
CSS_TEMPLATE = "report.css"
RETENTION_GC_RUNNING_WARNING = (
"Le nombre de versions des sauvegardes des VM/CT peut apparaitre superieur "
"au nombre de versions declarees car le garbage collector du PBS concerne "
"est en cours d'execution au moment de la generation du rapport."
)
@dataclass(frozen=True)
class ReportSection:
section_id: str
title: str
headers: list[str]
rows: list[list[Any]]
empty_message: str | None = None
page_break_after: bool = False
level: int = 2
warning: str | None = None
def render_pdf(
report_data: ReportData,
output_dir: Path,
filename_prefix: str = "rapport-sauvegardes-pve",
pbs_hostnames: dict[str, str] | None = None,
) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
pdf_path = unique_report_path(output_dir, filename_prefix, report_data.summary.generated_at)
html = render_html(report_data, pbs_hostnames or {})
try:
from weasyprint import HTML
except ImportError as exc:
raise RuntimeError(
"Generation PDF WeasyPrint impossible: dependance weasyprint absente"
) from exc
try:
HTML(string=html, base_url=str(Path.cwd())).write_pdf(pdf_path)
except AttributeError as exc:
if "transform" in str(exc):
raise RuntimeError(
"Generation PDF WeasyPrint impossible: version pydyf incompatible. "
"Installer les dependances du projet avec `pip install -r requirements.txt` "
"afin d'utiliser pydyf>=0.10,<0.11 avec WeasyPrint 62.x."
) from exc
raise
return pdf_path
def render_html(report_data: ReportData, pbs_hostnames: dict[str, str] | None = None) -> str:
try:
from jinja2 import Environment, PackageLoader, select_autoescape
except ImportError as exc:
raise RuntimeError(
"Generation HTML WeasyPrint impossible: dependance jinja2 absente"
) from exc
environment = Environment(
loader=PackageLoader(TEMPLATE_PACKAGE, TEMPLATE_DIR),
autoescape=select_autoescape(["html", "j2"]),
)
template = environment.get_template(HTML_TEMPLATE)
context = build_template_context(report_data, pbs_hostnames or {})
context["css"] = load_template_css()
return template.render(**context)
def build_template_context(
report_data: ReportData,
pbs_hostnames: dict[str, str] | None = None,
) -> dict[str, Any]:
generated_at = report_data.summary.generated_at
sections = build_sections(report_data, pbs_hostnames or {})
return {
"title": "Rapport des sauvegardes Proxmox VE",
"subtitle": "Synthese operationnelle et element de preuve pour audit.",
"version": __version__,
"generated_at": format_datetime(generated_at) or "non renseigne",
"sections": sections,
}
def build_sections(report_data: ReportData, pbs_hostnames: dict[str, str]) -> list[ReportSection]:
retention_sections = build_all_backup_retention_sections(report_data)
sections = [
build_summary_section(report_data),
build_pbs_storages_section(report_data),
build_pbs_access_users_section(report_data),
build_pbs_datastore_usages_section(report_data),
build_retention_policies_section(report_data),
build_backup_jobs_section(report_data),
build_missing_guests_section(report_data),
build_coverage_group_section(),
*build_coverage_sections(report_data, pbs_hostnames),
]
if retention_sections:
sections.extend([build_retention_group_section(), *retention_sections])
sections.append(build_issues_section(report_data))
return sections
def build_all_backup_retention_sections(report_data: ReportData) -> list[ReportSection]:
sections = []
for server_name in backup_retention_server_names(report_data):
sections.extend(build_backup_retention_sections(report_data, server_name))
return sections
def build_coverage_group_section() -> ReportSection:
return ReportSection(
section_id="sauvegarde-vmct",
title="Sauvegarde des VM/CT",
headers=[],
rows=[],
level=1,
)
def build_retention_group_section() -> ReportSection:
return ReportSection(
section_id="retention-sauvegardes-vmct",
title="Retention des sauvegardes VM/CT",
headers=[],
rows=[],
level=1,
)
def build_summary_section(report_data: ReportData) -> ReportSection:
summary = report_data.summary
return ReportSection(
section_id="resume",
title="Resume",
headers=["Indicateur", "Valeur"],
rows=[
["VM", summary.total_vm],
["Conteneurs LXC", summary.total_ct],
["Total VM/CT", summary.total_guests],
["Stockages PBS", summary.pbs_storage_count],
["Jobs de sauvegarde", summary.backup_job_count],
["Jobs actifs", summary.active_backup_job_count],
["Jobs inactifs", summary.inactive_backup_job_count],
["Sauvegardes PBS planifiees", summary.pbs_planned_count],
["Sauvegardes non PBS planifiees", summary.non_pbs_planned_count],
["Sauvegardes vers PBS desactive", summary.disabled_pbs_count],
["Non sauvegardees", summary.missing_count],
["Indeterminees", summary.indeterminate_count],
["Anomalies", summary.issue_count],
],
)
def build_pbs_storages_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="stockages-pbs",
title="Stockages PBS déclarés sur PVE",
headers=["ID", "Username", "Serveur PBS", "Datastore", "Namespace", "Actif"],
rows=[
[
storage.storage_id,
display(storage.username),
display(storage.server),
display(storage.datastore),
display(storage.namespace),
display_bool(storage.enabled),
]
for storage in report_data.pbs_storages
],
empty_message="Aucun stockage PBS collecte.",
)
def build_pbs_access_users_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="utilisateurs-pbs-audit-acces",
title="Utilisateurs PBS - Audit des accès",
headers=[
"Serveur PBS",
"Auth-id",
"Storage PVE",
"Datastore",
"Namespace",
"Actif",
"Expiration",
"Email",
"Permissions",
"Commentaire",
],
rows=[
[
user.server_name,
display(user.auth_id),
display(user.storage_id),
display(user.datastore),
display(user.namespace),
display_bool(user.enabled),
display_pbs_user_expire(user.expire),
display(user.email),
display_permissions(user.permissions),
display(user.comment),
]
for user in report_data.pbs_access_users
],
empty_message="Aucun utilisateur PBS collecte.",
)
def build_pbs_datastore_usages_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="espaces-stockage-pbs",
title="Espaces de stockage PBS",
headers=["Serveur PBS", "Datastore", "Espace total", "Espace consomme", "Espace libre"],
rows=[
pbs_datastore_usage_row(usage)
for usage in report_data.pbs_datastore_usages
],
empty_message="Aucun espace de stockage PBS collecte.",
)
def build_retention_policies_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="politique-retention",
title="Politique de retention",
headers=[
"Serveur PBS",
"Datastore",
"Namespace",
"Planification",
"Actif",
"Derniere",
"Horaire",
"Jour",
"Semaine",
"Mois",
"Annee",
"Profondeur",
],
rows=[
retention_policy_row(policy)
for policy in report_data.pbs_retention_policies
],
empty_message="Aucune politique de retention PBS collectee.",
)
def build_backup_jobs_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="jobs-sauvegarde",
title="Jobs de sauvegarde",
headers=["ID", "Storage", "Horaire", "Actif", "Mode", "Selection", "Exclusion"],
rows=[
[
job.job_id,
display(job.storage),
display(job.schedule),
display_bool(job.enabled),
display(job.mode),
display(job.selection),
display(job.excluded),
]
for job in report_data.backup_jobs
],
empty_message="Aucun job de sauvegarde collecte.",
page_break_after=True,
)
def build_missing_guests_section(report_data: ReportData) -> ReportSection:
missing = [item for item in report_data.coverage if item.status == "non_sauvegardee"]
return ReportSection(
section_id="vmct-non-sauvegardees",
title="VM/CT non sauvegardees",
headers=["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"],
rows=[coverage_row(item, include_storage=False) for item in missing],
empty_message="Aucune VM/CT non sauvegardee detectee.",
)
def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, str]) -> list[ReportSection]:
namespace_by_storage = {
storage.storage_id: storage.namespace for storage in report_data.pbs_storages
}
server_by_storage = {
storage.storage_id: format_pbs_server(storage.server, pbs_hostnames)
for storage in report_data.pbs_storages
}
coverage_by_namespace = {}
for item in report_data.coverage:
namespace = display(namespaces_for_storages(item.storages, namespace_by_storage))
coverage_by_namespace.setdefault(namespace, []).append(item)
if not coverage_by_namespace:
return [
ReportSection(
section_id="sauvegarde-vmct",
title="Sauvegarde des VM/CT - non renseigne",
headers=coverage_headers_without_namespace(),
rows=[],
empty_message="Aucune VM/CT collectee.",
page_break_after=True,
)
]
sections = []
sorted_namespaces = sorted(coverage_by_namespace, key=sort_text)
for namespace in sorted_namespaces:
rows = [
coverage_row_without_namespace(
item,
namespace_by_storage,
server_by_storage,
report_data,
)
for item in sorted(
coverage_by_namespace[namespace],
key=coverage_sort_key_without_namespace,
)
]
sections.append(
ReportSection(
section_id=f"sauvegarde-vmct-{section_id_fragment(namespace)}",
title=f"Sauvegarde des VM/CT - {namespace}",
headers=coverage_headers_without_namespace(),
rows=rows,
empty_message="Aucune VM/CT collectee.",
page_break_after=namespace == sorted_namespaces[-1],
)
)
return sections
def coverage_headers_without_namespace() -> list[str]:
return [
"VMID",
"Nom",
"Notes",
"Type",
"Noeud",
"Etat de la VM",
"Sauvegarde",
"Serveur PBS",
"Storage",
"Mode",
"Frequence de sauvegarde",
"Derniere sauvegarde",
]
def coverage_row_without_namespace(
item,
namespace_by_storage,
server_by_storage,
report_data: ReportData,
) -> list[Any]:
row = coverage_row(
item,
include_storage=True,
namespace_by_storage=namespace_by_storage,
server_by_storage=server_by_storage,
last_backup_by_vmid=report_data.last_backup_results,
)
return row[:9] + row[10:]
def coverage_sort_key_without_namespace(item) -> tuple[str, int]:
schedules = ", ".join(
sorted({job.schedule for job in item.jobs if job.schedule})
)
return (
sort_text(schedules),
item.guest.vmid,
)
def section_id_fragment(value: str) -> str:
allowed = []
for char in value.casefold():
if char.isalnum():
allowed.append(char)
elif char in {"-", "_", " ", "/", "."}:
allowed.append("-")
fragment = "".join(allowed).strip("-")
while "--" in fragment:
fragment = fragment.replace("--", "-")
return fragment or "non-renseigne"
def build_backup_retention_sections(report_data: ReportData, server_name: str) -> list[ReportSection]:
rows = build_backup_retention_rows(report_data, server_name)
headers = [str(value) for value in rows[0]]
data_rows = rows[1:]
headers_without_namespace = headers[:2] + headers[3:]
if not data_rows:
return [
ReportSection(
section_id=f"retention-{server_name.lower()}",
title=f"Retention des sauvegardes VM/CT {server_name} - non renseigne",
headers=headers_without_namespace,
rows=[],
empty_message=f"Aucune retention de sauvegarde VM/CT {server_name} collectee.",
page_break_after=True,
)
]
rows_by_namespace = {}
for row in data_rows:
namespace = display(row[2] if len(row) > 2 else None)
rows_by_namespace.setdefault(namespace, []).append(row)
sections = []
sorted_namespaces = sorted(rows_by_namespace, key=sort_text)
for namespace in sorted_namespaces:
namespace_rows = rows_by_namespace[namespace]
section_rows = [retention_row_without_namespace(row) for row in namespace_rows]
sections.append(
ReportSection(
section_id=f"retention-{server_name.lower()}-{section_id_fragment(namespace)}",
title=f"Retention des sauvegardes VM/CT {server_name} - {namespace}",
headers=headers_without_namespace,
rows=section_rows,
empty_message=f"Aucune retention de sauvegarde VM/CT {server_name} collectee.",
page_break_after=namespace == sorted_namespaces[-1],
warning=retention_gc_warning(report_data, server_name, namespace_rows),
)
)
return sections
def retention_row_without_namespace(row: list[Any]) -> list[Any]:
return row[:2] + row[3:]
def retention_gc_warning(
report_data: ReportData,
server_name: str,
rows: list[list[Any]],
) -> str | None:
gc_by_datastore = {
status.datastore: status
for status in report_data.pbs_gc_statuses
if status.server_name == server_name
}
for row in rows:
if len(row) < 9:
continue
datastore = retention_datastore_for_row(report_data, server_name, row)
if datastore is None:
continue
gc_status = gc_by_datastore.get(datastore)
if gc_status is not None and gc_status.status == "en_cours":
return RETENTION_GC_RUNNING_WARNING
return None
def retention_datastore_for_row(
report_data: ReportData,
server_name: str,
row: list[Any],
) -> str | None:
if len(row) > 3:
datastore = display(row[3])
if datastore != "non renseigne":
return datastore
try:
vmid = int(row[0])
except (TypeError, ValueError):
return None
namespace = display(row[2] if len(row) > 2 else None)
for summary in report_data.pbs_snapshot_summaries.values():
if (
summary.server_name == server_name
and summary.vmid == vmid
and display(summary.namespace) == namespace
):
return summary.datastore
return None
def build_issues_section(report_data: ReportData) -> ReportSection:
return ReportSection(
section_id="anomalies",
title="Anomalies",
headers=["Severite", "Composant", "Message", "Details"],
rows=[
[
issue.severity,
issue.component,
issue.message,
display(issue.details),
]
for issue in report_data.issues
],
empty_message="Aucune anomalie detectee.",
)
def load_template_css() -> str:
return (
resources.files(TEMPLATE_PACKAGE)
.joinpath(TEMPLATE_DIR, CSS_TEMPLATE)
.read_text(encoding="utf-8")
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import re
SENSITIVE_PATTERNS = (
re.compile(r"(PVEAPIToken=)[^\s]+", re.IGNORECASE),
re.compile(r"(PBSAPIToken=)[^\s]+", re.IGNORECASE),
re.compile(r"([A-Z0-9_]*TOKEN_SECRET=)[^\s,;]+", re.IGNORECASE),
re.compile(r"(password=)[^\s,;]+", re.IGNORECASE),
re.compile(r"(secret=)[^\s,;]+", re.IGNORECASE),
)
def sanitize_message(value: object) -> str:
message = str(value).replace("\n", " ").strip()
for pattern in SENSITIVE_PATTERNS:
message = pattern.sub(r"\1***", message)
return message
+347
View File
@@ -0,0 +1,347 @@
@page {
size: A3 landscape;
margin: 16mm 14mm 16mm 14mm;
@top-left {
content: "Rapport des sauvegardes Proxmox VE";
color: #1f4e79;
font-size: 9pt;
font-weight: 700;
}
@top-right {
content: "Page " counter(page) " / " counter(pages);
color: #1f4e79;
font-size: 8pt;
}
}
@page :first {
@top-left {
content: "";
}
@top-right {
content: "";
}
}
html {
color: #111827;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.5pt;
line-height: 1.35;
}
body {
margin: 0;
}
/* ── Cover page ─────────────────────────────────────────────────── */
.cover {
break-after: page;
display: flex;
flex-direction: column;
height: 265mm;
}
.cover-logo-bar {
border-bottom: 1px solid #dce6f1;
padding: 5mm 10mm;
text-align: center;
}
.cover-logo {
height: 22pt;
}
.cover-hero {
padding: 22mm 12mm 18mm 12mm;
text-align: center;
}
.cover-h1 {
color: #1f4e79;
font-size: 32pt;
line-height: 1.15;
margin: 0;
}
.cover-spacer {
flex: 1;
}
.cover-meta-bar {
background: #f4f7fa;
border-top: 3px solid #1f4e79;
display: flex;
justify-content: center;
}
.cover-meta-item {
padding: 5mm 10mm;
text-align: center;
}
.cover-meta-item:not(:last-child) {
border-right: 1px solid #dce6f1;
}
.cover-meta-label {
color: #5b6770;
display: block;
font-size: 7pt;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.cover-meta-value {
color: #1f4e79;
display: block;
font-size: 11pt;
font-weight: 700;
margin-top: 1mm;
}
/* ── Table of contents ──────────────────────────────────────────── */
.toc {
break-after: page;
}
.toc h2 {
border-bottom: 2px solid #1f4e79;
color: #1f4e79;
font-size: 15pt;
line-height: 1.2;
margin: 0 0 5mm 0;
padding-bottom: 2.5mm;
}
.toc ol {
list-style: none;
margin: 0;
padding: 0;
width: 200mm;
}
.toc li {
border-bottom: 1px dotted #c9d4e1;
margin: 0;
}
.toc a {
color: #374151;
display: block;
text-decoration: none;
}
.toc a::after {
content: target-counter(attr(href url), page);
color: #1f4e79;
float: right;
font-weight: 700;
min-width: 8mm;
text-align: right;
}
.toc-l1 > a {
color: #1f4e79;
font-size: 9.5pt;
font-weight: 700;
padding: 2.5mm 0;
}
.toc-l2 > a {
font-size: 8.5pt;
padding: 1.8mm 0 1.8mm 5mm;
}
/* ── Section headings ───────────────────────────────────────────── */
h1 {
font-size: 24pt;
line-height: 1.1;
margin: 0;
}
.section-group-title {
align-items: center;
border-bottom: 2px solid #1f4e79;
color: #1f4e79;
display: flex;
font-size: 18pt;
line-height: 1.15;
margin: 3mm 0 5mm 0;
padding-bottom: 2mm;
}
.report-section {
margin-bottom: 8mm;
}
.report-section h2 {
align-items: center;
background: #f4f7fa;
border-left: 4px solid #1f4e79;
color: #1f4e79;
display: flex;
font-size: 12pt;
line-height: 1.2;
margin: 0 0 4mm 0;
padding: 2.5mm 4mm;
}
.page-break-after {
break-after: page;
}
/* ── Section icons ──────────────────────────────────────────────── */
.section-icon {
height: 12pt;
margin-right: 5pt;
vertical-align: middle;
width: 12pt;
}
.section-icon-lg {
height: 16pt;
margin-right: 6pt;
width: 16pt;
}
/* ── KPI cards (resume section) ─────────────────────────────────── */
.kpi-grid {
display: grid;
gap: 3mm;
grid-template-columns: repeat(7, 1fr);
margin: 0 auto 4mm auto;
width: 96%;
}
.kpi-card {
background: #f4f7fa;
border: 1px solid #dce6f1;
border-left: 3px solid #1f4e79;
padding: 2.5mm 3mm 2mm 3mm;
}
.kpi-card.kpi-alert {
background: #fff7ed;
border-left-color: #c2410c;
}
.kpi-label {
color: #5b6770;
font-size: 6.5pt;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.kpi-value {
color: #1f4e79;
font-size: 13pt;
font-weight: 700;
line-height: 1.15;
margin-top: 0.5mm;
}
.kpi-card.kpi-alert .kpi-value {
color: #c2410c;
}
/* ── Tables ─────────────────────────────────────────────────────── */
table {
border-collapse: collapse;
font-size: 7.2pt;
margin-left: auto;
margin-right: auto;
table-layout: fixed;
width: 96%;
}
thead {
display: table-header-group;
}
tr {
break-inside: avoid;
}
th {
background: #1f4e79;
border: 1px solid #a8bfd4;
color: #ffffff;
font-size: 7.5pt;
font-weight: 700;
padding: 2.5mm 2.5mm;
text-align: left;
vertical-align: top;
}
td {
border: 1px solid #c9d4e1;
padding: 2.2mm 2.5mm;
vertical-align: top;
word-wrap: break-word;
}
tbody tr:nth-child(even) td {
background: #f4f8fc;
}
/* ── Cell status classes ─────────────────────────────────────────── */
td.status-active {
color: #166534;
font-weight: 700;
}
td.status-inactive {
color: #9a3412;
font-weight: 700;
}
td.status-success {
color: #166534;
}
td.status-error {
color: #9a3412;
font-weight: 700;
}
td.status-indeterminate {
color: #854d0e;
}
td.muted {
color: #9ca3af;
font-style: italic;
}
/* ── Alert / empty messages ─────────────────────────────────────── */
.empty {
background: #f4f7fa;
border-left: 4px solid #1f4e79;
color: #374151;
font-size: 8pt;
margin: 0;
padding: 3mm 4mm;
}
.warning {
background: #fff7ed;
border-left: 4px solid #c2410c;
color: #7c2d12;
font-size: 8pt;
font-weight: 700;
margin: 0 0 5mm 0;
padding: 3mm 4mm;
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
+200
View File
@@ -0,0 +1,200 @@
from pathlib import Path
from pve_backup_report.models import PbsAccessUser, PbsBackupSnapshotSummary, ReportData
from pve_backup_report.cli import (
build_parser,
configured_pbs_clients,
datastore_name_from_raw,
ensure_report_output_dir_writable,
ensure_writable_directory,
run,
)
from pve_backup_report.config import AppConfig, PbsServerConfig
def test_cli_check_config(tmp_path, monkeypatch) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"PVE_API_URL=https://pve.example.invalid:8006",
"PVE_API_TOKEN_ID=backup-report@pve!report",
"PVE_API_TOKEN_SECRET=secret",
]
),
encoding="utf-8",
)
monkeypatch.chdir(tmp_path)
assert run(["--check-config"]) == 0
def test_cli_has_dump_pbs_storage_usages() -> None:
args = build_parser().parse_args(["--dump-pbs-storage-usages"])
assert args.dump_pbs_storage_usages is True
def test_cli_has_dump_pbs_users() -> None:
args = build_parser().parse_args(["--dump-pbs-users"])
assert args.dump_pbs_users is True
def test_dump_report_data_does_not_export_sensitive_raw_fields(
monkeypatch,
capsys,
) -> None:
config = AppConfig(
pve_api_url="https://pve.example.invalid:8006",
pve_api_token_id="backup-report@pve!report",
pve_api_token_secret="secret",
report_output_dir=Path("reports"),
report_timezone="Europe/Paris",
pve_verify_tls=True,
pve_ca_bundle=None,
pve_timeout_seconds=30,
pve_backup_jobs_endpoint="/cluster/backup",
pve_task_history_limit=500,
pve_task_log_limit=5000,
pbs_hostnames={},
pbs_servers=(),
log_level="INFO",
report_filename_prefix="rapport-sauvegardes-pve",
)
report_data = ReportData(
pbs_access_users=[
PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs",
user_id="backup@pbs",
storage_id="BACKUP-PROD",
raw={
"Authorization": "PBSAPIToken=abc:secret",
"password": "secret-password",
},
)
],
pbs_snapshot_summaries={
("PBS01", "RAID5", "serveurs", "qemu", 100): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs",
snapshot_count=1,
raw={
"fingerprint": "aa:bb:cc",
"files": [{"filename": "index.json.blob"}],
"raw": {"secret": "secret-value"},
},
)
},
)
monkeypatch.setattr("pve_backup_report.cli.load_config", lambda: config)
monkeypatch.setattr(
"pve_backup_report.cli.collect_data_or_log_error",
lambda loaded_config, label: report_data,
)
assert run(["--dump-report-data"]) == 0
output = capsys.readouterr().out
assert "fingerprint" not in output
assert '"raw"' not in output
assert '"files"' not in output
assert "PBSAPIToken=abc:secret" not in output
assert "secret-password" not in output
assert "secret-value" not in output
assert '"pbs_access_users"' in output
assert '"auth_id": "backup@pbs"' in output
assert '"pbs_snapshot_summaries"' in output
assert '"snapshot_count": 1' in output
def test_datastore_name_from_raw() -> None:
assert datastore_name_from_raw({"name": "RAID5"}) == "RAID5"
assert datastore_name_from_raw({"store": "PBS2RAID5"}) == "PBS2RAID5"
assert datastore_name_from_raw({"datastore": "BACKUPSTORAGE"}) == "BACKUPSTORAGE"
assert datastore_name_from_raw({}) is None
def test_report_output_dir_must_be_directory(tmp_path) -> None:
output_file = tmp_path / "reports"
output_file.write_text("not a directory", encoding="utf-8")
try:
ensure_report_output_dir_writable(output_file)
except OSError as exc:
assert "REPORT_OUTPUT_DIR doit pointer vers un repertoire" in str(exc)
else:
raise AssertionError("OSError attendu")
def test_docker_report_output_dir_falls_back_to_local_reports(
tmp_path,
monkeypatch,
) -> None:
def fake_ensure_writable_directory(path: Path) -> None:
if path == Path("/reports"):
raise OSError("permission denied")
ensure_writable_directory(path)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
"pve_backup_report.cli.ensure_writable_directory",
fake_ensure_writable_directory,
)
assert ensure_report_output_dir_writable(Path("/reports")) == Path("reports")
assert (tmp_path / "reports").is_dir()
def test_configured_pbs_clients_uses_every_configured_server() -> None:
config = AppConfig(
pve_api_url="https://pve.example.invalid:8006",
pve_api_token_id="backup-report@pve!report",
pve_api_token_secret="secret",
report_output_dir=Path("reports"),
report_timezone="Europe/Paris",
pve_verify_tls=True,
pve_ca_bundle=None,
pve_timeout_seconds=30,
pve_backup_jobs_endpoint="/cluster/backup",
pve_task_history_limit=500,
pve_task_log_limit=5000,
pbs_hostnames={},
pbs_servers=(
PbsServerConfig(
prefix="PBS01",
name="PBS01",
api_url="https://backup-a.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret",
verify_tls=True,
ca_bundle=None,
timeout_seconds=30,
),
PbsServerConfig(
prefix="PBS04",
name="PBS04",
api_url="https://backup-d.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret4",
verify_tls=True,
ca_bundle=None,
timeout_seconds=30,
),
),
log_level="INFO",
report_filename_prefix="rapport-sauvegardes-pve",
)
clients = configured_pbs_clients(config)
try:
assert [client.server_name for client in clients] == ["PBS01", "PBS04"]
finally:
for client in clients:
client.close()
+857
View File
@@ -0,0 +1,857 @@
from pve_backup_report.collectors import (
collect_pbs_datastore_usages,
collect_pbs_access_users,
collect_pbs_gc_statuses,
collect_pbs_retention_policies,
collect_pbs_snapshot_summaries,
collect_guests,
extract_finished_backup_from_log_line,
extract_task_vmid,
guest_notes,
is_missing_pbs_snapshot_namespace,
normalize_backup_job,
normalize_guest,
normalize_last_backup_result,
normalize_pbs_storage,
normalize_pbs_retention_policy,
normalize_pbs_snapshot,
normalize_pbs_datastore_usage,
normalize_pbs_gc_status,
normalize_pbs_namespace,
normalize_pbs_access_user,
normalize_pool,
pbs_auth_user_id,
pbs_datastore_acl_path,
pbs_client_storages,
pbs_snapshot_scopes,
parse_vzdump_task_log,
merge_pbs_snapshot_summary,
task_duration_seconds,
)
from pve_backup_report.models import PbsStorage
from pve_backup_report.pbs_client import PbsHttpError
from pve_backup_report.pve_client import PveApiError
class FakePveClient:
def __init__(
self,
raw_resources: list[dict] | object,
configs: dict[tuple[str, str, int], dict] | None = None,
config_errors: dict[tuple[str, str, int], PveApiError] | None = None,
) -> None:
self._raw_resources = raw_resources
self._configs = configs or {}
self._config_errors = config_errors or {}
def get_cluster_resources(self) -> list[dict] | object:
return self._raw_resources
def get_qemu_config(self, node: str, vmid: int) -> dict:
return self._guest_config("qemu", node, vmid)
def get_lxc_config(self, node: str, vmid: int) -> dict:
return self._guest_config("lxc", node, vmid)
def _guest_config(self, guest_type: str, node: str, vmid: int) -> dict:
key = (guest_type, node, vmid)
error = self._config_errors.get(key)
if error is not None:
raise error
return self._configs.get(key, {})
class FakePbsClient:
def __init__(
self,
server_name: str,
api_url: str | None = "https://backup.example.invalid:8007",
pbs_hostnames: dict[str, str] | None = None,
raw_policies: list[dict] | None = None,
raw_datastores: list[dict] | None = None,
raw_namespaces: dict[str, list[dict]] | None = None,
raw_snapshots: dict[tuple[str, str], list[dict]] | None = None,
snapshot_errors: dict[tuple[str, str], Exception] | None = None,
raw_statuses: dict[str, dict] | None = None,
raw_gc_statuses: dict[str, dict] | None = None,
raw_users: list[dict] | None = None,
raw_permissions: dict[tuple[str, str], dict] | None = None,
) -> None:
self.server_name = server_name
self.api_url = api_url
self.config = type("FakeConfig", (), {"pbs_hostnames": pbs_hostnames or {}})()
self._raw_policies = raw_policies or []
self._raw_datastores = raw_datastores or []
self._raw_namespaces = raw_namespaces or {}
self._raw_snapshots = raw_snapshots or {}
self._snapshot_errors = snapshot_errors or {}
self._raw_statuses = raw_statuses or {}
self._raw_gc_statuses = raw_gc_statuses or {}
self._raw_users = raw_users or []
self._raw_permissions = raw_permissions or {}
def get_prune_jobs(self) -> list[dict]:
return self._raw_policies
def get_datastores(self) -> list[dict]:
return self._raw_datastores
def get_datastore_namespaces(self, datastore: str) -> list[dict]:
return self._raw_namespaces.get(datastore, [])
def get_datastore_snapshots(self, datastore: str, namespace: str | None) -> list[dict]:
error = self._snapshot_errors.get((datastore, namespace or "/"))
if error is not None:
raise error
return self._raw_snapshots.get((datastore, namespace or "/"), [])
def get_datastore_status(self, datastore: str) -> dict:
return self._raw_statuses[datastore]
def get_datastore_gc_status(self, datastore: str) -> dict:
return self._raw_gc_statuses[datastore]
def get_access_users(self) -> list[dict]:
return self._raw_users
def get_access_permissions(self, auth_id: str, path: str) -> dict:
return self._raw_permissions.get((auth_id, path), {})
def test_normalize_pbs_storage() -> None:
storage = normalize_pbs_storage(
{
"storage": "backup-storage",
"type": "pbs",
"username": "backup@pbs",
"server": "backup.example.invalid",
"datastore": "prod",
"namespace": "pve",
"disable": 0,
}
)
assert storage.storage_id == "backup-storage"
assert storage.username == "backup@pbs"
assert storage.server == "backup.example.invalid"
assert storage.datastore == "prod"
assert storage.namespace == "pve"
assert storage.enabled is True
def test_normalize_pbs_storage_defaults_to_enabled_when_disable_is_absent() -> None:
storage = normalize_pbs_storage(
{
"storage": "backup-storage",
"type": "pbs",
"server": "backup.example.invalid",
"datastore": "prod",
}
)
assert storage.enabled is True
def test_normalize_pbs_storage_marks_disabled() -> None:
storage = normalize_pbs_storage(
{
"storage": "backup-storage",
"type": "pbs",
"server": "backup.example.invalid",
"datastore": "prod",
"disable": 1,
}
)
assert storage.enabled is False
def test_normalize_pbs_access_user() -> None:
user = normalize_pbs_access_user(
"PBS01",
PbsStorage(
storage_id="backup-storage",
username="backup@pbs!pve",
datastore="RAID5",
namespace="prod",
),
{
"userid": "backup@pbs",
"enable": True,
"expire": "0",
"email": "admin@example.invalid",
"comment": "Compte PVE",
},
{"Datastore.Backup": True, "Datastore.Modify": False},
)
assert user.server_name == "PBS01"
assert user.auth_id == "backup@pbs!pve"
assert user.user_id == "backup@pbs"
assert user.storage_id == "backup-storage"
assert user.enabled is True
assert user.expire == 0
assert user.expire == 0
assert user.email == "admin@example.invalid"
assert user.comment == "Compte PVE"
assert user.permissions == {"Datastore.Backup": True, "Datastore.Modify": False}
def test_normalize_pbs_access_user_defaults_to_enabled_when_enable_is_absent() -> None:
user = normalize_pbs_access_user(
"PBS01",
PbsStorage(storage_id="backup-storage", username="backup@pbs"),
{"userid": "backup@pbs", "comment": "Compte PVE"},
)
assert user.enabled is True
def test_normalize_pbs_access_user_keeps_unknown_enabled_when_user_is_absent() -> None:
user = normalize_pbs_access_user(
"PBS01",
PbsStorage(storage_id="backup-storage", username="missing@pbs"),
{},
)
assert user.enabled is None
assert user.expire is None
def test_collect_pbs_access_users_matches_pve_storages_and_permissions() -> None:
users = collect_pbs_access_users(
[
FakePbsClient(
"PBS01",
raw_users=[
{
"userid": "backup@pbs",
"enable": True,
"email": "admin@example.invalid",
}
],
raw_permissions={
(
"backup@pbs!pve",
"/datastore/RAID5/prod",
): {
"/datastore/RAID5/prod": {
"Datastore.Backup": True,
"Datastore.Modify": False,
}
}
},
)
],
[
PbsStorage(
storage_id="backup-storage",
username="backup@pbs!pve",
server="PBS01",
datastore="RAID5",
namespace="prod",
)
],
)
assert len(users) == 1
assert users[0].server_name == "PBS01"
assert users[0].auth_id == "backup@pbs!pve"
assert users[0].user_id == "backup@pbs"
assert users[0].email == "admin@example.invalid"
assert users[0].permissions == {
"Datastore.Backup": True,
"Datastore.Modify": False,
}
def test_pbs_auth_user_id_removes_token_suffix() -> None:
assert pbs_auth_user_id("backup@pbs!pve") == "backup@pbs"
assert pbs_auth_user_id("backup@pbs") == "backup@pbs"
def test_pbs_datastore_acl_path() -> None:
assert pbs_datastore_acl_path("RAID5", None) == "/datastore/RAID5"
assert pbs_datastore_acl_path("RAID5", "/") == "/datastore/RAID5"
assert pbs_datastore_acl_path("RAID5", "prod") == "/datastore/RAID5/prod"
def test_normalize_pbs_retention_policy() -> None:
policy = normalize_pbs_retention_policy(
"PBS01",
{
"id": "prune-prod",
"store": "RAID5",
"ns": "serveurs-internes",
"schedule": "daily",
"keep-daily": "14",
"keep-weekly": 8,
"max-depth": 0,
"disable": 0,
},
)
assert policy.policy_id == "prune-prod"
assert policy.server_name == "PBS01"
assert policy.datastore == "RAID5"
assert policy.namespace == "serveurs-internes"
assert policy.keep_daily == 14
assert policy.keep_weekly == 8
assert policy.max_depth == 0
assert policy.enabled is True
def test_collect_pbs_retention_policies_supports_multiple_servers() -> None:
policies = collect_pbs_retention_policies(
[
FakePbsClient(
"PBS02",
raw_policies=[
{
"id": "prune-pbs02",
"store": "PBS2RAID5",
"ns": "serveurs-internes",
"schedule": "daily",
"keep-daily": 7,
}
],
),
FakePbsClient(
"PBS03",
raw_policies=[
{
"id": "prune-pbs03",
"store": "BACKUPSTORAGE",
"ns": "serveurs-internes",
"schedule": "weekly",
"keep-weekly": 4,
}
],
),
]
)
assert [policy.server_name for policy in policies] == ["PBS02", "PBS03"]
assert [policy.policy_id for policy in policies] == ["prune-pbs02", "prune-pbs03"]
def test_normalize_pbs_datastore_usage() -> None:
usage = normalize_pbs_datastore_usage(
"PBS01",
"RAID5",
{"total": "1000", "used": 400, "avail": 600},
)
assert usage.server_name == "PBS01"
assert usage.datastore == "RAID5"
assert usage.total_bytes == 1000
assert usage.used_bytes == 400
assert usage.available_bytes == 600
def test_collect_pbs_datastore_usages() -> None:
usages = collect_pbs_datastore_usages(
[
FakePbsClient(
"PBS01",
raw_datastores=[{"name": "RAID5"}],
raw_statuses={"RAID5": {"total": 1000, "used": 400, "avail": 600}},
)
],
[],
)
assert len(usages) == 1
assert usages[0].server_name == "PBS01"
assert usages[0].datastore == "RAID5"
assert usages[0].total_bytes == 1000
assert usages[0].used_bytes == 400
assert usages[0].available_bytes == 600
def test_collect_pbs_datastore_usages_uses_pve_storages_as_fallback() -> None:
usages = collect_pbs_datastore_usages(
[
FakePbsClient(
"PBS01",
raw_datastores=[],
raw_statuses={"RAID5": {"total": 1000, "used": 400, "avail": 600}},
)
],
[
PbsStorage(
storage_id="BACKUP-PRODR5",
server="PBS01",
datastore="RAID5",
)
],
)
assert len(usages) == 1
assert usages[0].datastore == "RAID5"
def test_pbs_client_storages_matches_hostname_mapping() -> None:
storages = pbs_client_storages(
FakePbsClient(
"PBS01",
api_url="https://backup.example.invalid:8007",
pbs_hostnames={"192.0.2.10": "backup.example.invalid"},
),
[
PbsStorage(
storage_id="BACKUP-PRODR5",
server="192.0.2.10",
datastore="RAID5",
)
],
)
assert [storage.storage_id for storage in storages] == ["BACKUP-PRODR5"]
def test_collect_pbs_datastore_usages_warns_when_no_datastore() -> None:
issues = []
usages = collect_pbs_datastore_usages(
[FakePbsClient("PBS01", raw_datastores=[])],
[],
issues,
)
assert usages == []
assert issues
assert issues[0].component == "pbs_storage_usage"
def test_normalize_pbs_gc_status_detects_running() -> None:
status = normalize_pbs_gc_status(
"PBS02",
"PBS2RAID5",
{"upid": "UPID:pbs02:gc", "schedule": "*:0/30", "next-run": 1778319000},
)
assert status.server_name == "PBS02"
assert status.datastore == "PBS2RAID5"
assert status.status == "en_cours"
assert status.schedule == "*:0/30"
assert status.next_run is not None
def test_collect_pbs_gc_statuses() -> None:
statuses = collect_pbs_gc_statuses(
[
FakePbsClient(
"PBS02",
raw_datastores=[{"name": "PBS2RAID5"}],
raw_gc_statuses={"PBS2RAID5": {"upid": "UPID:pbs02:gc"}},
)
],
[],
)
assert len(statuses) == 1
assert statuses[0].status == "en_cours"
def test_pbs_snapshot_scopes_adds_all_pve_namespaces_to_api_datastores() -> None:
scopes = pbs_snapshot_scopes(
FakePbsClient(
"PBS02",
raw_datastores=[{"name": "PBS2RAID5"}],
raw_namespaces={"PBS2RAID5": [{"ns": "sync-only"}]},
),
[
PbsStorage(storage_id="prod", namespace="serveurs-internes"),
PbsStorage(storage_id="lab", namespace="Serveurs-PVELAB"),
],
)
assert scopes == [
("PBS2RAID5", "Serveurs-PVELAB"),
("PBS2RAID5", "serveurs-internes"),
("PBS2RAID5", "sync-only"),
]
def test_collect_pbs_snapshot_summaries_reads_pve_namespaces_on_indirect_pbs() -> None:
summaries = collect_pbs_snapshot_summaries(
[
FakePbsClient(
"PBS02",
raw_datastores=[{"name": "PBS2RAID5"}],
raw_namespaces={"PBS2RAID5": []},
raw_snapshots={
("PBS2RAID5", "serveurs-internes"): [
{
"backup-type": "vm",
"backup-id": "100",
"backup-time": 1775849405,
}
],
("PBS2RAID5", "Serveurs-PVELAB"): [
{
"backup-type": "ct",
"backup-id": "200",
"backup-time": 1775849405,
}
],
},
)
],
[
PbsStorage(storage_id="prod", namespace="serveurs-internes"),
PbsStorage(storage_id="lab", namespace="Serveurs-PVELAB"),
],
)
assert set(summaries) == {
("PBS02", "PBS2RAID5", "serveurs-internes", "qemu", 100),
("PBS02", "PBS2RAID5", "Serveurs-PVELAB", "lxc", 200),
}
def test_collect_pbs_snapshot_summaries_ignores_missing_non_root_namespace() -> None:
issues = []
summaries = collect_pbs_snapshot_summaries(
[
FakePbsClient(
"PBS02",
raw_datastores=[{"name": "PBS2RAID5"}],
raw_namespaces={"PBS2RAID5": []},
snapshot_errors={
("PBS2RAID5", "absente"): PbsHttpError(
"/admin/datastore/PBS2RAID5/snapshots",
400,
"Bad Request",
)
},
)
],
[PbsStorage(storage_id="absente", namespace="absente")],
issues,
)
assert summaries == {}
assert issues == []
def test_collect_pbs_snapshot_summaries_keeps_root_bad_request_as_warning() -> None:
issues = []
summaries = collect_pbs_snapshot_summaries(
[
FakePbsClient(
"PBS02",
raw_datastores=[{"name": "PBS2RAID5"}],
raw_namespaces={"PBS2RAID5": []},
snapshot_errors={
("PBS2RAID5", "/"): PbsHttpError(
"/admin/datastore/PBS2RAID5/snapshots",
400,
"Bad Request",
)
},
)
],
[PbsStorage(storage_id="root", namespace=None)],
issues,
)
assert summaries == {}
assert len(issues) == 1
assert issues[0].component == "pbs_snapshots"
def test_is_missing_pbs_snapshot_namespace_only_matches_non_root_400() -> None:
assert is_missing_pbs_snapshot_namespace(
PbsHttpError("/admin/datastore/store/snapshots", 400, "Bad Request"),
"absente",
)
assert not is_missing_pbs_snapshot_namespace(
PbsHttpError("/admin/datastore/store/snapshots", 400, "Bad Request"),
"/",
)
assert not is_missing_pbs_snapshot_namespace(
PbsHttpError("/admin/datastore/store/snapshots", 500, "Internal Server Error"),
"absente",
)
def test_normalize_pbs_snapshot() -> None:
summary = normalize_pbs_snapshot(
"PBS01",
"RAID5",
"serveurs-internes",
{
"backup-type": "vm",
"backup-id": "1110001",
"backup-time": 1775849405,
"size": 123456789,
},
)
assert summary is not None
assert summary.server_name == "PBS01"
assert summary.vmid == 1110001
assert summary.guest_type == "qemu"
assert summary.snapshot_count == 1
assert summary.newest_backup_size_bytes == 123456789
def test_merge_pbs_snapshot_summary_keeps_newest_size() -> None:
older = normalize_pbs_snapshot(
"PBS01",
"RAID5",
"serveurs-internes",
{
"backup-type": "vm",
"backup-id": "1110001",
"backup-time": 1775849405,
"size": 100,
},
)
newer = normalize_pbs_snapshot(
"PBS01",
"RAID5",
"serveurs-internes",
{
"backup-type": "vm",
"backup-id": "1110001",
"backup-time": 1775935805,
"size": 200,
},
)
assert older is not None
assert newer is not None
merged = merge_pbs_snapshot_summary(older, newer)
assert merged.snapshot_count == 2
assert merged.newest_backup_size_bytes == 200
def test_normalize_pbs_namespace_root() -> None:
assert normalize_pbs_namespace("") == "/"
assert normalize_pbs_namespace(None) == "/"
assert normalize_pbs_namespace("progiciels") == "progiciels"
def test_normalize_backup_job() -> None:
job = normalize_backup_job(
{
"id": "backup-prod",
"storage": "backup-storage",
"schedule": "daily 02:00",
"disable": 1,
"mode": "snapshot",
"vmid": "100,101",
"exclude": "101",
}
)
assert job.job_id == "backup-prod"
assert job.storage == "backup-storage"
assert job.schedule == "daily 02:00"
assert job.enabled is False
assert job.mode == "snapshot"
assert job.selection == "vmid=100,101"
assert job.excluded == "101"
def test_normalize_guest() -> None:
guest = normalize_guest(
{
"id": "qemu/100",
"vmid": "100",
"type": "qemu",
"name": "srv-app",
"node": "pve01",
"status": "running",
}
)
assert guest is not None
assert guest.vmid == 100
assert guest.name == "srv-app"
assert guest.guest_type == "qemu"
assert guest.node == "pve01"
assert guest.status == "running"
def test_guest_notes_reads_description_and_strips_empty() -> None:
assert guest_notes({"description": " notes applicatives "}) == "notes applicatives"
assert guest_notes({"description": " "}) is None
def test_collect_guests_enriches_notes_from_vm_and_ct_config() -> None:
guests = collect_guests(
FakePveClient(
[
{"id": "qemu/100", "vmid": 100, "type": "qemu", "name": "vm", "node": "pve01"},
{"id": "lxc/200", "vmid": 200, "type": "lxc", "name": "ct", "node": "pve02"},
],
configs={
("qemu", "pve01", 100): {"description": "note VM"},
("lxc", "pve02", 200): {"description": "note CT"},
},
)
)
assert [guest.notes for guest in guests] == ["note VM", "note CT"]
def test_collect_guests_keeps_guest_when_notes_are_unavailable() -> None:
issues = []
guests = collect_guests(
FakePveClient(
[{"id": "qemu/100", "vmid": 100, "type": "qemu", "name": "vm", "node": "pve01"}],
config_errors={
("qemu", "pve01", 100): PveApiError("config indisponible"),
},
),
issues,
)
assert len(guests) == 1
assert guests[0].notes is None
assert len(issues) == 1
assert issues[0].message == "notes VM/CT indisponibles"
def test_normalize_pool_members() -> None:
pool = normalize_pool(
"prod",
{
"members": [
{"type": "qemu", "vmid": 100},
{"type": "lxc", "id": "lxc/101"},
{"type": "storage", "id": "storage/local"},
]
},
)
assert pool.pool_id == "prod"
assert pool.vmids == {100, 101}
def test_normalize_last_backup_result_success() -> None:
result = normalize_last_backup_result(
{
"type": "vzdump",
"id": "100",
"status": "OK",
"starttime": 1778119140,
"endtime": 1778119200,
"node": "pve01",
}
)
assert result is not None
assert result.vmid == 100
assert result.status == "succes"
assert result.duration_seconds == 60
assert result.node == "pve01"
def test_normalize_last_backup_result_failure() -> None:
result = normalize_last_backup_result(
{
"type": "vzdump",
"id": "100",
"status": "interrupted",
"endtime": 1778119200,
}
)
assert result is not None
assert result.status == "echec"
def test_extract_task_vmid_from_guest_id() -> None:
assert extract_task_vmid({"id": "qemu/100"}) == 100
assert extract_task_vmid({"id": "lxc/101"}) == 101
def test_extract_task_vmid_from_upid() -> None:
assert extract_task_vmid({"upid": "UPID:pve01:123:456:789:vzdump:100:root@pam:"}) == 100
def test_parse_vzdump_task_log_extracts_per_guest_results() -> None:
results = parse_vzdump_task_log(
{"type": "vzdump", "status": "OK", "endtime": 1778119200, "node": "pve01"},
[
"INFO: Starting Backup of VM 100 (qemu)",
"INFO: Finished Backup of VM 100 (00:00:10)",
"INFO: Starting Backup of CT 101 (lxc)",
"INFO: Finished Backup of CT 101 (00:00:08)",
],
)
assert {result.vmid for result in results} == {100, 101}
assert {result.status for result in results} == {"succes"}
assert {result.vmid: result.duration_seconds for result in results} == {
100: 10,
101: 8,
}
def test_parse_vzdump_task_log_extracts_failure() -> None:
results = parse_vzdump_task_log(
{"type": "vzdump", "status": "interrupted", "endtime": 1778119200, "node": "pve01"},
[
"INFO: Starting Backup of VM 100 (qemu)",
"ERROR: Backup of VM 100 failed - command failed",
],
)
assert len(results) == 1
assert results[0].vmid == 100
assert results[0].status == "echec"
def test_parse_vzdump_task_log_extracts_job_vmid_list_and_skips_external() -> None:
results = parse_vzdump_task_log(
{"type": "vzdump", "status": "OK", "endtime": 1778119200, "node": "pve02"},
[
"INFO: starting new backup job: vzdump 100 101 102 --mode snapshot --storage PBS",
"INFO: skip external VMs: 101",
],
)
assert {result.vmid for result in results} == {100, 102}
assert {result.status for result in results} == {"succes"}
def test_parse_vzdump_task_log_uses_task_duration_for_started_guest_without_finished_line() -> None:
results = parse_vzdump_task_log(
{
"type": "vzdump",
"status": "OK",
"starttime": 1778119140,
"endtime": 1778119200,
"node": "pve01",
},
[
"INFO: starting new backup job: vzdump 100 101 --mode snapshot --storage PBS",
"INFO: skip external VMs: 101",
"INFO: Starting Backup of VM 100 (qemu)",
"INFO: creating Proxmox Backup Server archive 'vm/100/2026-05-07T00:00:00Z'",
],
)
assert len(results) == 1
assert results[0].vmid == 100
assert results[0].duration_seconds == 60
def test_extract_finished_backup_from_log_line_extracts_duration() -> None:
assert extract_finished_backup_from_log_line(
"INFO: Finished Backup of VM 100 (01:02:03)"
) == (100, 3723)
def test_task_duration_seconds_from_task_times() -> None:
assert task_duration_seconds({"starttime": 1778119140, "endtime": 1778119200}) == 60
+78
View File
@@ -0,0 +1,78 @@
import os
import pytest
from pve_backup_report.config import ConfigError, load_config, parse_pbs_servers
def test_load_config_from_env_file(tmp_path, monkeypatch) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"PVE_API_URL=https://pve.example.invalid:8006",
"PVE_API_TOKEN_ID=backup-report@pve!report",
"PVE_API_TOKEN_SECRET=secret",
"PVE_VERIFY_TLS=false",
"PVE_TIMEOUT_SECONDS=10",
"PBS_HOSTNAMES=192.0.2.10=backup-a,192.0.2.11=backup-b",
]
),
encoding="utf-8",
)
monkeypatch.delenv("PVE_API_URL", raising=False)
monkeypatch.delenv("PVE_API_TOKEN_ID", raising=False)
monkeypatch.delenv("PVE_API_TOKEN_SECRET", raising=False)
for key in list(os.environ):
if key.startswith("PBS") and key != "PBS_HOSTNAMES":
monkeypatch.delenv(key, raising=False)
config = load_config(env_file)
assert config.pve_api_url == "https://pve.example.invalid:8006"
assert config.pve_verify_tls is False
assert config.pve_timeout_seconds == 10
assert config.pve_backup_jobs_endpoint == "/cluster/backup"
assert config.pve_task_history_limit == 500
assert config.pve_task_log_limit == 5000
assert config.configured_pbs_servers == ()
assert config.pbs_hostnames == {
"192.0.2.10": "backup-a",
"192.0.2.11": "backup-b",
}
def test_parse_pbs_servers_detects_unbounded_numeric_prefixes() -> None:
servers = parse_pbs_servers(
{
"PBS10_NAME": "PBS10",
"PBS10_API_URL": "https://backup-j.example.invalid:8007",
"PBS10_API_TOKEN_ID": "backup-report@pbs!report",
"PBS10_API_TOKEN_SECRET": "secret10",
"PBS02_NAME": "PBS02",
"PBS02_API_URL": "https://backup-b.example.invalid:8007",
"PBS02_API_TOKEN_ID": "backup-report@pbs!report",
"PBS02_API_TOKEN_SECRET": "secret2",
},
pve_verify_tls=True,
pve_timeout_seconds=30,
)
assert [server.prefix for server in servers] == ["PBS02", "PBS10"]
assert [server.name for server in servers] == ["PBS02", "PBS10"]
assert [server.api_url for server in servers] == [
"https://backup-b.example.invalid:8007",
"https://backup-j.example.invalid:8007",
]
def test_parse_pbs_servers_rejects_incomplete_api_block() -> None:
with pytest.raises(ConfigError, match="configuration PBS04 incomplete"):
parse_pbs_servers(
{
"PBS04_API_URL": "https://backup-d.example.invalid:8007",
"PBS04_API_TOKEN_ID": "backup-report@pbs!report",
},
pve_verify_tls=True,
pve_timeout_seconds=30,
)
+159
View File
@@ -0,0 +1,159 @@
from pve_backup_report.coverage import (
STATUS_DISABLED_PBS,
STATUS_INDETERMINATE,
STATUS_MISSING,
STATUS_NON_PBS_PLANNED,
STATUS_PBS_PLANNED,
analyze_backup_coverage,
calculate_backup_coverage,
calculate_explicit_vmid_coverage,
parse_job_vmids,
)
from pve_backup_report.models import (
BackupJob,
Guest,
PbsDatastoreUsage,
PbsGarbageCollectionStatus,
PbsStorage,
Pool,
ReportData,
)
def test_parse_job_vmids_with_exclusions() -> None:
job = BackupJob(
job_id="backup-prod",
enabled=True,
selection="vmid=100,101,102",
excluded="101",
)
assert parse_job_vmids(job) == {100, 102}
def test_calculate_explicit_vmid_coverage() -> None:
guests = [
Guest(vmid=100, name="srv-a", guest_type="qemu"),
Guest(vmid=101, name="srv-b", guest_type="lxc"),
]
jobs = [
BackupJob(
job_id="backup-prod",
enabled=True,
selection="vmid=100",
)
]
coverage = calculate_explicit_vmid_coverage(guests, jobs)
assert coverage[0].status == STATUS_INDETERMINATE
assert coverage[0].jobs[0].job_id == "backup-prod"
assert coverage[1].status == STATUS_MISSING
def test_all_job_covers_all_except_excluded() -> None:
guests = [
Guest(vmid=100, name="srv-a", guest_type="qemu"),
Guest(vmid=101, name="srv-b", guest_type="lxc"),
]
jobs = [
BackupJob(
job_id="backup-all",
storage="backup-storage",
enabled=True,
selection="all=true",
excluded="101",
)
]
storages = [PbsStorage(storage_id="backup-storage", enabled=True)]
coverage = calculate_backup_coverage(guests, jobs, storages)
assert coverage[0].status == STATUS_PBS_PLANNED
assert coverage[1].status == STATUS_MISSING
def test_non_pbs_storage_is_distinguished() -> None:
guests = [Guest(vmid=100, name="srv-a", guest_type="qemu")]
jobs = [
BackupJob(
job_id="backup-local",
storage="local",
enabled=True,
selection="vmid=100",
)
]
coverage = calculate_backup_coverage(guests, jobs, pbs_storages=[])
assert coverage[0].status == STATUS_NON_PBS_PLANNED
def test_disabled_pbs_storage_is_reported() -> None:
guests = [Guest(vmid=100, name="srv-a", guest_type="qemu")]
jobs = [
BackupJob(
job_id="backup-disabled",
storage="pbs-disabled",
enabled=True,
selection="vmid=100",
)
]
storages = [PbsStorage(storage_id="pbs-disabled", enabled=False)]
coverage = calculate_backup_coverage(guests, jobs, storages)
assert coverage[0].status == STATUS_DISABLED_PBS
def test_pool_job_covers_pool_members_except_excluded() -> None:
guests = [
Guest(vmid=100, name="srv-a", guest_type="qemu"),
Guest(vmid=101, name="srv-b", guest_type="lxc"),
Guest(vmid=102, name="srv-c", guest_type="qemu"),
]
jobs = [
BackupJob(
job_id="backup-pool",
storage="backup-storage",
enabled=True,
selection="pool=prod",
excluded="101",
)
]
storages = [PbsStorage(storage_id="backup-storage", enabled=True)]
pools = [Pool(pool_id="prod", vmids={100, 101})]
coverage = calculate_backup_coverage(guests, jobs, storages, pools)
assert coverage[0].status == STATUS_PBS_PLANNED
assert coverage[1].status == STATUS_MISSING
assert coverage[2].status == STATUS_MISSING
def test_analyze_backup_coverage_preserves_pbs_datastore_usages() -> None:
usage = PbsDatastoreUsage(
server_name="PBS01",
datastore="RAID5",
total_bytes=100,
used_bytes=40,
available_bytes=60,
)
report_data = ReportData(pbs_datastore_usages=[usage])
analyzed = analyze_backup_coverage(report_data)
assert analyzed.pbs_datastore_usages == [usage]
def test_analyze_backup_coverage_preserves_pbs_gc_statuses() -> None:
status = PbsGarbageCollectionStatus(
server_name="PBS02",
datastore="PBS2RAID5",
status="en_cours",
)
report_data = ReportData(pbs_gc_statuses=[status])
analyzed = analyze_backup_coverage(report_data)
assert analyzed.pbs_gc_statuses == [status]
+13
View File
@@ -0,0 +1,13 @@
def test_package_imports() -> None:
import pve_backup_report
import pve_backup_report.cli
import pve_backup_report.collectors
import pve_backup_report.config
import pve_backup_report.coverage
import pve_backup_report.logging_config
import pve_backup_report.models
import pve_backup_report.pbs_client
import pve_backup_report.pve_client
import pve_backup_report.report_pdf
assert pve_backup_report.__version__
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
from pathlib import Path
from pve_backup_report.config import AppConfig, PbsServerConfig
from pve_backup_report.pbs_client import PbsClient, PbsConnectionError, PbsHttpError
class FakeResponse:
def __init__(self, status_code: int, payload: dict, reason: str = "OK") -> None:
self.status_code = status_code
self._payload = payload
self.reason = reason
def json(self) -> dict:
return self._payload
class FakeSession:
def __init__(self, response: FakeResponse) -> None:
self.headers: dict[str, str] = {}
self.response = response
self.calls: list[tuple[str, dict[str, object] | None, int, bool | str]] = []
def get(
self,
url: str,
params: dict[str, object] | None,
timeout: int,
verify: bool | str,
) -> FakeResponse:
self.calls.append((url, params, timeout, verify))
return self.response
def close(self) -> None:
pass
def make_config() -> AppConfig:
return AppConfig(
pve_api_url="https://pve.example.invalid:8006",
pve_api_token_id="backup-report@pve!report",
pve_api_token_secret="secret",
report_output_dir=Path("reports"),
report_timezone="Europe/Paris",
pve_verify_tls=False,
pve_ca_bundle=None,
pve_timeout_seconds=30,
pve_backup_jobs_endpoint="/cluster/backup",
pve_task_history_limit=500,
pve_task_log_limit=5000,
pbs_hostnames={},
pbs_servers=(
PbsServerConfig(
prefix="PBS01",
name="PBS01",
api_url="https://backup-a.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret",
verify_tls=False,
ca_bundle=None,
timeout_seconds=30,
),
PbsServerConfig(
prefix="PBS02",
name="PBS02",
api_url="https://backup-b.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret2",
verify_tls=False,
ca_bundle=None,
timeout_seconds=30,
),
PbsServerConfig(
prefix="PBS10",
name="PBS10",
api_url="https://backup-j.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret10",
verify_tls=False,
ca_bundle=None,
timeout_seconds=120,
),
),
log_level="INFO",
report_filename_prefix="rapport-sauvegardes-pve",
)
def test_get_prune_jobs_uses_pbs_token_auth() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_prune_jobs() == []
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/config/prune", None, 30, False)
]
assert session.headers["Authorization"].startswith(
"PBSAPIToken=backup-report@pbs!report:"
)
assert "secret" in session.headers["Authorization"]
def test_get_datastore_snapshots_uses_namespace_param() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_datastore_snapshots("RAID5", "serveurs-internes") == []
assert session.calls == [
(
"https://backup-a.example.invalid:8007/api2/json/admin/datastore/RAID5/snapshots",
{"ns": "serveurs-internes"},
30,
False,
)
]
def test_get_datastores() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_datastores() == []
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/config/datastore", None, 30, False)
]
def test_get_datastore_status() -> None:
session = FakeSession(FakeResponse(200, {"data": {"total": 100, "used": 40, "avail": 60}}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_datastore_status("RAID5") == {"total": 100, "used": 40, "avail": 60}
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/admin/datastore/RAID5/status", None, 30, False)
]
def test_get_datastore_gc_status() -> None:
session = FakeSession(FakeResponse(200, {"data": {"upid": "UPID:pbs:gc"}}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_datastore_gc_status("RAID5") == {"upid": "UPID:pbs:gc"}
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/admin/datastore/RAID5/gc", None, 30, False)
]
def test_get_datastore_namespaces() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_datastore_namespaces("RAID5") == []
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/admin/datastore/RAID5/namespace", None, 30, False)
]
def test_get_access_users() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_access_users() == []
assert session.calls == [
("https://backup-a.example.invalid:8007/api2/json/access/users", None, 30, False)
]
def test_get_access_permissions() -> None:
session = FakeSession(FakeResponse(200, {"data": {"/datastore/RAID5": {}}}))
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_access_permissions("backup@pbs", "/datastore/RAID5") == {
"/datastore/RAID5": {}
}
assert session.calls == [
(
"https://backup-a.example.invalid:8007/api2/json/access/permissions",
{"auth-id": "backup@pbs", "path": "/datastore/RAID5"},
30,
False,
)
]
def test_pbs02_client_uses_pbs02_settings() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
config = make_config()
client = PbsClient(config, server=config.pbs_servers[1], session=session) # type: ignore[arg-type]
assert client.get_prune_jobs() == []
assert session.calls == [
("https://backup-b.example.invalid:8007/api2/json/config/prune", None, 30, False)
]
assert session.headers["Authorization"].startswith(
"PBSAPIToken=backup-report@pbs!report:"
)
assert "secret2" in session.headers["Authorization"]
def test_pbs10_client_uses_dynamically_discovered_settings() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
config = make_config()
client = PbsClient(config, server=config.pbs_servers[2], session=session) # type: ignore[arg-type]
assert client.get_prune_jobs() == []
assert session.calls == [
("https://backup-j.example.invalid:8007/api2/json/config/prune", None, 120, False)
]
assert session.headers["Authorization"].startswith(
"PBSAPIToken=backup-report@pbs!report:"
)
assert "secret10" in session.headers["Authorization"]
def test_pbs_sanitize_exception_masks_sensitive_values() -> None:
message = PbsClient._sanitize_exception(
PbsConnectionError(
"PBSAPIToken=backup@pbs!report:secret password=secret2 secret=secret3"
)
)
assert "report:secret" not in message
assert "secret2" not in message
assert "secret3" not in message
assert "PBSAPIToken=***" in message
assert "password=***" in message
assert "secret=***" in message
def test_pbs_http_error_message_is_sanitized() -> None:
session = FakeSession(
FakeResponse(
500,
{"data": "PBSAPIToken=backup@pbs!report:secret password=secret2"},
"Server Error",
)
)
client = PbsClient(make_config(), session=session) # type: ignore[arg-type]
try:
client.get_prune_jobs()
except PbsHttpError as exc:
assert "report:secret" not in exc.message
assert "secret2" not in exc.message
assert exc.message == "PBSAPIToken=*** password=***"
else:
raise AssertionError("PbsHttpError attendu")
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
from pathlib import Path
import pytest
from pve_backup_report.config import AppConfig
from pve_backup_report.pve_client import PveClient, PveConnectionError, PveHttpError
class FakeResponse:
def __init__(self, status_code: int, payload: dict, reason: str = "OK") -> None:
self.status_code = status_code
self._payload = payload
self.reason = reason
def json(self) -> dict:
return self._payload
class FakeSession:
def __init__(self, response: FakeResponse) -> None:
self.headers: dict[str, str] = {}
self.response = response
self.calls: list[tuple[str, dict[str, object] | None, int, bool | str]] = []
def get(
self,
url: str,
params: dict[str, object] | None,
timeout: int,
verify: bool | str,
) -> FakeResponse:
self.calls.append((url, params, timeout, verify))
return self.response
def close(self) -> None:
pass
def make_config() -> AppConfig:
return AppConfig(
pve_api_url="https://pve.example.invalid:8006",
pve_api_token_id="backup-report@pve!report",
pve_api_token_secret="secret",
report_output_dir=Path("reports"),
report_timezone="Europe/Paris",
pve_verify_tls=True,
pve_ca_bundle=None,
pve_timeout_seconds=30,
pve_backup_jobs_endpoint="/cluster/backup",
pve_task_history_limit=500,
pve_task_log_limit=5000,
pbs_hostnames={},
pbs_servers=(),
log_level="INFO",
report_filename_prefix="rapport-sauvegardes-pve",
)
def test_get_uses_token_auth_and_returns_data() -> None:
session = FakeSession(FakeResponse(200, {"data": [{"node": "pve1"}]}))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
data = client.get_nodes()
assert data == [{"node": "pve1"}]
assert session.calls == [
("https://pve.example.invalid:8006/api2/json/nodes", None, 30, True)
]
assert session.headers["Authorization"].startswith(
"PVEAPIToken=backup-report@pve!report="
)
assert "secret" in session.headers["Authorization"]
def test_http_error_keeps_endpoint_and_status() -> None:
session = FakeSession(FakeResponse(401, {"message": "permission denied"}, "Unauthorized"))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
with pytest.raises(PveHttpError) as exc_info:
client.get_storages()
assert exc_info.value.endpoint == "/storage"
assert exc_info.value.status_code == 401
def test_http_error_message_is_sanitized() -> None:
session = FakeSession(
FakeResponse(
500,
{"message": "PVEAPIToken=backup@pve!report=secret secret=secret2"},
"Server Error",
)
)
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
with pytest.raises(PveHttpError) as exc_info:
client.get_nodes()
assert "report=secret" not in exc_info.value.message
assert "secret2" not in exc_info.value.message
assert exc_info.value.message == "PVEAPIToken=*** secret=***"
def test_ca_bundle_overrides_boolean_verify_tls(tmp_path: Path) -> None:
ca_bundle = tmp_path / "internal-ca.pem"
ca_bundle.write_text("test ca", encoding="utf-8")
config = make_config()
config = AppConfig(
**{
**config.__dict__,
"pve_ca_bundle": ca_bundle,
}
)
session = FakeSession(FakeResponse(200, {"data": []}))
client = PveClient(config, session=session) # type: ignore[arg-type]
client.get_backup_jobs()
assert session.calls[0][3] == str(ca_bundle)
def test_check_required_endpoints_keeps_testing_after_error() -> None:
class MultiResponseSession(FakeSession):
def __init__(self) -> None:
super().__init__(FakeResponse(200, {"data": []}))
self.responses = [
FakeResponse(200, {"data": [{"node": "pve1"}]}),
FakeResponse(200, {"data": [{"storage": "pbs"}]}),
FakeResponse(200, {"data": [{"subdir": "jobs"}]}),
FakeResponse(404, {"message": "No such endpoint"}),
]
def get(
self,
url: str,
params: dict[str, object] | None,
timeout: int,
verify: bool | str,
) -> FakeResponse:
self.calls.append((url, params, timeout, verify))
return self.responses.pop(0)
client = PveClient(make_config(), session=MultiResponseSession()) # type: ignore[arg-type]
results = client.check_required_endpoints()
assert [result.endpoint for result in results] == [
"/nodes",
"/storage",
"/cluster",
"/cluster/backup",
]
assert [result.ok for result in results] == [True, True, True, False]
assert results[2].detail == "sous-endpoints: jobs"
def test_get_cluster_tasks_without_params() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_cluster_tasks() == []
assert session.calls == [
(
"https://pve.example.invalid:8006/api2/json/cluster/tasks",
None,
30,
True,
)
]
def test_get_node_tasks_without_params() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_node_tasks("pve01") == []
assert session.calls == [
(
"https://pve.example.invalid:8006/api2/json/nodes/pve01/tasks",
None,
30,
True,
)
]
def test_get_task_log_encodes_upid() -> None:
session = FakeSession(FakeResponse(200, {"data": []}))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
upid = "UPID:pve01:123:456:789:vzdump:100:root@pam:"
assert client.get_task_log("pve01", upid) == []
assert session.calls == [
(
"https://pve.example.invalid:8006/api2/json/nodes/pve01/tasks/UPID%3Apve01%3A123%3A456%3A789%3Avzdump%3A100%3Aroot%40pam%3A/log",
{"start": 0, "limit": 5000},
30,
True,
)
]
def test_get_guest_config_endpoints() -> None:
session = FakeSession(FakeResponse(200, {"data": {"description": "note"}}))
client = PveClient(make_config(), session=session) # type: ignore[arg-type]
assert client.get_qemu_config("pve01", 100) == {"description": "note"}
assert client.get_lxc_config("pve02", 200) == {"description": "note"}
assert session.calls == [
(
"https://pve.example.invalid:8006/api2/json/nodes/pve01/qemu/100/config",
None,
30,
True,
),
(
"https://pve.example.invalid:8006/api2/json/nodes/pve02/lxc/200/config",
None,
30,
True,
),
]
def test_pve_sanitize_exception_masks_sensitive_values() -> None:
message = PveClient._sanitize_exception(
PveConnectionError(
"PVEAPIToken=backup@pve!report=secret PVE_API_TOKEN_SECRET=secret2"
)
)
assert "report=secret" not in message
assert "secret2" not in message
assert "PVEAPIToken=***" in message
assert "PVE_API_TOKEN_SECRET=***" in message
+216
View File
@@ -0,0 +1,216 @@
from dataclasses import replace
from pathlib import Path
from pve_backup_report.config import AppConfig, PbsServerConfig
from pve_backup_report.coverage import STATUS_MISSING, STATUS_PBS_PLANNED
from pve_backup_report.models import (
BackupCoverage,
BackupJob,
Guest,
LastBackupResult,
PbsAccessUser,
PbsBackupSnapshotSummary,
PbsRetentionPolicy,
PbsStorage,
ReportData,
)
from pve_backup_report.report_data import build_report_summary, prepare_report_data, report_data_to_dict
def make_config() -> AppConfig:
return AppConfig(
pve_api_url="https://pve.example.invalid:8006",
pve_api_token_id="backup-report@pve!report",
pve_api_token_secret="secret",
report_output_dir=Path("reports"),
report_timezone="Europe/Paris",
pve_verify_tls=False,
pve_ca_bundle=None,
pve_timeout_seconds=30,
pve_backup_jobs_endpoint="/cluster/backup",
pve_task_history_limit=500,
pve_task_log_limit=5000,
pbs_hostnames={},
pbs_servers=(),
log_level="INFO",
report_filename_prefix="rapport-sauvegardes-pve",
)
def test_build_report_summary() -> None:
guest_vm = Guest(vmid=100, name="srv-a", guest_type="qemu")
guest_ct = Guest(vmid=101, name="ct-a", guest_type="lxc")
active_job = BackupJob(job_id="backup-a", enabled=True)
inactive_job = BackupJob(job_id="backup-b", enabled=False)
report_data = ReportData(
pbs_storages=[PbsStorage(storage_id="backup-storage")],
guests=[guest_vm, guest_ct],
backup_jobs=[active_job, inactive_job],
coverage=[
BackupCoverage(guest=guest_vm, status=STATUS_PBS_PLANNED),
BackupCoverage(guest=guest_ct, status=STATUS_MISSING),
],
)
summary = build_report_summary(report_data, make_config())
assert summary.total_vm == 1
assert summary.total_ct == 1
assert summary.total_guests == 2
assert summary.pbs_storage_count == 1
assert summary.backup_job_count == 2
assert summary.active_backup_job_count == 1
assert summary.inactive_backup_job_count == 1
assert summary.pbs_planned_count == 1
assert summary.missing_count == 1
def test_report_data_to_dict_keeps_pdf_inputs() -> None:
guest = Guest(vmid=100, name="srv-a", guest_type="qemu", node="pve01")
job = BackupJob(job_id="backup-a", storage="backup-storage", schedule="23:00")
report_data = ReportData(
pbs_storages=[PbsStorage(storage_id="backup-storage", server="backup.example.invalid")],
pbs_access_users=[
PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs",
user_id="backup@pbs",
storage_id="backup-storage",
permissions={"Datastore.Backup": True},
raw={
"Authorization": "PBSAPIToken=backup@pbs!report:secret",
"password": "secret",
"token": "secret",
},
)
],
pbs_retention_policies=[
PbsRetentionPolicy(
policy_id="prune-prod",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs-internes",
keep_daily=14,
)
],
pbs_snapshot_summaries={
("PBS01", "RAID5", "serveurs-internes", "qemu", 100): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
snapshot_count=3,
raw={
"fingerprint": "aa:bb:cc",
"files": [{"filename": "index.json.blob"}],
"owner": "backup@pbs",
},
)
},
guests=[guest],
backup_jobs=[job],
coverage=[
BackupCoverage(
guest=guest,
status=STATUS_PBS_PLANNED,
jobs=[job],
storages=["backup-storage"],
)
],
last_backup_results={100: LastBackupResult(vmid=100, status="succes")},
)
data = report_data_to_dict(report_data)
assert data["pbs_storages"][0]["id"] == "backup-storage"
assert data["pbs_access_users"][0]["auth_id"] == "backup@pbs"
assert data["pbs_access_users"][0]["server"] == "PBS01"
assert data["pbs_access_users"][0]["user_id"] == "backup@pbs"
assert data["pbs_access_users"][0]["permissions"] == {"Datastore.Backup": True}
assert "raw" not in data["pbs_access_users"][0]
assert data["pbs_server_names"] == []
assert data["pbs_retention_policies"][0]["id"] == "prune-prod"
assert data["pbs_retention_policies"][0]["keep_daily"] == 14
assert data["pbs_snapshot_summaries"][0]["snapshot_count"] == 3
assert data["pbs_snapshot_summaries"][0]["server"] == "PBS01"
assert data["pbs_snapshot_summaries"][0]["type"] == "qemu"
assert "raw" not in data["pbs_snapshot_summaries"][0]
assert data["backup_jobs"][0]["id"] == "backup-a"
assert data["coverage"][0]["vmid"] == 100
assert data["coverage"][0]["jobs"] == ["backup-a"]
assert data["coverage"][0]["last_backup"]["status"] == "succes"
def test_report_data_to_dict_redacts_sensitive_nested_fields() -> None:
report_data = ReportData(
pbs_snapshot_summaries={
("PBS01", "RAID5", "serveurs", "qemu", 100): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs",
snapshot_count=1,
raw={
"nested": {
"api_token_secret": "secret",
"safe": "visible",
},
"ticket": "secret-ticket",
},
)
}
)
data = report_data_to_dict(report_data)
text = str(data)
assert "secret" not in text
assert "ticket" not in text
assert "api_token_secret" not in text
def test_prepare_report_data_keeps_only_configured_pbs_servers() -> None:
config = replace(
make_config(),
pbs_servers=(
PbsServerConfig(
prefix="PBS01",
name="PBS01",
api_url="https://backup.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret",
verify_tls=True,
ca_bundle=None,
timeout_seconds=30,
),
PbsServerConfig(
prefix="PBS04",
name="PBS04",
api_url="https://backup-extra.example.invalid:8007",
api_token_id="backup-report@pbs!report",
api_token_secret="secret4",
verify_tls=True,
ca_bundle=None,
timeout_seconds=30,
),
),
)
access_user = PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs",
user_id="backup@pbs",
storage_id="backup-storage",
)
report_data = prepare_report_data(
ReportData(
pbs_server_names=["PBS01", "PBS02", "PBS03", "PBS04"],
pbs_access_users=[access_user],
),
config,
)
assert report_data.pbs_server_names == ["PBS01", "PBS04"]
assert report_data.pbs_access_users == [access_user]
+489
View File
@@ -0,0 +1,489 @@
from datetime import datetime
from pve_backup_report.models import (
BackupCoverage,
BackupJob,
Guest,
LastBackupResult,
PbsAccessUser,
PbsBackupSnapshotSummary,
PbsDatastoreUsage,
PbsRetentionPolicy,
PbsStorage,
ReportData,
)
from pve_backup_report.report_pdf import coverage_sort_key
from pve_backup_report.report_pdf import coverage_row
from pve_backup_report.report_pdf import backup_retention_row
from pve_backup_report.report_pdf import build_table
from pve_backup_report.report_pdf import build_backup_retention_rows
from pve_backup_report.report_pdf import build_styles
from pve_backup_report.report_pdf import display_retention_delta
from pve_backup_report.report_pdf import expected_retention_versions
from pve_backup_report.report_pdf import format_duration
from pve_backup_report.report_pdf import find_snapshot_summary
from pve_backup_report.report_pdf import format_last_backup
from pve_backup_report.report_pdf import format_size
from pve_backup_report.report_pdf import pbs_datastore_usage_row
from pve_backup_report.report_pdf import pbs_access_user_row
from pve_backup_report.report_pdf import retention_policy_row
from pve_backup_report.report_pdf import add_table_of_contents
from pve_backup_report.report_pdf import unique_report_path
from pve_backup_report.report_pdf import format_pbs_server
def test_unique_report_path_uses_timestamp(tmp_path) -> None:
generated_at = datetime(2026, 5, 7, 2, 0, 0)
path = unique_report_path(tmp_path, "rapport", generated_at)
assert path.name == "rapport-2026-05-07-020000.pdf"
def test_unique_report_path_does_not_overwrite(tmp_path) -> None:
generated_at = datetime(2026, 5, 7, 2, 0, 0)
existing = tmp_path / "rapport-2026-05-07-020000.pdf"
existing.write_text("existing", encoding="utf-8")
path = unique_report_path(tmp_path, "rapport", generated_at)
assert path.name == "rapport-2026-05-07-020000-1.pdf"
def test_format_pbs_server_with_hostname_mapping() -> None:
assert (
format_pbs_server("192.0.2.10", {"192.0.2.10": "backup-display"})
== "192.0.2.10 (backup-display)"
)
def test_format_pbs_server_without_mapping() -> None:
assert format_pbs_server("192.0.2.10", {}) == "192.0.2.10"
def test_format_duration() -> None:
assert format_duration(3723) == "01:02:03"
def test_format_size() -> None:
assert format_size(1536) == "1.5 Kio"
assert format_size(2 * 1024 * 1024 * 1024) == "2.0 Gio"
def test_pbs_datastore_usage_row() -> None:
usage = PbsDatastoreUsage(
server_name="PBS01",
datastore="RAID5",
total_bytes=10 * 1024 * 1024 * 1024,
used_bytes=4 * 1024 * 1024 * 1024,
available_bytes=6 * 1024 * 1024 * 1024,
)
assert pbs_datastore_usage_row(usage) == [
"PBS01",
"RAID5",
"10.0 Gio",
"4.0 Gio",
"6.0 Gio",
]
def test_pbs_access_user_row_displays_permissions() -> None:
user = PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs!pve",
user_id="backup@pbs",
storage_id="BACKUP-PROD",
datastore="RAID5",
namespace="serveurs",
enabled=True,
expire=0,
email="admin@example.invalid",
permissions={"Datastore.Backup": True, "Datastore.Modify": False},
comment="Compte PVE",
)
assert pbs_access_user_row(user) == [
"PBS01",
"backup@pbs!pve",
"BACKUP-PROD",
"RAID5",
"serveurs",
"oui",
"aucune",
"admin@example.invalid",
"Datastore.Backup",
"Compte PVE",
]
def test_build_table_centers_table() -> None:
table = build_table([["Colonne"], ["Valeur"]], [4])
assert table.hAlign == "CENTER"
def test_add_table_of_contents_adds_page_break() -> None:
story: list[object] = []
add_table_of_contents(story, build_styles())
assert len(story) == 3
def test_format_last_backup_includes_duration() -> None:
result = LastBackupResult(
vmid=100,
status="succes",
finished_at=datetime(2026, 5, 7, 2, 14),
duration_seconds=222,
)
assert format_last_backup(result) == "Succes - 2026-05-07 02:14 - duree 00:03:42"
def test_retention_policy_row_splits_columns() -> None:
policy = PbsRetentionPolicy(
policy_id="prune-prod",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs-internes",
schedule="daily",
enabled=True,
keep_last=1,
keep_hourly=2,
keep_daily=14,
keep_weekly=8,
keep_monthly=3,
keep_yearly=1,
max_depth=0,
)
assert retention_policy_row(policy) == [
"PBS01",
"RAID5",
"serveurs-internes",
"daily",
"oui",
1,
2,
14,
8,
3,
1,
0,
]
def test_find_snapshot_summary_matches_storage_namespace_and_guest() -> None:
guest = Guest(vmid=100, name="srv", guest_type="qemu")
item = BackupCoverage(
guest=guest,
status="sauvegarde_pbs_planifiee",
storages=["BACKUP-PRODR5"],
)
summary = PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
snapshot_count=13,
)
assert (
find_snapshot_summary(
item,
{"BACKUP-PRODR5": "RAID5"},
{"BACKUP-PRODR5": "serveurs-internes"},
{("PBS01", "RAID5", "serveurs-internes", "qemu", 100): summary},
"PBS01",
)
== summary
)
def test_find_snapshot_summary_matches_pbs02_without_pve_storage_datastore() -> None:
guest = Guest(vmid=100, name="srv", guest_type="qemu")
item = BackupCoverage(
guest=guest,
status="sauvegarde_pbs_planifiee",
storages=["BACKUP-PRODR5"],
)
summary = PbsBackupSnapshotSummary(
server_name="PBS02",
vmid=100,
guest_type="qemu",
datastore="PBS2RAID5",
namespace="serveurs-internes",
snapshot_count=13,
)
assert (
find_snapshot_summary(
item,
{"BACKUP-PRODR5": "RAID5"},
{"BACKUP-PRODR5": "serveurs-internes"},
{("PBS02", "PBS2RAID5", "serveurs-internes", "qemu", 100): summary},
"PBS02",
)
== summary
)
def test_coverage_sort_key_uses_namespace_then_schedule_then_vmid() -> None:
first = BackupCoverage(
guest=Guest(vmid=200, name="b", guest_type="qemu"),
status="sauvegarde_pbs_planifiee",
jobs=[BackupJob(job_id="backup-b", schedule="22:00")],
storages=["STORAGE-B"],
)
second = BackupCoverage(
guest=Guest(vmid=100, name="a", guest_type="qemu"),
status="sauvegarde_pbs_planifiee",
jobs=[BackupJob(job_id="backup-a", schedule="21:00")],
storages=["STORAGE-A"],
)
third = BackupCoverage(
guest=Guest(vmid=50, name="c", guest_type="qemu"),
status="non_sauvegardee",
)
sorted_items = sorted(
[first, second, third],
key=lambda item: coverage_sort_key(
item,
{
"STORAGE-A": "serveurs-internes",
"STORAGE-B": "Serveurs-PVELAB",
},
),
)
assert [item.guest.vmid for item in sorted_items] == [200, 100, 50]
def test_coverage_row_includes_notes_after_name() -> None:
row = coverage_row(
BackupCoverage(
guest=Guest(vmid=100, name="srv", guest_type="qemu", notes="note applicative"),
status="sauvegarde_pbs_planifiee",
),
include_storage=True,
)
assert row[:3] == [100, "srv", "note applicative"]
def test_coverage_row_uses_french_missing_notes_label() -> None:
row = coverage_row(
BackupCoverage(
guest=Guest(vmid=100, name="srv", guest_type="qemu"),
status="sauvegarde_pbs_planifiee",
),
include_storage=True,
)
assert row[2] == "non renseigné"
def test_backup_retention_row_uses_snapshot_summary() -> None:
summary = PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
snapshot_count=13,
oldest_backup_at=datetime(2026, 3, 27, 21, 30),
newest_backup_at=datetime(2026, 5, 7, 21, 30),
newest_backup_size_bytes=2 * 1024 * 1024 * 1024,
)
assert backup_retention_row(
summary,
Guest(vmid=100, name="srv", guest_type="qemu"),
) == [
100,
"srv",
"serveurs-internes",
"RAID5",
"Active sur PVE",
"13",
"non renseigne",
"non renseigne",
"2026-03-27 21:30",
"2026-05-07 21:30",
"2.0 Gio",
]
def test_display_retention_delta_formats_sign() -> None:
assert display_retention_delta(13, 12) == "+1"
assert display_retention_delta(11, 12) == "-1"
assert display_retention_delta(12, 12) == "0"
assert display_retention_delta(12, None) == "non renseigne"
def test_expected_retention_versions_uses_active_policy_for_snapshot_namespace() -> None:
summary = PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
)
report_data = ReportData(
pbs_retention_policies=[
PbsRetentionPolicy(
policy_id="disabled",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs-internes",
enabled=False,
keep_daily=99,
),
PbsRetentionPolicy(
policy_id="active",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs-internes",
keep_last=1,
keep_daily=7,
keep_weekly=4,
),
]
)
assert expected_retention_versions(report_data, summary) == 12
def test_build_backup_retention_rows_includes_inactive_guest() -> None:
report_data = ReportData(
guests=[
Guest(vmid=100, name="srv", guest_type="qemu"),
],
pbs_storages=[
PbsStorage(storage_id="backup-storage", namespace="serveurs-internes"),
PbsStorage(storage_id="pbs-root", namespace=None),
],
pbs_retention_policies=[
PbsRetentionPolicy(
policy_id="prune-root",
server_name="PBS01",
datastore="RAID5",
namespace="/",
keep_last=5,
),
PbsRetentionPolicy(
policy_id="prune-serveurs",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs-internes",
keep_last=1,
keep_daily=7,
keep_weekly=4,
),
],
pbs_snapshot_summaries={
("PBS01", "RAID5", "serveurs-internes", "qemu", 100): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
snapshot_count=13,
oldest_backup_at=datetime(2026, 3, 27, 21, 30),
newest_backup_at=datetime(2026, 5, 7, 21, 30),
newest_backup_size_bytes=10 * 1024 * 1024,
),
("PBS01", "RAID5", "serveurs-internes", "qemu", 200): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=200,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs-internes",
snapshot_count=4,
oldest_backup_at=datetime(2026, 4, 1, 21, 30),
newest_backup_at=datetime(2026, 5, 8, 21, 30),
newest_backup_size_bytes=3 * 1024 * 1024 * 1024,
),
("PBS01", "RAID5", "hors-pve", "qemu", 300): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=300,
guest_type="qemu",
datastore="RAID5",
namespace="hors-pve",
snapshot_count=2,
oldest_backup_at=datetime(2026, 4, 2, 21, 30),
newest_backup_at=datetime(2026, 5, 8, 21, 45),
),
("PBS01", "RAID5", "/", "qemu", 400): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=400,
guest_type="qemu",
datastore="RAID5",
namespace="/",
snapshot_count=5,
oldest_backup_at=datetime(2026, 4, 3, 21, 30),
newest_backup_at=datetime(2026, 5, 8, 22, 0),
newest_backup_size_bytes=512,
raw={"name": "srv-racine"},
),
},
)
rows = build_backup_retention_rows(report_data, "PBS01")
assert rows[0] == [
"VMID",
"Nom VM/CT",
"Namespace",
"Datastore",
"Etat PVE",
"Nombre de versions",
"Nombre attendu de versions",
"Delta",
"Plus ancienne",
"Plus recente",
"Taille",
]
assert rows[1] == [
400,
"srv-racine",
"/",
"RAID5",
"Non-active sur PVE",
"5",
"5",
"0",
"2026-04-03 21:30",
"2026-05-08 22:00",
"512 o",
]
assert rows[2] == [
100,
"srv",
"serveurs-internes",
"RAID5",
"Active sur PVE",
"13",
"12",
"+1",
"2026-03-27 21:30",
"2026-05-07 21:30",
"10.0 Mio",
]
assert rows[3] == [
200,
"non renseigne",
"serveurs-internes",
"RAID5",
"Non-active sur PVE",
"4",
"12",
"-8",
"2026-04-01 21:30",
"2026-05-08 21:30",
"3.0 Gio",
]
+285
View File
@@ -0,0 +1,285 @@
from datetime import datetime
from pve_backup_report.models import (
BackupCoverage,
BackupJob,
Guest,
PbsAccessUser,
PbsBackupSnapshotSummary,
PbsDatastoreUsage,
PbsGarbageCollectionStatus,
PbsRetentionPolicy,
PbsStorage,
ReportData,
ReportSummary,
)
from pve_backup_report.report_weasy_pdf import build_template_context
from pve_backup_report.report_weasy_pdf import render_html
def test_build_template_context_contains_sections() -> None:
report_data = ReportData(
pbs_storages=[
PbsStorage(
storage_id="BACKUP-PROD",
username="backup@pbs",
server="192.0.2.10",
datastore="RAID5",
namespace="serveurs",
enabled=True,
),
PbsStorage(
storage_id="BACKUP-LAB",
username="backup@pbs",
server="192.0.2.10",
datastore="RAID5",
namespace="lab",
enabled=True,
)
],
pbs_datastore_usages=[
PbsDatastoreUsage(
server_name="PBS01",
datastore="RAID5",
total_bytes=100,
used_bytes=60,
available_bytes=40,
)
],
pbs_access_users=[
PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs",
user_id="backup@pbs",
storage_id="BACKUP-PROD",
datastore="RAID5",
namespace="serveurs",
enabled=True,
expire=0,
email="admin@example.invalid",
permissions={"Datastore.Backup": True, "Datastore.Modify": False},
comment="Compte PVE",
)
],
pbs_gc_statuses=[
PbsGarbageCollectionStatus(
server_name="PBS01",
datastore="RAID5",
status="en_cours",
)
],
pbs_retention_policies=[
PbsRetentionPolicy(
policy_id="prune-serveurs",
server_name="PBS01",
datastore="RAID5",
namespace="serveurs",
keep_daily=3,
),
PbsRetentionPolicy(
policy_id="prune-lab",
server_name="PBS01",
datastore="RAID5",
namespace="lab",
keep_daily=3,
),
],
guests=[
Guest(vmid=100, name="srv", guest_type="qemu", notes="note production"),
Guest(vmid=101, name="lab", guest_type="qemu"),
],
coverage=[
BackupCoverage(
guest=Guest(vmid=100, name="srv", guest_type="qemu", notes="note production"),
status="sauvegarde_pbs_planifiee",
jobs=[BackupJob(job_id="backup-prod", schedule="22:00")],
storages=["BACKUP-PROD"],
),
BackupCoverage(
guest=Guest(vmid=101, name="lab", guest_type="qemu"),
status="sauvegarde_pbs_planifiee",
jobs=[BackupJob(job_id="backup-lab", schedule="21:00")],
storages=["BACKUP-LAB"],
)
],
pbs_snapshot_summaries={
("PBS01", "RAID5", "serveurs", "qemu", 100): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=100,
guest_type="qemu",
datastore="RAID5",
namespace="serveurs",
snapshot_count=3,
oldest_backup_at=datetime(2026, 5, 7, 22, 0),
newest_backup_at=datetime(2026, 5, 9, 22, 0),
),
("PBS01", "RAID5", "lab", "qemu", 101): PbsBackupSnapshotSummary(
server_name="PBS01",
vmid=101,
guest_type="qemu",
datastore="RAID5",
namespace="lab",
snapshot_count=2,
oldest_backup_at=datetime(2026, 5, 8, 21, 0),
newest_backup_at=datetime(2026, 5, 9, 21, 0),
),
},
summary=ReportSummary(generated_at=datetime(2026, 5, 9, 2, 0)),
)
context = build_template_context(
report_data,
{"192.0.2.10": "backup-display"},
)
assert context["generated_at"] == "2026-05-09 02:00"
assert [section.title for section in context["sections"]][:4] == [
"Resume",
"Stockages PBS déclarés sur PVE",
"Utilisateurs PBS - Audit des accès",
"Espaces de stockage PBS",
]
access_section = next(
section for section in context["sections"] if section.title == "Utilisateurs PBS - Audit des accès"
)
assert access_section.headers == [
"Serveur PBS",
"Auth-id",
"Storage PVE",
"Datastore",
"Namespace",
"Actif",
"Expiration",
"Email",
"Permissions",
"Commentaire",
]
assert access_section.rows[0] == [
"PBS01",
"backup@pbs",
"BACKUP-PROD",
"RAID5",
"serveurs",
"oui",
"aucune",
"admin@example.invalid",
"Datastore.Backup",
"Compte PVE",
]
missing_section = next(
section for section in context["sections"] if section.title == "VM/CT non sauvegardees"
)
assert missing_section.headers == ["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"]
coverage_group = next(
section for section in context["sections"] if section.title == "Sauvegarde des VM/CT"
)
assert coverage_group.level == 1
coverage_sections = [
section
for section in context["sections"]
if section.title.startswith("Sauvegarde des VM/CT -")
]
assert [section.title for section in coverage_sections] == [
"Sauvegarde des VM/CT - lab",
"Sauvegarde des VM/CT - serveurs",
]
coverage_section = next(
section for section in coverage_sections if section.title == "Sauvegarde des VM/CT - serveurs"
)
assert "Namespace" not in coverage_section.headers
assert coverage_section.headers[2] == "Notes"
assert coverage_section.rows[0][2] == "note production"
assert coverage_section.rows[0][7] == "192.0.2.10 (backup-display)"
assert coverage_section.rows[0][9] == "non renseigne"
retention_group = next(
section for section in context["sections"] if section.title == "Retention des sauvegardes VM/CT"
)
assert retention_group.level == 1
assert retention_group.warning is None
retention_sections = [
section
for section in context["sections"]
if section.title.startswith("Retention des sauvegardes VM/CT PBS01")
]
assert [section.title for section in retention_sections] == [
"Retention des sauvegardes VM/CT PBS01 - lab",
"Retention des sauvegardes VM/CT PBS01 - serveurs",
]
assert not any(
section.title.startswith("Retention des sauvegardes VM/CT PBS02")
for section in context["sections"]
)
assert not any(
section.title.startswith("Retention des sauvegardes VM/CT PBS03")
for section in context["sections"]
)
assert all("Namespace" not in section.headers for section in retention_sections)
assert all("Datastore" in section.headers for section in retention_sections)
assert all("Nombre attendu de versions" in section.headers for section in retention_sections)
assert all("Delta" in section.headers for section in retention_sections)
assert all("garbage collector" in (section.warning or "") for section in retention_sections)
assert retention_sections[0].rows[0][0] == 101
assert retention_sections[0].rows[0][2] == "RAID5"
assert retention_sections[0].rows[0][3] == "Active sur PVE"
assert retention_sections[0].rows[0][5] == "3"
assert retention_sections[0].rows[0][6] == "-1"
def test_render_html_keeps_css_unescaped() -> None:
html = render_html(ReportData())
assert 'content: "Rapport des sauvegardes Proxmox VE"' in html
assert "&#34;" not in html
assert '<h1 class="section-group-title">' in html
assert "Sauvegarde des VM/CT" in html
assert '<h1 class="section-group-title">Retention des sauvegardes VM/CT</h1>' not in html
def test_pdf_pbs_access_users_table_keeps_expected_fields_without_raw_secrets() -> None:
report_data = ReportData(
pbs_access_users=[
PbsAccessUser(
server_name="PBS01",
auth_id="backup@pbs",
user_id="backup@pbs",
storage_id="BACKUP-PROD",
datastore="RAID5",
namespace="serveurs",
enabled=True,
expire=0,
email="admin@example.invalid",
permissions={"Datastore.Backup": True},
comment="Compte PVE",
raw={
"Authorization": "PBSAPIToken=abc:secret",
"api_token_secret": "secret-value",
"password": "secret-password",
},
)
],
)
html = render_html(report_data)
assert "Utilisateurs PBS - Audit des accès" in html
assert "backup@pbs" in html
assert "BACKUP-PROD" in html
assert "RAID5" in html
assert "serveurs" in html
assert "Datastore.Backup" in html
assert "Compte PVE" in html
assert "PBSAPIToken=abc:secret" not in html
assert "secret-value" not in html
assert "secret-password" not in html
assert "api_token_secret" not in html
def test_build_template_context_omits_unconfigured_retention_servers() -> None:
report_data = ReportData(pbs_server_names=["PBS01"])
context = build_template_context(report_data)
titles = [section.title for section in context["sections"]]
assert "Retention des sauvegardes VM/CT PBS01 - non renseigne" in titles
assert "Retention des sauvegardes VM/CT PBS02 - non renseigne" not in titles
assert "Retention des sauvegardes VM/CT PBS03 - non renseigne" not in titles
+32
View File
@@ -0,0 +1,32 @@
from pve_backup_report.sanitization import sanitize_message
def test_sanitize_message_masks_api_tokens_and_secrets() -> None:
message = (
"PVEAPIToken=user@pve!report=pve-secret "
"PBSAPIToken=user@pbs!report:pbs-secret "
"PBS01_API_TOKEN_SECRET=secret1 "
"password=secret2 "
"secret=secret3"
)
sanitized = sanitize_message(message)
assert "pve-secret" not in sanitized
assert "pbs-secret" not in sanitized
assert "secret1" not in sanitized
assert "secret2" not in sanitized
assert "secret3" not in sanitized
assert "PVEAPIToken=***" in sanitized
assert "PBSAPIToken=***" in sanitized
assert "PBS01_API_TOKEN_SECRET=***" in sanitized
assert "password=***" in sanitized
assert "secret=***" in sanitized
def test_sanitize_message_flattens_newlines() -> None:
assert sanitize_message("line1\nsecret=value") == "line1 secret=***"
def test_sanitize_message_masks_exact_pbs_api_token_shape() -> None:
assert sanitize_message("error PBSAPIToken=abc:secret") == "error PBSAPIToken=***"